diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 92612ce65..61d5ac1d2 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -42,6 +42,15 @@ 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */; }; 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; + 6213388E265F777C00A81A2A /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388D265F777C00A81A2A /* LibraryViewModel.swift */; }; + 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388F265F83A900A81A2A /* LibraryListView.swift */; }; + 621338932660107500A81A2A /* String++.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338922660107500A81A2A /* String++.swift */; }; + 62133895266096EF00A81A2A /* LibraryListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62133894266096EF00A81A2A /* LibraryListViewModel.swift */; }; + 621338B32660A07800A81A2A /* LazyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 621338B22660A07800A81A2A /* LazyView.swift */; }; + 6273DD43265F4195009C1D0B /* Moya in Frameworks */ = {isa = PBXBuildFile; productRef = 6273DD42265F4195009C1D0B /* Moya */; }; + 6273DD45265F4195009C1D0B /* CombineMoya in Frameworks */ = {isa = PBXBuildFile; productRef = 6273DD44265F4195009C1D0B /* CombineMoya */; }; + 6273DD48265F41B3009C1D0B /* JellyfinAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6273DD47265F41B3009C1D0B /* JellyfinAPI.swift */; }; + 6273DD4E265F47B2009C1D0B /* LibrarySearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6273DD4D265F47B2009C1D0B /* LibrarySearchViewModel.swift */; }; AE8C3154265D60BF008AA076 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8C3153265D60BF008AA076 /* SettingsModel.swift */; }; AE8C3156265D616A008AA076 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AE8C3155265D616A008AA076 /* SettingsViewModel.swift */; }; AE8C3159265D6F90008AA076 /* bitrates.json in Resources */ = {isa = PBXBuildFile; fileRef = AE8C3158265D6F90008AA076 /* bitrates.json */; }; @@ -102,6 +111,13 @@ 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; 53F8377C265FF67C00F456B3 /* VideoPlayerSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerSettingsView.swift; sourceTree = ""; }; 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; + 6213388D265F777C00A81A2A /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + 6213388F265F83A900A81A2A /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.swift; sourceTree = ""; }; + 621338922660107500A81A2A /* String++.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String++.swift"; sourceTree = ""; }; + 62133894266096EF00A81A2A /* LibraryListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListViewModel.swift; sourceTree = ""; }; + 621338B22660A07800A81A2A /* LazyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LazyView.swift; sourceTree = ""; }; + 6273DD47265F41B3009C1D0B /* JellyfinAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinAPI.swift; sourceTree = ""; }; + 6273DD4D265F47B2009C1D0B /* LibrarySearchViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchViewModel.swift; sourceTree = ""; }; AE8C3153265D60BF008AA076 /* SettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = ""; }; AE8C3155265D616A008AA076 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; AE8C3158265D6F90008AA076 /* bitrates.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = bitrates.json; sourceTree = ""; }; @@ -114,6 +130,8 @@ files = ( 538CD954263E3DC100BB5AF0 /* SDWebImageSwiftUI in Frameworks */, 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */, + 6273DD45265F4195009C1D0B /* CombineMoya in Frameworks */, + 6273DD43265F4195009C1D0B /* Moya in Frameworks */, 53D5E3DD264B47EE00BADDC8 /* MobileVLCKit.xcframework in Frameworks */, 5338F754263B65E10014BF09 /* SwiftyRequest in Frameworks */, 53352571265EA0A0006CCA86 /* Introspect in Frameworks */, @@ -146,6 +164,10 @@ 5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = { isa = PBXGroup; children = ( + 621338B12660A06F00A81A2A /* SwiftUI */, + 621338912660106C00A81A2A /* Extensions */, + 6273DD4A265F4794009C1D0B /* Domains */, + 6273DD46265F419B009C1D0B /* APIs */, AE8C3157265D6F5E008AA076 /* Resources */, AE8C3152265D607B008AA076 /* ViewModels */, AE8C3151265D6075008AA076 /* Models */, @@ -174,6 +196,7 @@ 53987CA526572F0700E7EA70 /* SeriesItemView.swift */, 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, + 6213388F265F83A900A81A2A /* LibraryListView.swift */, ); path = JellyfinPlayer; sourceTree = ""; @@ -194,6 +217,72 @@ name = Frameworks; sourceTree = ""; }; + 6213388B265F776B00A81A2A /* Library */ = { + isa = PBXGroup; + children = ( + 6213388C265F777100A81A2A /* ViewModels */, + ); + path = Library; + sourceTree = ""; + }; + 6213388C265F777100A81A2A /* ViewModels */ = { + isa = PBXGroup; + children = ( + 6213388D265F777C00A81A2A /* LibraryViewModel.swift */, + 62133894266096EF00A81A2A /* LibraryListViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 621338912660106C00A81A2A /* Extensions */ = { + isa = PBXGroup; + children = ( + 621338922660107500A81A2A /* String++.swift */, + ); + path = Extensions; + sourceTree = ""; + }; + 621338B12660A06F00A81A2A /* SwiftUI */ = { + isa = PBXGroup; + children = ( + 621338B22660A07800A81A2A /* LazyView.swift */, + ); + path = SwiftUI; + sourceTree = ""; + }; + 6273DD46265F419B009C1D0B /* APIs */ = { + isa = PBXGroup; + children = ( + 6273DD47265F41B3009C1D0B /* JellyfinAPI.swift */, + ); + path = APIs; + sourceTree = ""; + }; + 6273DD49265F478E009C1D0B /* Search */ = { + isa = PBXGroup; + children = ( + 6273DD4B265F479B009C1D0B /* ViewModels */, + ); + path = Search; + sourceTree = ""; + }; + 6273DD4A265F4794009C1D0B /* Domains */ = { + isa = PBXGroup; + children = ( + 6213388B265F776B00A81A2A /* Library */, + 6273DD49265F478E009C1D0B /* Search */, + ); + path = Domains; + sourceTree = ""; + }; + 6273DD4B265F479B009C1D0B /* ViewModels */ = { + isa = PBXGroup; + children = ( + 6273DD4D265F47B2009C1D0B /* LibrarySearchViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; AE8C3150265D5FE1008AA076 /* Views */ = { isa = PBXGroup; children = ( @@ -254,6 +343,8 @@ 538CD953263E3DC100BB5AF0 /* SDWebImageSwiftUI */, 5302F8292658791C00647A2E /* Sentry */, 53352570265EA0A0006CCA86 /* Introspect */, + 6273DD42265F4195009C1D0B /* Moya */, + 6273DD44265F4195009C1D0B /* CombineMoya */, ); productName = JellyfinPlayer; productReference = 5377CBF1263B596A003A4E83 /* JellyfinPlayer.app */; @@ -292,6 +383,7 @@ 538CD952263E3DC100BB5AF0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, 5302F8282658791C00647A2E /* XCRemoteSwiftPackageReference "sentry-cocoa" */, 5335256F265EA0A0006CCA86 /* XCRemoteSwiftPackageReference "SwiftUI-Introspect" */, + 6273DD41265F4195009C1D0B /* XCRemoteSwiftPackageReference "Moya" */, ); productRefGroup = 5377CBF2263B596A003A4E83 /* Products */; projectDirPath = ""; @@ -321,6 +413,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 621338932660107500A81A2A /* String++.swift in Sources */, 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */, 5377CBFE263B596B003A4E83 /* PersistenceController.swift in Sources */, 5389276E263C25100035E14B /* ContinueWatchingView.swift in Sources */, @@ -328,6 +421,7 @@ 53F8377D265FF67C00F456B3 /* VideoPlayerSettingsView.swift in Sources */, 53987CA426572C1300E7EA70 /* SeasonItemView.swift in Sources */, 53192D5D265AA78A008A4215 /* DeviceProfileBuilder.swift in Sources */, + 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */, AE8C3154265D60BF008AA076 /* SettingsModel.swift in Sources */, 53892770263C25230035E14B /* NextUpView.swift in Sources */, 535BAEA5264A151C005FA86D /* VideoPlayer.swift in Sources */, @@ -336,6 +430,10 @@ 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelector.swift in Sources */, 53E4E647263F6CF100F67C6B /* LibraryFilterView.swift in Sources */, + 6213388E265F777C00A81A2A /* LibraryViewModel.swift in Sources */, + 621338B32660A07800A81A2A /* LazyView.swift in Sources */, + 62133895266096EF00A81A2A /* LibraryListViewModel.swift in Sources */, + 6273DD48265F41B3009C1D0B /* JellyfinAPI.swift in Sources */, 53892777263CBB000035E14B /* JellyApiTypings.swift in Sources */, 5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */, 53987CA82657424A00E7EA70 /* EpisodeItemView.swift in Sources */, @@ -344,6 +442,7 @@ 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, AE8C3156265D616A008AA076 /* SettingsViewModel.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, + 6273DD4E265F47B2009C1D0B /* LibrarySearchViewModel.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, @@ -597,6 +696,14 @@ minimumVersion = 2.0.2; }; }; + 6273DD41265F4195009C1D0B /* XCRemoteSwiftPackageReference "Moya" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Moya/Moya"; + requirement = { + kind = exactVersion; + version = "15.0.0-alpha.1"; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -630,6 +737,16 @@ package = 538CD952263E3DC100BB5AF0 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; productName = SDWebImageSwiftUI; }; + 6273DD42265F4195009C1D0B /* Moya */ = { + isa = XCSwiftPackageProductDependency; + package = 6273DD41265F4195009C1D0B /* XCRemoteSwiftPackageReference "Moya" */; + productName = Moya; + }; + 6273DD44265F4195009C1D0B /* CombineMoya */ = { + isa = XCSwiftPackageProductDependency; + package = 6273DD41265F4195009C1D0B /* XCRemoteSwiftPackageReference "Moya" */; + productName = CombineMoya; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 627e89b9f..45cf41c3e 100644 --- a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { "object": { "pins": [ + { + "package": "Alamofire", + "repositoryURL": "https://github.com/Alamofire/Alamofire.git", + "state": { + "branch": null, + "revision": "f96b619bcb2383b43d898402283924b80e2c4bae", + "version": "5.4.3" + } + }, { "package": "async-http-client", "repositoryURL": "https://github.com/swift-server/async-http-client.git", @@ -37,6 +46,33 @@ "version": "1.9.200" } }, + { + "package": "Moya", + "repositoryURL": "https://github.com/Moya/Moya", + "state": { + "branch": null, + "revision": "e5a28fb62dd5ff4e17b7025643366550044a40b0", + "version": "15.0.0-alpha.1" + } + }, + { + "package": "ReactiveSwift", + "repositoryURL": "https://github.com/Moya/ReactiveSwift.git", + "state": { + "branch": null, + "revision": "f195d82bb30e412e70446e2b4a77e1b514099e88", + "version": "6.1.0" + } + }, + { + "package": "RxSwift", + "repositoryURL": "https://github.com/ReactiveX/RxSwift.git", + "state": { + "branch": null, + "revision": "254617dd7fae0c45319ba5fbea435bf4d0e15b5d", + "version": "5.1.2" + } + }, { "package": "SDWebImage", "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", diff --git a/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer.xcscheme b/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer.xcscheme new file mode 100644 index 000000000..2cceb211f --- /dev/null +++ b/JellyfinPlayer.xcodeproj/xcshareddata/xcschemes/JellyfinPlayer.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/JellyfinPlayer/APIs/JellyfinAPI.swift b/JellyfinPlayer/APIs/JellyfinAPI.swift new file mode 100644 index 000000000..cebe8c4fb --- /dev/null +++ b/JellyfinPlayer/APIs/JellyfinAPI.swift @@ -0,0 +1,148 @@ +// +// SearchAPI.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/27. +// + +import Foundation +import Moya + +enum ImageType: String { + case primary = "Primary" + case backdrop = "Backdrop" + case thumb = "Thumb" + case banner = "Banner" +} + +enum Field: String { + case primaryImageAspectRatio = "PrimaryImageAspectRatio" + case basicSyncInfo = "BasicSyncInfo" +} + +enum ItemType: String { + case movie = "Movie" + case series = "Series" +} + +enum SortType: String { + case name = "SortName" + case dateCreated = "DateCreated" + case datePlayed = "DatePlayed" + case premiereDate = "PremiereDate" + case runtime = "Runtime" +} + +enum ASC: String { + case descending = "Descending" + case ascending = "Ascending" +} + +enum FilterType: String { + case isFavorite = "IsFavorite" + case isUnplayed = "IsUnplayed" +} + +struct Filter { + var imageTypes: [ImageType] = [.primary, .backdrop, .thumb, .banner] + var fields: [Field] = [.primaryImageAspectRatio, .basicSyncInfo] + var itemTypes: [ItemType] = [.movie, .series] + var filterTypes = [FilterType]() + var sort: SortType? = .dateCreated + var asc: ASC? = .descending + var parentID: String? + var imageTypeLimit: Int? = 1 + var recursive = true + var genres = [String]() + var personIds = [String]() + var officialRatings = [String]() +} + +extension Filter { + var toParamters: [String: Any] { + var parameters = [String: Any]() + parameters["EnableImageTypes"] = imageTypes.map(\.rawValue).joined(separator: ",") + parameters["Fields"] = fields.map(\.rawValue).joined(separator: ",") + parameters["Filters"] = filterTypes.map(\.rawValue).joined(separator: ",") + parameters["ImageTypeLimit"] = imageTypeLimit + parameters["IncludeItemTypes"] = itemTypes.map(\.rawValue).joined(separator: ",") + parameters["ParentId"] = parentID + parameters["Recursive"] = recursive + parameters["SortBy"] = sort?.rawValue + parameters["SortOrder"] = asc?.rawValue + parameters["Genres"] = genres.joined(separator: ",") + parameters["PersonIds"] = personIds.joined(separator: ",") + parameters["OfficialRatings"] = officialRatings.joined(separator: ",") + return parameters + } +} + +enum JellyfinAPI { + case items(globalData: GlobalData, filter: Filter, page: Int) + case search(globalData: GlobalData, filter: Filter, searchQuery: String, page: Int) +} + +extension JellyfinAPI: TargetType { + + var baseURL: URL { + switch self { + case let .items(global, _, _), + let .search(global, _, _, _): + return URL(string: global.server?.baseURI ?? "")! + } + } + + var path: String { + switch self { + case let .items(global, _, _), + let .search(global, _, _, _): + return "/Users/\(global.user?.user_id ?? "")/Items" + } + } + + var method: Moya.Method { + switch self { + case .items, .search: + return .get + } + } + + var sampleData: Data { + "{".data(using: .utf8)! + } + + var task: Task { + switch self { + case let .search(_, filter, searchQuery, page): + var parameters = filter.toParamters + parameters["searchTerm"] = searchQuery + parameters["StartIndex"] = (page - 1) * 100 + parameters["Limit"] = 100 + return .requestParameters(parameters: parameters, encoding: URLEncoding.jellyfin) + case let .items(_, filter, page): + var parameters = filter.toParamters + parameters["StartIndex"] = (page - 1) * 100 + parameters["Limit"] = 100 + return .requestParameters(parameters: parameters, encoding: URLEncoding.jellyfin) + } + } + + var headers: [String: String]? { + switch self { + case let .items(global, _, _), + let .search(global, _, _, _): + var headers = [String: String]() + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + headers["X-Emby-Authorization"] = global.authHeader + return headers + } + } +} + +extension URLEncoding { + + static var jellyfin: URLEncoding { + URLEncoding(destination: .methodDependent, arrayEncoding: .noBrackets, boolEncoding: .literal) + } +} diff --git a/JellyfinPlayer/ContentView.swift b/JellyfinPlayer/ContentView.swift index c36783f0f..27971ece8 100644 --- a/JellyfinPlayer/ContentView.swift +++ b/JellyfinPlayer/ContentView.swift @@ -8,121 +8,149 @@ import SwiftUI import KeychainSwift -import SwiftyRequest -import SwiftyJSON -import Sentry import SDWebImageSwiftUI +import Sentry +import SwiftyJSON +import SwiftyRequest struct ContentView: View { - @Environment(\.managedObjectContext) private var viewContext - @EnvironmentObject var orientationInfo: OrientationInfo - @StateObject private var globalData = GlobalData() - @EnvironmentObject var jsi: justSignedIn + @Environment(\.managedObjectContext) + private var viewContext + @EnvironmentObject + var orientationInfo: OrientationInfo + @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 - @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 isNetworkErrored = false; - @State private var isLoading = false; - @State private var tabSelection: String = "Home"; - @State private var libraries: [String] = []; - @State private var library_names: [String: String] = [:]; - @State private var librariesShowRecentlyAdded: [String] = []; - @State private var libraryPrefillID: String = ""; - @State private var showSettingsPopover: Bool = false; - @State private var viewDidLoad: Bool = false; + @State + private var needsToSelectServer = false + @State + private var isSignInErrored = false + @State + private var isNetworkErrored = false + @State + private var isLoading = false + @State + private var tabSelection: String = "Home" + @State + private var libraries: [String] = [] + @State + private var library_names: [String: String] = [:] + @State + private var librariesShowRecentlyAdded: [String] = [] + @State + private var libraryPrefillID: String = "" + @State + private var showSettingsPopover: Bool = false + @State + private var viewDidLoad: Bool = false func startup() { let size = UIScreen.main.bounds.size if size.width < size.height { - orientationInfo.orientation = .portrait; + orientationInfo.orientation = .portrait } else { - orientationInfo.orientation = .landscape; + orientationInfo.orientation = .landscape } - - if(_viewDidLoad.wrappedValue) { + + if _viewDidLoad.wrappedValue { return } - - _viewDidLoad.wrappedValue = true; + + _viewDidLoad.wrappedValue = true SentrySDK.start { options in options.dsn = "https://75ac77d6af4d406eb989f3d8ef0f119f@o513670.ingest.sentry.io/5778242" options.debug = false // Enabled debug when first installing is always helpful options.tracesSampleRate = 1.0 - options.releaseName = "ios-" + (Bundle.main.infoDictionary?["CFBundleVersion"] as! String); + options.releaseName = "ios-" + (Bundle.main.infoDictionary?["CFBundleVersion"] as! String) options.enableOutOfMemoryTracking = true } - + let cache = SDImageCache(namespace: "tiny") cache.config.maxMemoryCost = 125 * 1024 * 1024 // 125MB memory cache.config.maxDiskSize = 1000 * 1024 * 1024 // 1000MB disk SDImageCachesManager.shared.addCache(cache) SDWebImageManager.defaultImageCache = SDImageCachesManager.shared - + _libraries.wrappedValue = [] _library_names.wrappedValue = [:] _librariesShowRecentlyAdded.wrappedValue = [] - if(servers.isEmpty) { - _isLoading.wrappedValue = false; - _needsToSelectServer.wrappedValue = true; + if servers.isEmpty { + _isLoading.wrappedValue = false + _needsToSelectServer.wrappedValue = true } else { - _isLoading.wrappedValue = true; - let savedUser = savedUsers[0]; + _isLoading.wrappedValue = true + let savedUser = savedUsers[0] - let keychain = KeychainSwift(); - if(keychain.get("AccessToken_\(savedUser.user_id ?? "")") != nil) { + 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 appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String + var header = "MediaBrowser " + header.append("Client=\"SwiftFin\",") + header.append("Device=\"\(UIDevice.current.name.removeRegexMatches(pattern: "[^\\w\\s]"))\",") + header.append("DeviceId=\"\(globalData.user?.device_uuid ?? "")\",") + header.append("Version=\"\(appVersion ?? "0.0.1")\",") + header.append("Token=\"\(globalData.authToken)\"") + globalData.authHeader = header + 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 + + request.responseData { (result: Result, RestError>) in switch result { - case .success( let resp): + case let .success(resp): do { let json = try JSON(data: resp.body) let array2 = json["Configuration"]["LatestItemsExcludes"].arrayObject as? [String] ?? [] - - let request2 = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/Views") + + 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 + + request2.responseData { (result2: Result, RestError>) in switch result2 { - case .success( let resp): + case let .success(resp): do { let json2 = try JSON(data: resp.body) - for (_,item2):(String, JSON) in json2["Items"] { + for (_, item2): (String, JSON) in json2["Items"] { _library_names.wrappedValue[item2["Id"].string ?? ""] = item2["Name"].string ?? "" } - - for (_,item2):(String, JSON) in json2["Items"] { - if(item2["CollectionType"].string == "tvshows" || item2["CollectionType"].string == "movies") { + + for (_, item2): (String, JSON) in json2["Items"] { + if item2["CollectionType"].string == "tvshows" || item2["CollectionType"].string == "movies" { _libraries.wrappedValue.append(item2["Id"].string ?? "") _librariesShowRecentlyAdded.wrappedValue.append(item2["Id"].string ?? "") } } - + _librariesShowRecentlyAdded.wrappedValue = _libraries.wrappedValue.filter { element in - return !array2.contains(element) + !array2.contains(element) } - + _libraries.wrappedValue.forEach { library in - if(_library_names.wrappedValue[library] == nil) { + if _library_names.wrappedValue[library] == nil { _libraries.wrappedValue.removeAll { ele in - if(library == ele) { + if library == ele { return true } else { return false @@ -130,39 +158,32 @@ struct ContentView: View { } } } - + dump(_libraries.wrappedValue) dump(_librariesShowRecentlyAdded.wrappedValue) dump(_library_names.wrappedValue) - } catch { - - } - break - case .failure(let error): + } catch {} + case let .failure(error): SentrySDK.capture(error: error) - break } - let defaults = UserDefaults.standard; - if(defaults.integer(forKey: "InNetworkBandwidth") == 0) { - defaults.setValue(40000000, forKey: "InNetworkBandwidth") + let defaults = UserDefaults.standard + if defaults.integer(forKey: "InNetworkBandwidth") == 0 { + defaults.setValue(40_000_000, forKey: "InNetworkBandwidth") } - if(defaults.integer(forKey: "OutOfNetworkBandwidth") == 0) { - defaults.setValue(40000000, forKey: "OutOfNetworkBandwidth") + if defaults.integer(forKey: "OutOfNetworkBandwidth") == 0 { + defaults.setValue(40_000_000, forKey: "OutOfNetworkBandwidth") } - _isLoading.wrappedValue = false; + _isLoading.wrappedValue = false } - } catch { - - } - break - case .failure( let error): - if(error.response?.status.code == 401) { - _isLoading.wrappedValue = false; - _isSignInErrored.wrappedValue = true; + } catch {} + case let .failure(error): + if error.response?.status.code == 401 { + _isLoading.wrappedValue = false + _isSignInErrored.wrappedValue = true } else { SentrySDK.capture(error: error) - _isLoading.wrappedValue = false; - _isNetworkErrored.wrappedValue = true; + _isLoading.wrappedValue = false + _isNetworkErrored.wrappedValue = true } } } @@ -170,34 +191,39 @@ struct ContentView: View { } var body: some View { - if(needsToSelectServer) { - NavigationView() { + if needsToSelectServer { + NavigationView { ConnectToServerView(isActive: $needsToSelectServer) } .navigationViewStyle(StackNavigationViewStyle()) .environmentObject(globalData) - } else if(isSignInErrored) { - NavigationView() { - ConnectToServerView(skip_server: true, skip_server_prefill: globalData.server, reauth_deviceId: globalData.user?.device_uuid ?? "", isActive: $isSignInErrored) + } else if isSignInErrored { + NavigationView { + ConnectToServerView(skip_server: true, skip_server_prefill: globalData.server, + reauth_deviceId: globalData.user?.device_uuid ?? "", isActive: $isSignInErrored) } .navigationViewStyle(StackNavigationViewStyle()) .environmentObject(globalData) } else { - if(!jsi.did) { + if !jsi.did { LoadingView(isShowing: $isLoading) { TabView(selection: $tabSelection) { - NavigationView() { + NavigationView { VStack(alignment: .leading) { - ScrollView() { + ScrollView { Spacer().frame(height: orientationInfo.orientation == .portrait ? 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)) + 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_id: library_names[library_id] ?? ""], libraries: [library_id], filter: "&SortBy=DateCreated&SortOrder=Descending")) { + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(parentID: library_id)), + title: library_names[library_id] ?? "") + }) { Text("See All").font(.subheadline).fontWeight(.bold) } }.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) @@ -210,29 +236,31 @@ struct ContentView: View { .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { Button { - showSettingsPopover = true; + showSettingsPopover = true } label: { Image(systemName: "gear") } } - }.fullScreenCover( isPresented: $showSettingsPopover) { SettingsView(viewModel: SettingsViewModel(), close: $showSettingsPopover) } + } + .fullScreenCover(isPresented: $showSettingsPopover) { + SettingsView(viewModel: SettingsViewModel(), close: $showSettingsPopover) + } } } .navigationViewStyle(StackNavigationViewStyle()) - .tabItem({ + .tabItem { Text("Home") Image(systemName: "house") - }) + } .tag("Home") - - NavigationView() { - LibraryView(prefill: "", names: library_names, libraries: libraries) + NavigationView { + LibraryListView(viewModel: .init(libraryNames: library_names, libraryIDs: libraries)) } .navigationViewStyle(StackNavigationViewStyle()) - .tabItem({ + .tabItem { Text("All Media") Image(systemName: "folder") - }) + } .tag("All Media") } } @@ -243,13 +271,13 @@ struct ContentView: View { } } else { Text("Signing in...") - .onAppear(perform: { - DispatchQueue.main.async { [self] in - _viewDidLoad.wrappedValue = false - usleep(500000); - self.jsi.did = false; - } - }) + .onAppear(perform: { + DispatchQueue.main.async { [self] in + _viewDidLoad.wrappedValue = false + usleep(500_000) + self.jsi.did = false + } + }) } } } diff --git a/JellyfinPlayer/ContinueWatchingView.swift b/JellyfinPlayer/ContinueWatchingView.swift index 858be88ae..20bd94684 100644 --- a/JellyfinPlayer/ContinueWatchingView.swift +++ b/JellyfinPlayer/ContinueWatchingView.swift @@ -64,7 +64,7 @@ struct ContinueWatchingView: View { let json = try JSON(data: body) for (_,item):(String, JSON) in json["Items"] { // Do something you want - let itemObj = ResumeItem() + var itemObj = ResumeItem() if(item["PrimaryImageAspectRatio"].double ?? 0.0 < 1.0) { //portrait; use backdrop instead itemObj.Image = item["BackdropImageTags"][0].string ?? "" diff --git a/JellyfinPlayer/Domains/Library/ViewModels/LibraryListViewModel.swift b/JellyfinPlayer/Domains/Library/ViewModels/LibraryListViewModel.swift new file mode 100644 index 000000000..2103abe9c --- /dev/null +++ b/JellyfinPlayer/Domains/Library/ViewModels/LibraryListViewModel.swift @@ -0,0 +1,38 @@ +// +// LibraryListViewModel.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/28. +// + +import Combine +import CombineMoya +import Foundation +import Moya +import SwiftyJSON + +final class LibraryListViewModel: ObservableObject { + fileprivate var provider = + MoyaProvider(plugins: [NetworkLoggerPlugin()]) + + @Published + var libraryIDs = [String]() + @Published + var libraryNames = [String: String]() + + fileprivate var cancellables = Set() + + init(libraryNames: [String: String], libraryIDs: [String]) { + self.libraryIDs = libraryIDs + self.libraryNames = libraryNames + refresh() + } + + func refresh() { + libraryIDs.append("favorites") + libraryNames["favorites"] = "Favorites" + + libraryIDs.append("genres") + libraryNames["genres"] = "Genres - WIP" + } +} diff --git a/JellyfinPlayer/Domains/Library/ViewModels/LibraryViewModel.swift b/JellyfinPlayer/Domains/Library/ViewModels/LibraryViewModel.swift new file mode 100644 index 000000000..70a970e56 --- /dev/null +++ b/JellyfinPlayer/Domains/Library/ViewModels/LibraryViewModel.swift @@ -0,0 +1,144 @@ +// +// LibraryViewModel.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/27. +// + +import Combine +import CombineMoya +import Foundation +import Moya +import SwiftyJSON + +final class LibraryViewModel: ObservableObject { + fileprivate var provider = + MoyaProvider(plugins: [NetworkLoggerPlugin()]) + + @Published + var filter: Filter + + @Published + var items = [ResumeItem]() + + @Published + var isLoading: Bool = true + + @Published + var isHiddenPreviousButton = true + @Published + var isHiddenNextButton = true + + var page = 1 + + var globalData = GlobalData() { + didSet { + injectEnvironmentData() + } + } + + fileprivate var cancellables = Set() + + init(filter: Filter = Filter()) { + self.filter = filter + } + + fileprivate func injectEnvironmentData() { + cancellables.removeAll() + + $filter + .sink(receiveValue: requestInitItems(_:)) + .store(in: &cancellables) + } + + func requestNextPage() { + page += 1 + requestItems(filter) + } + + func requestPreviousPage() { + page -= 1 + requestItems(filter) + } + + func requestInitItems(_ filter: Filter) { + page = 1 + requestItems(filter) + } + + fileprivate func requestItems(_ filter: Filter) { + print("ASDASDA") + print(globalData.authHeader) + isLoading = true + provider.requestPublisher(.items(globalData: globalData, filter: filter, page: page)) + // .map(ResumeItem.self) TO DO + .print() + .receive(on: DispatchQueue.main) + .map { response -> ([ResumeItem], Int) in + let body = response.data + var totalCount = 0 + var innerItems = [ResumeItem]() + do { + let json = try JSON(data: body) + totalCount = json["TotalRecordCount"].int ?? 0 + for (_, item): (String, JSON) in json["Items"] { + // Do something you want + var 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 + + innerItems.append(itemObj) + } + } catch {} + return (innerItems, totalCount) + } + .sink(receiveCompletion: { [weak self] _ in + guard let self = self else { return } + self.isLoading = false + }, receiveValue: { [weak self] items, count in + guard let self = self else { return } + if count > 100 { + if self.page > 1 { + self.isHiddenPreviousButton = false + } + if count > (self.page * 100) { + self.isHiddenNextButton = false + } + } else { + self.isHiddenNextButton = true + self.isHiddenPreviousButton = true + } + + self.items = items + }) + .store(in: &cancellables) + } +} diff --git a/JellyfinPlayer/Domains/Search/ViewModels/LibrarySearchViewModel.swift b/JellyfinPlayer/Domains/Search/ViewModels/LibrarySearchViewModel.swift new file mode 100644 index 000000000..c1ee031b6 --- /dev/null +++ b/JellyfinPlayer/Domains/Search/ViewModels/LibrarySearchViewModel.swift @@ -0,0 +1,105 @@ +// +// LibrarySearchViewModel.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/27. +// + +import Combine +import CombineMoya +import Foundation +import Moya +import SwiftyJSON + +final class LibrarySearchViewModel: ObservableObject { + fileprivate var provider = MoyaProvider(plugins: [NetworkLoggerPlugin()]) + + var filter: Filter + + @Published + var items = [ResumeItem]() + + @Published + var searchQuery = "" + @Published + var isLoading: Bool = true + + var page = 1 + + var globalData = GlobalData() { + didSet { + injectEnvironmentData() + } + } + + fileprivate var cancellables = Set() + + init(filter: Filter) { + self.filter = filter + } + + fileprivate func injectEnvironmentData() { + cancellables.removeAll() + + $searchQuery + .debounce(for: 0.25, scheduler: DispatchQueue.main) + .sink(receiveValue: requestSearch(query:)) + .store(in: &cancellables) + } + + fileprivate func requestSearch(query: String) { + isLoading = true + provider.requestPublisher(.search(globalData: globalData, filter: filter, searchQuery: query, page: page)) + // .map(ResumeItem.self) TO DO + .print() + .sink(receiveCompletion: { [weak self] _ in + guard let self = self else { return } + self.isLoading = false + }, receiveValue: { [weak self] response in + guard let self = self else { return } + let body = response.data + var innerItems = [ResumeItem]() + do { + let json = try JSON(data: body) + for (_, item): (String, JSON) in json["Items"] { + // Do something you want + var 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 + + innerItems.append(itemObj) + } + } catch {} + self.items = innerItems + }) + .store(in: &cancellables) + } +} diff --git a/JellyfinPlayer/EpisodeItemView.swift b/JellyfinPlayer/EpisodeItemView.swift index 2c204b8b7..9ec089e63 100644 --- a/JellyfinPlayer/EpisodeItemView.swift +++ b/JellyfinPlayer/EpisodeItemView.swift @@ -5,91 +5,107 @@ // Created by Aiden Vigue on 5/13/21. // +import SDWebImageSwiftUI import SwiftUI -import SwiftyRequest import SwiftyJSON -import SDWebImageSwiftUI +import SwiftyRequest struct EpisodeItemView: View { - @EnvironmentObject private var globalData: GlobalData - @EnvironmentObject private var orientationInfo: OrientationInfo - @EnvironmentObject private var playbackInfo: ItemPlayback - var item: ResumeItem; - var fullItem: DetailItem; + @EnvironmentObject + private var globalData: GlobalData + @EnvironmentObject + private var orientationInfo: OrientationInfo + @EnvironmentObject + private var playbackInfo: ItemPlayback + var item: ResumeItem + var fullItem: DetailItem + + @State + private var isLoading: Bool = true + @State + private var progressString: String = "" + @State + private var viewDidLoad: Bool = false - @State private var isLoading: Bool = true; - @State private var progressString: String = ""; - @State private var viewDidLoad: Bool = false; - - @State private var watched: Bool = false { + @State + private var watched: Bool = false { didSet { - if(watched == true) { + if watched == true { let date = Date() let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" - let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)?DatePlayed=\(formatter.string(from: date).replacingOccurrences(of: ":", with: "%3A"))") + let request = RestRequest(method: .post, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)?DatePlayed=\(formatter.string(from: date).replacingOccurrences(of: ":", with: "%3A"))") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } else { - let request = RestRequest(method: .delete, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)") + let request = RestRequest(method: .delete, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } } - }; - - @State private var favorite: Bool = false { + } + + @State + private var favorite: Bool = false { didSet { - if(favorite == true) { - let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") + if favorite == true { + let request = RestRequest(method: .post, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } else { - let request = RestRequest(method: .delete, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") + let request = RestRequest(method: .delete, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } } - }; - + } + init(item: ResumeItem) { - self.item = item; - fullItem = DetailItem(); + self.item = item + self.fullItem = DetailItem() } - + func loadData() { - if(_viewDidLoad.wrappedValue == true) { + if _viewDidLoad.wrappedValue == true { return } - _viewDidLoad.wrappedValue = true; + _viewDidLoad.wrappedValue = true 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 + + request.responseData { (result: Result, RestError>) in switch result { - case .success(let response): + case let .success(response): let body = response.body do { let json = try JSON(data: body) @@ -110,225 +126,260 @@ struct EpisodeItemView: View { 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; - fullItem.CommunityRating = String(json["CommunityRating"].float ?? 0.0); - fullItem.CriticRating = String(json["CriticRating"].int ?? 0); + fullItem.Watched = json["UserData"]["Played"].bool ?? false + fullItem.CommunityRating = String(json["CommunityRating"].float ?? 0.0) + fullItem.CriticRating = String(json["CriticRating"].int ?? 0) fullItem.ParentId = json["ParentId"].string ?? "" fullItem.ParentBackdropItemId = json["ParentBackdropItemId"].string ?? "" - //People + // People fullItem.Directors = [] fullItem.Studios = [] fullItem.Writers = [] fullItem.Cast = [] fullItem.Genres = [] - - for (_,person):(String, JSON) in json["People"] { - if(person["Type"].stringValue == "Director") { - fullItem.Directors.append(person["Name"].string ?? ""); - } else if(person["Type"].stringValue == "Writer") { - fullItem.Writers.append(person["Name"].string ?? ""); - } else if(person["Type"].stringValue == "Actor") { - let cast = CastMember(); - cast.Name = person["Name"].string ?? ""; - cast.Id = person["Id"].string ?? ""; - let imageTag = person["PrimaryImageTag"].string ?? ""; - cast.ImageBlurHash = person["ImageBlurHashes"]["Primary"][imageTag].string ?? ""; - cast.Role = person["Role"].string ?? ""; - cast.Image = URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(cast.Id)/Images/Primary?maxHeight=250&quality=85&tag=\(imageTag)")! - fullItem.Cast.append(cast); + + for (_, person): (String, JSON) in json["People"] { + if person["Type"].stringValue == "Director" { + fullItem.Directors.append(person["Name"].string ?? "") + } else if person["Type"].stringValue == "Writer" { + fullItem.Writers.append(person["Name"].string ?? "") + } else if person["Type"].stringValue == "Actor" { + let cast = CastMember() + cast.Name = person["Name"].string ?? "" + cast.Id = person["Id"].string ?? "" + let imageTag = person["PrimaryImageTag"].string ?? "" + cast.ImageBlurHash = person["ImageBlurHashes"]["Primary"][imageTag].string ?? "" + cast.Role = person["Role"].string ?? "" + cast + .Image = + URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(cast.Id)/Images/Primary?maxHeight=250&quality=85&tag=\(imageTag)")! + fullItem.Cast.append(cast) } } - - //Studios - for (_,studio):(String, JSON) in json["Studios"] { - fullItem.Studios.append(studio["Name"].string ?? ""); + + // Studios + for (_, studio): (String, JSON) in json["Studios"] { + fullItem.Studios.append(studio["Name"].string ?? "") } - - //Genres - for (_,genre):(String, JSON) in json["GenreItems"] { + + // Genres + for (_, genre): (String, JSON) in json["GenreItems"] { let tmpGenre = IVGenre() - tmpGenre.Id = genre["Id"].string ?? ""; - tmpGenre.Name = genre["Name"].string ?? ""; - fullItem.Genres.append(tmpGenre); + tmpGenre.Id = genre["Id"].string ?? "" + tmpGenre.Name = genre["Name"].string ?? "" + fullItem.Genres.append(tmpGenre) } - + _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) { + _favorite.wrappedValue = json["UserData"]["IsFavorite"].bool ?? false + + // Process runtime + let seconds: Int = ((json["RunTimeTicks"].int ?? 0) / 10_000_000) + 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) { + + if fullItem.Progress != 0 { + let remainingSecs = (Double(json["RunTimeTicks"].int ?? 0) - fullItem.Progress) / 10_000_000 + 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): + } catch {} + case let .failure(error): debugPrint(error) - break } - _isLoading.wrappedValue = false; + _isLoading.wrappedValue = false } } - + var body: some View { LoadingView(isShowing: $isLoading) { - VStack(alignment:.leading) { - if(!isLoading) { - if(orientationInfo.orientation == .portrait) { + VStack(alignment: .leading) { + if !isLoading { + if orientationInfo.orientation == .portrait { GeometryReader { geometry in - VStack() { + VStack { WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.ParentBackdropItemId)/Images/Backdrop?maxWidth=550&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))!) + 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: UIDevice.current.userInterfaceIdiom == .pad ? 350 : (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets + .trailing, + height: UIDevice.current + .userInterfaceIdiom == .pad ? 350 : + (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets + .trailing) * 0.5625) } - + .opacity(0.3) .aspectRatio(contentMode: .fill) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: UIDevice.current.userInterfaceIdiom == .pad ? 350 : (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, + height: UIDevice.current + .userInterfaceIdiom == .pad ? 350 : + (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.SeriesId ?? "")/Images/Primary?maxWidth=250&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) - }.aspectRatio(contentMode: .fill) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Spacer() - Text(fullItem.Name).font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(y: -4) - HStack() { - Text(String(fullItem.ProductionYear)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Text(fullItem.Runtime).font(.subheadline) - .fontWeight(.medium) + .overlay(HStack { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.SeriesId ?? "")/Images/Primary?maxWidth=250&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) + }.aspectRatio(contentMode: .fill) + .frame(width: 120, height: 180) + .cornerRadius(10) + VStack(alignment: .leading) { + Spacer() + Text(fullItem.Name).font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .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) + if fullItem.OfficialRating != "" { + Text(fullItem.OfficialRating).font(.subheadline) + .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) - if(fullItem.OfficialRating != "") { - Text(fullItem.OfficialRating).font(.subheadline) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + if fullItem.CommunityRating != "0" { + HStack { + Image(systemName: "star").foregroundColor(.secondary) + Text(fullItem.CommunityRating).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: -7, y: 0.7) } - if(fullItem.CommunityRating != "0") { - HStack() { - Image(systemName: "star").foregroundColor(.secondary) - Text(fullItem.CommunityRating).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .offset(x: -7, y: 0.7) - } - } - }.frame(maxWidth: .infinity, alignment: .leading) - }.frame(maxWidth: .infinity, alignment: .leading).offset(x: 0, y: UIDevice.current.userInterfaceIdiom == .pad ? -98 : -46).padding(.trailing, 16) - }.offset(x: 16, y: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - , alignment: .bottomLeading) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.frame(maxWidth: .infinity, alignment: .leading) + .offset(x: 0, y: UIDevice.current.userInterfaceIdiom == .pad ? -98 : -46) + .padding(.trailing, 16) + }.offset(x: 16, y: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40), + alignment: .bottomLeading) VStack(alignment: .leading) { - HStack() { - //Play button + HStack { + // Play button Button { - self.playbackInfo.itemToPlay = fullItem; - self.playbackInfo.shouldPlay = true; + self.playbackInfo.itemToPlay = fullItem + self.playbackInfo.shouldPlay = true } label: { - HStack() { - Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + 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(red: 172/255, green: 92/255, blue: 195/255)) + .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) .cornerRadius(10) }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) + .frame(width: 120, height: 35) Spacer() - HStack() { - Button() { + HStack { + Button { favorite.toggle() } label: { - if(!favorite) { + 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)) + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) } } - Button() { + Button { watched.toggle() } label: { - if(watched) { - Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) + 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)) + Image(systemName: "xmark.rectangle").foregroundColor(Color.primary) + .font(.system(size: 20)) } } } - }.padding(.leading, 16).padding(.trailing,16) - ScrollView() { + }.padding(.leading, 16).padding(.trailing, 16) + ScrollView { VStack(alignment: .leading) { - if(fullItem.Tagline != "") { - Text(fullItem.Tagline).font(.body).italic().padding(.top, 7).fixedSize(horizontal: false, vertical: true).padding(.leading, 16).padding(.trailing,16) + if fullItem.Tagline != "" { + Text(fullItem.Tagline).font(.body).italic().padding(.top, 7) + .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) + .padding(.trailing, 16) } - Text(fullItem.Overview).font(.footnote).padding(.top, 3).fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16).padding(.trailing,16) - if(fullItem.Genres.count != 0) { + Text(fullItem.Overview).font(.footnote).padding(.top, 3) + .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) + .padding(.trailing, 16) + if !fullItem.Genres.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - HStack() { + HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(fullItem.Genres, id: \.Id) {genre in - NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { + ForEach(fullItem.Genres, id: \.Id) { genre in + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(genres: [ + genre + .Name, + ])), title: genre.Name) + }) { Text(genre.Name).font(.footnote) } } - }.padding(.leading, 16).padding(.trailing,16) + }.padding(.leading, 16).padding(.trailing, 16) } } - if(fullItem.Cast.count != 0) { + if !fullItem.Cast.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - VStack() { - Spacer().frame(height: 8); - HStack() { + VStack { + Spacer().frame(height: 8) + HStack { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { - VStack() { + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(personIds: [ + cast + .Id, + ])), title: cast.Name) + }) { + VStack { WebImage(url: cast.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: (cast.ImageBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : cast.ImageBlurHash), size: CGSize(width: 16, height: 16))!) + Image(uiImage: UIImage(blurHash: cast + .ImageBlurHash == "" ? + "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : + cast.ImageBlurHash, + size: CGSize(width: 16, + height: 16))!) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) @@ -337,9 +388,11 @@ struct EpisodeItemView: View { .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .cornerRadius(10) - Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1).frame(width: 100).foregroundColor(Color.primary) - if(cast.Role != "") { - Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) + Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1) + .frame(width: 100).foregroundColor(Color.primary) + if cast.Role != "" { + Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1) + .foregroundColor(Color.secondary).frame(width: 100) } } } @@ -350,28 +403,33 @@ struct EpisodeItemView: View { } }.padding(.top, -3) } - if(fullItem.Directors.count != 0) { - HStack() { + if !fullItem.Directors.isEmpty { + HStack { Text("Directors:").font(.callout).fontWeight(.semibold) - Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,16) + Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16).padding(.trailing, 16) } - if(fullItem.Writers.count != 0) { - HStack() { + if !fullItem.Writers.isEmpty { + HStack { Text("Writers:").font(.callout).fontWeight(.semibold) - Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,16) + Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16).padding(.trailing, 16) } - if(fullItem.Studios.count != 0) { - HStack() { + if !fullItem.Studios.isEmpty { + HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,16) + Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16).padding(.trailing, 16) } Spacer().frame(height: 3) } } - }.padding(EdgeInsets(top: UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24, leading: 0, bottom: 0, trailing: 0)) + } + .padding(EdgeInsets(top: UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24, leading: 0, bottom: 0, + trailing: 0)) } } } else { @@ -380,14 +438,21 @@ struct EpisodeItemView: View { WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.ParentBackdropItemId)/Images/Backdrop?maxWidth=\(String(Int(geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing)))&quality=80&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))!) + 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.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets + .trailing, + height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets + .bottom) } - + .opacity(0.3) .aspectRatio(contentMode: .fill) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, + height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) .edgesIgnoringSafeArea(.all) .blur(radius:2) HStack() { @@ -395,7 +460,10 @@ struct EpisodeItemView: View { WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.SeriesId ?? "")/Images/Primary?maxWidth=250&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))!) + 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) } @@ -405,23 +473,24 @@ struct EpisodeItemView: View { .shadow(radius: 5) Spacer().frame(height: 15) Button { - self.playbackInfo.itemToPlay = fullItem; - self.playbackInfo.shouldPlay = true; + self.playbackInfo.itemToPlay = fullItem + self.playbackInfo.shouldPlay = true } label: { - HStack() { - Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + 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(red: 172/255, green: 92/255, blue: 195/255)) + .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) .cornerRadius(10) }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) + .frame(width: 120, height: 35) Spacer() } - ScrollView() { + ScrollView { VStack(alignment: .leading) { - HStack() { + HStack { VStack(alignment: .leading) { Text(fullItem.Name).font(.headline) .fontWeight(.semibold) @@ -429,7 +498,7 @@ struct EpisodeItemView: View { .fixedSize(horizontal: false, vertical: true) .offset(x: 14, y: 0) Spacer().frame(height: 1) - HStack() { + HStack { Text(String(fullItem.ProductionYear)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) @@ -438,19 +507,17 @@ struct EpisodeItemView: View { .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - if(fullItem.OfficialRating != "") { + if fullItem.OfficialRating != "" { 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) - ) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) } - if(fullItem.CommunityRating != "0") { - HStack() { + if fullItem.CommunityRating != "0" { + HStack { Image(systemName: "star").foregroundColor(.secondary) Text(fullItem.CommunityRating).font(.subheadline) .fontWeight(.semibold) @@ -461,59 +528,83 @@ struct EpisodeItemView: View { } Spacer() }.frame(maxWidth: .infinity) - .offset(x: 14) + .offset(x: 14) }.frame(maxWidth: .infinity) Spacer() - HStack() { - Button() { + HStack { + Button { favorite.toggle() } label: { - if(!favorite) { - Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) + 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)) + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) } } - Button() { + Button { watched.toggle() } label: { - if(watched) { - Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) + 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)) + Image(systemName: "xmark.rectangle").foregroundColor(Color.primary) + .font(.system(size: 20)) } } } }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if(fullItem.Tagline != "") { - Text(fullItem.Tagline).font(.body).italic().padding(.top, 3).fixedSize(horizontal: false, vertical: true).padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + if fullItem.Tagline != "" { + Text(fullItem.Tagline).font(.body).italic().padding(.top, 3) + .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - Text(fullItem.Overview).font(.footnote).padding(.top, 3).fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if(fullItem.Genres.count != 0) { + Text(fullItem.Overview).font(.footnote).padding(.top, 3) + .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + if !fullItem.Genres.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - HStack() { + HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(fullItem.Genres, id: \.Id) {genre in - NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { + ForEach(fullItem.Genres, id: \.Id) { genre in + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(genres: [ + genre + .Name, + ])), title: genre.Name) + }) { Text(genre.Name).font(.footnote) } } - }.padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } } - if(fullItem.Cast.count != 0) { + if !fullItem.Cast.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - VStack() { - Spacer().frame(height: 8); - HStack() { + VStack { + Spacer().frame(height: 8) + HStack { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { - VStack() { + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(personIds: [ + cast + .Id, + ])), title: cast.Name) + }) { + VStack { WebImage(url: cast.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: (cast.ImageBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : cast.ImageBlurHash), size: CGSize(width: 16, height: 16))!) + Image(uiImage: UIImage(blurHash: cast + .ImageBlurHash == "" ? + "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : + cast.ImageBlurHash, + size: CGSize(width: 16, + height: 16))!) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) @@ -522,9 +613,11 @@ struct EpisodeItemView: View { .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .cornerRadius(10) - Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1).frame(width: 100).foregroundColor(Color.primary) - if(cast.Role != "") { - Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) + Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1) + .frame(width: 100).foregroundColor(Color.primary) + if cast.Role != "" { + Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1) + .foregroundColor(Color.secondary).frame(width: 100) } } } @@ -535,28 +628,35 @@ struct EpisodeItemView: View { } }.padding(.top, -3) } - if(fullItem.Directors.count != 0) { - HStack() { + if !fullItem.Directors.isEmpty { + HStack { Text("Directors:").font(.callout).fontWeight(.semibold) - Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - if(fullItem.Writers.count != 0) { - HStack() { + if !fullItem.Writers.isEmpty { + HStack { Text("Writers:").font(.callout).fontWeight(.semibold) - Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - if(fullItem.Studios.count != 0) { - HStack() { + if !fullItem.Studios.isEmpty { + HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - Spacer().frame(height: 100); + Spacer().frame(height: 100) }.frame(maxHeight: .infinity) } - }.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55).edgesIgnoringSafeArea(.leading) + }.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + .edgesIgnoringSafeArea(.leading) } } } @@ -565,9 +665,9 @@ struct EpisodeItemView: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle("\(fullItem.Name) - S\(String(fullItem.ParentIndexNumber ?? 0)):E\(String(fullItem.IndexNumber ?? 0)) - \(fullItem.SeriesName ?? "")") }.onAppear(perform: loadData) - .supportedOrientations(.allButUpsideDown) - .overrideViewPreference(.unspecified) - .preferredColorScheme(.none) - .prefersHomeIndicatorAutoHidden(false) + .supportedOrientations(.allButUpsideDown) + .overrideViewPreference(.unspecified) + .preferredColorScheme(.none) + .prefersHomeIndicatorAutoHidden(false) } } diff --git a/JellyfinPlayer/Extensions/String++.swift b/JellyfinPlayer/Extensions/String++.swift new file mode 100644 index 000000000..dfbddfe73 --- /dev/null +++ b/JellyfinPlayer/Extensions/String++.swift @@ -0,0 +1,18 @@ +// +// String++.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/28. +// + +import Foundation + +extension String { + func removeRegexMatches(pattern: String, replaceWith: String = "") -> String { + do { + let regex = try NSRegularExpression(pattern: pattern, options: .caseInsensitive) + let range = NSRange(location: 0, length: count) + return regex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: replaceWith) + } catch { return self } + } +} diff --git a/JellyfinPlayer/JellyApiTypings.swift b/JellyfinPlayer/JellyApiTypings.swift index 2373b3825..9d584c7a4 100644 --- a/JellyfinPlayer/JellyApiTypings.swift +++ b/JellyfinPlayer/JellyApiTypings.swift @@ -53,25 +53,25 @@ struct ServerAuthByNameResponse: Codable { 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 SeasonImage: String? = nil; - @Published var SeasonImageType: String? = nil; - @Published var SeasonImageBlurHash: String? = nil; - @Published var ItemBadge: Int? = 0; - @Published var ProductionYear: Int = 1999; - @Published var Watched: Bool = false; +struct ResumeItem { + var Name: String = ""; + var Id: String = ""; + var IndexNumber: Int? = nil; + var ParentIndexNumber: Int? = nil; + var Image: String = ""; + var ImageType: String = ""; + var BlurHash: String = ""; + var `Type`: String = ""; + var SeasonId: String? = nil; + var SeriesId: String? = nil; + var SeriesName: String? = nil; + var ItemProgress: Double = 0; + var SeasonImage: String? = nil; + var SeasonImageType: String? = nil; + var SeasonImageBlurHash: String? = nil; + var ItemBadge: Int? = 0; + var ProductionYear: Int = 1999; + var Watched: Bool = false; } struct ServerMeResponse: Codable { diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index 6f7230aca..f2572a502 100644 --- a/JellyfinPlayer/JellyfinPlayerApp.swift +++ b/JellyfinPlayer/JellyfinPlayerApp.swift @@ -11,7 +11,7 @@ class justSignedIn: ObservableObject { @Published var did: Bool = false } -class GlobalData: ObservableObject { +class GlobalData: ObservableObject { @Published var user: SignedInUser? @Published var authToken: String = "" @Published var server: Server? @@ -19,6 +19,16 @@ class GlobalData: ObservableObject { @Published var isInNetwork: Bool = true; } +extension GlobalData: Equatable { + + static func == (lhs: GlobalData, rhs: GlobalData) -> Bool { + lhs.user == rhs.user + && lhs.authToken == rhs.authToken + && lhs.server == rhs.server + && lhs.authHeader == rhs.authHeader + } +} + extension UIDevice { var hasNotch: Bool { let bottom = UIApplication.shared.windows.filter {$0.isKeyWindow}.first?.safeAreaInsets.bottom ?? 0 diff --git a/JellyfinPlayer/LatestMediaView.swift b/JellyfinPlayer/LatestMediaView.swift index 210fe1f33..7170af526 100644 --- a/JellyfinPlayer/LatestMediaView.swift +++ b/JellyfinPlayer/LatestMediaView.swift @@ -47,7 +47,7 @@ struct LatestMediaView: View { let json = try JSON(data: body) for (_,item):(String, JSON) in json { // Do something you want - let itemObj = ResumeItem() + var itemObj = ResumeItem() itemObj.Image = item["ImageTags"]["Primary"].string ?? "" itemObj.ImageType = "Primary" itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" diff --git a/JellyfinPlayer/LibraryFilterView.swift b/JellyfinPlayer/LibraryFilterView.swift index a16c4a5b4..43aa3f4b9 100644 --- a/JellyfinPlayer/LibraryFilterView.swift +++ b/JellyfinPlayer/LibraryFilterView.swift @@ -14,173 +14,150 @@ struct Genre: Hashable, Identifiable { 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; - + @Environment(\.presentationMode) + var presentationMode + @Environment(\.managedObjectContext) + private var viewContext + @EnvironmentObject + var globalData: GlobalData + + @State + var library: String + + @Binding + var filter: Filter + @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 + func onAppear() { - if(_viewDidLoad.wrappedValue == true) { + if _viewDidLoad.wrappedValue == true { return } - _viewDidLoad.wrappedValue = true; - if(_output.wrappedValue.contains("&Filters=IsUnplayed")) { - _onlyUnplayed.wrappedValue = true; + _viewDidLoad.wrappedValue = true + if filter.filterTypes.contains(.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 !filter.genres.isEmpty { + _selectedGenres.wrappedValue = Set(filter.genres.map { Genre(name: $0) }) } - 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 ?? "")) - } + if !filter.officialRatings.isEmpty { + _selectedRatings.wrappedValue = Set(filter.officialRatings.map { Genre(name: $0) }) } - 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() + _sortBySelection.wrappedValue = filter.sort?.rawValue ?? sortBySelection + _sortOrder.wrappedValue = filter.asc?.rawValue ?? sortOrder + _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 + + request.responseData { (result: Result, RestError>) in switch result { - case .success(let response): + case let .success(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) + // print(genreName) let genre = Genre(name: genreName) allGenres.append(genre) } - + let arr2 = json["OfficialRatings"].arrayObject as? [String] ?? [] for genreName in arr2 { - //print(genreName) + // print(genreName) let genre = Genre(name: genreName) allRatings.append(genre) } - } catch { - - } - break - case .failure(let error): + } catch {} + case let .failure(error): debugPrint(error) - break } - isLoading = false; + isLoading = false } } - - func recalculateFilters() { - print("recalcFilters running"); - 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() { + NavigationView { LoadingView(isShowing: $isLoading) { Form { Toggle("Only show unplayed items", isOn: $onlyUnplayed) - .onChange(of: onlyUnplayed) { tag in - recalculateFilters() + .onChange(of: onlyUnplayed) { value in + if value { + filter.filterTypes.append(.isUnplayed) + } else { + filter.filterTypes.removeAll { $0 == .isUnplayed } + } } - 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() - } - + MultiSelector(label: "Genres", + options: allGenres, + optionToString: { $0.name }, + selected: $selectedGenres) + .onChange(of: selectedGenres) { genres in + filter.genres = genres.map(\.id) + } + MultiSelector(label: "Parental Ratings", + options: allRatings, + optionToString: { $0.name }, + selected: $selectedRatings) + .onChange(of: selectedRatings) { ratings in + filter.officialRatings = ratings.map(\.id) + } + 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() + Text("Runtime").tag("Runtime") + }.onChange(of: sortBySelection) { value in + guard let sort = SortType(rawValue: value) else { return } + filter.sort = sort } Picker("Sort order", selection: $sortOrder) { Text("Ascending").tag("Ascending") Text("Descending").tag("Descending") - }.onChange(of: sortOrder) { tag in - recalculateFilters() + }.onChange(of: sortOrder) { order in + guard let asc = ASC(rawValue: order) else { return } + filter.asc = asc } } } }.onAppear(perform: onAppear) - .navigationBarTitle("Filters", displayMode: .inline) - .toolbar { - ToolbarItemGroup(placement: .navigationBarLeading) { - Button { - close = false - } label: { - HStack() { - Text("Back").font(.callout) + .navigationBarTitle("Filters", displayMode: .inline) + .toolbar { + ToolbarItemGroup(placement: .navigationBarLeading) { + Button { + presentationMode.wrappedValue.dismiss() + } label: { + HStack { + Text("Back").font(.callout) + } } } } - } } } } diff --git a/JellyfinPlayer/LibraryListView.swift b/JellyfinPlayer/LibraryListView.swift new file mode 100644 index 000000000..907437138 --- /dev/null +++ b/JellyfinPlayer/LibraryListView.swift @@ -0,0 +1,45 @@ +// +// LibraryListView.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/27. +// + +import Foundation +import SwiftUI + +struct LibraryListView: View { + @Environment(\.managedObjectContext) + private var viewContext + @EnvironmentObject + var globalData: GlobalData + @ObservedObject + var viewModel: LibraryListViewModel + + var body: some View { + List(viewModel.libraryIDs, id: \.self) { id in + switch id { + case "favorites": + NavigationLink(destination: LazyView { LibraryView(viewModel: .init(filter: Filter(filterTypes: [.isFavorite])), + title: viewModel.libraryNames[id] ?? "") }) { + Text(viewModel.libraryNames[id] ?? "").foregroundColor(Color.primary) + } + case "genres": + Text(viewModel.libraryNames[id] ?? "").foregroundColor(Color.primary) + default: + NavigationLink(destination: LazyView { LibraryView(viewModel: .init(filter: Filter(parentID: id)), + title: viewModel.libraryNames[id] ?? "") }) { + Text(viewModel.libraryNames[id] ?? "").foregroundColor(Color.primary) + } + } + } + .navigationTitle("All Media") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + NavigationLink(destination: LazyView { LibrarySearchView(viewModel: .init(filter: .init())) }) { + Image(systemName: "magnifyingglass") + } + } + } + } +} diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift index bd8f8d3a8..aa8f5ee1c 100644 --- a/JellyfinPlayer/LibrarySearchView.swift +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -5,193 +5,140 @@ // Created by Aiden Vigue on 5/2/21. // +import SDWebImageSwiftUI import SwiftUI import SwiftyJSON import SwiftyRequest -import SDWebImageSwiftUI struct LibrarySearchView: View { - @Environment(\.managedObjectContext) private var viewContext - @EnvironmentObject var globalData: GlobalData - - @State var url: String; - @Binding var close: Bool; - @State var open: Bool = false; - @State private var isLoading: Bool = true; - @State private var onlyUnplayed: Bool = false; - @State private var viewDidLoad: Bool = false; - @State var items: [ResumeItem] = [] - @State var linkedItem: ResumeItem = ResumeItem(); - @State var searchQuery: String = "" { - didSet { - self.onAppear(); - } - }; + @Environment(\.managedObjectContext) + private var viewContext + @EnvironmentObject + var globalData: GlobalData + @ObservedObject + var viewModel: LibrarySearchViewModel + @State + private var tracks: [GridItem] = [] + + @Environment(\.verticalSizeClass) + var verticalSizeClass: UserInterfaceSizeClass? + @Environment(\.horizontalSizeClass) + var horizontalSizeClass: UserInterfaceSizeClass? + func onAppear() { + guard viewModel.globalData != globalData else { return } recalcTracks() - _isLoading.wrappedValue = true; - _items.wrappedValue = []; - let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + _url.wrappedValue + "&searchTerm=" + searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + (_url.wrappedValue.contains("SortBy") ? "" : "&SortBy=Name&SortOrder=Descending")) - 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.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 = false; - } + viewModel.globalData = globalData } - - @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? - @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? var isPortrait: Bool { let result = verticalSizeClass == .regular && horizontalSizeClass == .compact return result } - + func recalcTracks() { - let trkCnt: Int = Int(floor(UIScreen.main.bounds.size.width / 125)); + let trkCnt = Int(floor(UIScreen.main.bounds.size.width / 125)) _tracks.wrappedValue = [] - for _ in (0.., RestError>) in - switch result { - case .success(let response): - let body = response.body - do { - let json = try JSON(data: body) - _totalItemCount.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; - } + @State + private var showFiltersPopover: Bool = false + @State + private var showingSearchView: Bool = false + + private var title: String + + @State + private var tracks: [GridItem] = [] + + init(viewModel: LibraryViewModel, title: String) { + self.viewModel = viewModel + self.title = title } - + func onAppear() { - if(_prefill_id.wrappedValue != "") { - _selected_library_id.wrappedValue = _prefill_id.wrappedValue; - } - if(_items.wrappedValue.count == 0) { - _firstItemIndex.wrappedValue = 0; - _lastItemIndex.wrappedValue = itemsPerPage; - loadItems() - } - } - - func nextPage() { - _firstItemIndex.wrappedValue = _lastItemIndex.wrappedValue; - _lastItemIndex.wrappedValue = _firstItemIndex.wrappedValue + itemsPerPage; - - if(_lastItemIndex.wrappedValue > _totalItemCount.wrappedValue) { - _firstItemIndex.wrappedValue = _totalItemCount.wrappedValue - itemsPerPage; - _lastItemIndex.wrappedValue = _totalItemCount.wrappedValue; - } - - _items.wrappedValue = []; - loadItems() - } - - func previousPage() { - _lastItemIndex.wrappedValue = _firstItemIndex.wrappedValue; - _firstItemIndex.wrappedValue = _lastItemIndex.wrappedValue - itemsPerPage; - - if(_firstItemIndex.wrappedValue < 0) { - _firstItemIndex.wrappedValue = 0; - _lastItemIndex.wrappedValue = itemsPerPage; - } - - _items.wrappedValue = []; - loadItems() + guard viewModel.globalData != globalData else { return } + recalcTracks() + viewModel.globalData = globalData } - - @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? - @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? + + @Environment(\.verticalSizeClass) + var verticalSizeClass: UserInterfaceSizeClass? + @Environment(\.horizontalSizeClass) + var horizontalSizeClass: UserInterfaceSizeClass? var isPortrait: Bool { let result = verticalSizeClass == .regular && horizontalSizeClass == .compact return result } - + func recalcTracks() { - let trkCnt: Int = Int(floor(UIScreen.main.bounds.size.width / 125)); + let trkCnt = Int(floor(UIScreen.main.bounds.size.width / 125)) _tracks.wrappedValue = [] - for _ in (0.. itemsPerPage) { - HStack() { - Spacer() - if(firstItemIndex != 0) { - Button { - previousPage() - } label: { - Image(systemName: "chevron.left").font(.system(size: 30)) - } - } - if(lastItemIndex != totalItemCount) { - Button { - nextPage() - } label: { - Image(systemName: "chevron.right").font(.system(size: 30)) - } - } - Spacer() + ZStack { + ScrollView(.vertical) { + Spacer().frame(height: 16) + LazyVGrid(columns: tracks) { + ForEach(viewModel.items, id: \.Id) { item in + NavigationLink(destination: ItemView(item: item)) { + ItemGridView(item: item) } } - Spacer().frame(height: 16) - } - .gesture( - DragGesture().onChanged { value in - if value.translation.height > 0 { - print("Scroll down") - } else { - print("Scroll up") - } - } - ) - .onChange(of: isPortrait) { _ in - recalcTracks() } + Spacer().frame(height: 16) } - .overrideViewPreference(.unspecified) - .onAppear(perform: onAppear) - .onChange(of: filterString) { tag in - isLoading = true; - items = []; - firstItemIndex = 0; - lastItemIndex = itemsPerPage; - loadItems(); + .onChange(of: isPortrait) { _ in + recalcTracks() } - .navigationTitle(extraParam == "" ? (library_names[prefill_id] ?? "Library") : title) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - if(totalItemCount > itemsPerPage) { - if(firstItemIndex != 0) { - Button { - previousPage() - } label: { - Image(systemName: "chevron.left") - } - } - if(lastItemIndex != totalItemCount) { - Button { - nextPage() - } label: { - Image(systemName: "chevron.right") - } - } - } - NavigationLink(destination: LibrarySearchView(url: url, close: $closeSearch), isActive: $closeSearch) { - Image(systemName: "magnifyingglass") - } + if viewModel.isLoading { + ActivityIndicator($viewModel.isLoading) + } else if viewModel.items.isEmpty { + Text("Empty Response") + } + } +// .overrideViewPreference(.unspecified) + .onAppear(perform: onAppear) + .navigationTitle(title) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if !viewModel.isHiddenPreviousButton { Button { - showFiltersPopover = true + viewModel.requestPreviousPage() } label: { - Image(systemName: "line.horizontal.3.decrease") + Image(systemName: "chevron.left") } } - }.fullScreenCover( isPresented: self.$showFiltersPopover) { LibraryFilterView(library: selected_library_id, output: $filterString, close: $showFiltersPopover).environmentObject(self.globalData) } - } 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) + if !viewModel.isHiddenNextButton { + Button { + viewModel.requestNextPage() + } label: { + Image(systemName: "chevron.right") } } - }.onAppear(perform: listOnAppear).navigationTitle("All Media") - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - NavigationLink(destination: LibrarySearchView(url: "/Users/\(globalData.user?.user_id ?? "")/Items?Limit=300&StartIndex=0&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb%2CBanner&IncludeItemTypes=Movie,Series\(extraParam)", close: $closeSearch), isActive: $closeSearch) { - Image(systemName: "magnifyingglass") - } + NavigationLink(destination: LazyView { LibrarySearchView(viewModel: .init(filter: viewModel.filter)) }) { + Image(systemName: "magnifyingglass") + } + Button { + showFiltersPopover = true + } label: { + Image(systemName: "line.horizontal.3.decrease") } } - + } + .sheet(isPresented: self.$showFiltersPopover) { + LibraryFilterView(library: viewModel.filter.parentID ?? "", filter: $viewModel.filter) + .environmentObject(self.globalData) + } + } +} + +extension LibraryView { + struct ItemGridView: View { + @EnvironmentObject + var globalData: GlobalData + var item: ResumeItem + + var body: some View { + VStack(alignment: .leading) { + if item.Type == "Movie" { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?maxWidth=250&quality=80&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: 16, height: 16))!) + .resizable() + .frame(width: 100, height: 150) + .cornerRadius(10) + } + .frame(width: 100, height: 150) + .cornerRadius(10) + } else { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?maxWidth=250&quality=80&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: 16, height: 16))!) + .resizable() + .frame(width: 100, height: 150) + .cornerRadius(10) + } + .frame(width: 100, height: 150) + .cornerRadius(10).overlay(ZStack { + if item.ItemBadge == 0 { + Image(systemName: "checkmark") + .font(.caption) + .padding(3) + .foregroundColor(.white) + } else { + Text("\(String(item.ItemBadge ?? 0))") + .font(.caption) + .padding(3) + .foregroundColor(.white) + } + }.background(Color.black) + .opacity(0.8) + .cornerRadius(10.0) + .padding(3), alignment: .topTrailing) + } + Text(item.Name) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Text(String(item.ProductionYear)) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + }.frame(width: 100) } } } diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index 8642325ee..54e120032 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -5,140 +5,198 @@ // Created by Aiden Vigue on 5/13/21. // +import SDWebImageSwiftUI import SwiftUI -import SwiftyRequest import SwiftyJSON -import SDWebImageSwiftUI +import SwiftyRequest 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 = ""; - @Published var Directors: [String] = []; - @Published var Writers: [String] = []; - @Published var CriticRating: String = ""; - @Published var CommunityRating: String = ""; - @Published var Studios: [String] = []; - @Published var ParentId: String = ""; - @Published var Genres: [IVGenre] = []; - @Published var ProgressStr: String = ""; - @Published var ResumeItem: ResumeItem? = nil; - @Published var ParentBackdropItemId: String = ""; + @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 = "" + @Published + var Directors: [String] = [] + @Published + var Writers: [String] = [] + @Published + var CriticRating: String = "" + @Published + var CommunityRating: String = "" + @Published + var Studios: [String] = [] + @Published + var ParentId: String = "" + @Published + var Genres: [IVGenre] = [] + @Published + var ProgressStr: String = "" + @Published + var ResumeItem: ResumeItem? = nil + @Published + var ParentBackdropItemId: String = "" } class IVGenre: ObservableObject { - @Published var Id: String = ""; - @Published var Name: String = ""; + @Published + var Id: String = "" + @Published + var Name: 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")!; + @Published + var Name: String = "" + @Published + var Role: String = "" + @Published + var ImageBlurHash: String = "" + @Published + var Id: String = "" + @Published + var Image = URL(string: "https://example.com")! } struct MovieItemView: View { - @EnvironmentObject private var globalData: GlobalData - @EnvironmentObject private var orientationInfo: OrientationInfo - @EnvironmentObject private var playbackInfo: ItemPlayback - - @State private var isLoading: Bool = true; - - var item: ResumeItem; - var fullItem: DetailItem; - - @State private var progressString: String = ""; - @State private var viewDidLoad: Bool = false; - @State private var watched: Bool = false { + @EnvironmentObject + private var globalData: GlobalData + @EnvironmentObject + private var orientationInfo: OrientationInfo + @EnvironmentObject + private var playbackInfo: ItemPlayback + + @State + private var isLoading: Bool = true + + var item: ResumeItem + var fullItem: DetailItem + + @State + private var progressString: String = "" + @State + private var viewDidLoad: Bool = false + @State + private var watched: Bool = false { didSet { - if(watched == true) { + if watched == true { let date = Date() let formatter = DateFormatter() formatter.locale = Locale(identifier: "en_US_POSIX") formatter.timeZone = TimeZone(secondsFromGMT: 0) - print((globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)?DatePlayed=\(formatter.string(from: date).replacingOccurrences(of: ":", with: "%3A"))") - let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)?DatePlayed=\(formatter.string(from: date).replacingOccurrences(of: ":", with: "%3A"))") + print((globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)?DatePlayed=\(formatter.string(from: date).replacingOccurrences(of: ":", with: "%3A"))") + let request = RestRequest(method: .post, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)?DatePlayed=\(formatter.string(from: date).replacingOccurrences(of: ":", with: "%3A"))") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } else { - let request = RestRequest(method: .delete, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)") + let request = RestRequest(method: .delete, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/PlayedItems/\(fullItem.Id)") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } } - }; - @State private var favorite: Bool = false { + } + + @State + private var favorite: Bool = false { didSet { - if(favorite == true) { - let request = RestRequest(method: .post, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") + if favorite == true { + let request = RestRequest(method: .post, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } else { - let request = RestRequest(method: .delete, url: (globalData.server?.baseURI ?? "") + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") + let request = RestRequest(method: .delete, + url: (globalData.server?.baseURI ?? "") + + "/Users/\(globalData.user?.user_id ?? "")/FavoriteItems/\(fullItem.Id)") request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" - - request.responseData() { (result: Result, RestError>) in + + request.responseData { (_: Result, RestError>) in } } } - }; - + } + init(item: ResumeItem) { - self.item = item; - fullItem = DetailItem(); + self.item = item + self.fullItem = DetailItem() } func loadData() { - if(_viewDidLoad.wrappedValue == true) { - return; + if _viewDidLoad.wrappedValue == true { + return } - _viewDidLoad.wrappedValue = true; + _viewDidLoad.wrappedValue = true 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 + + request.responseData { (result: Result, RestError>) in switch result { - case .success(let response): + case let .success(response): let body = response.body do { let json = try JSON(data: body) @@ -159,224 +217,259 @@ struct MovieItemView: View { 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; - fullItem.CommunityRating = String(json["CommunityRating"].float ?? 0.0); - fullItem.CriticRating = String(json["CriticRating"].int ?? 0); + fullItem.Watched = json["UserData"]["Played"].bool ?? false + fullItem.CommunityRating = String(json["CommunityRating"].float ?? 0.0) + fullItem.CriticRating = String(json["CriticRating"].int ?? 0) fullItem.ParentId = json["ParentId"].string ?? "" - //People + // People fullItem.Directors = [] fullItem.Studios = [] fullItem.Writers = [] fullItem.Cast = [] fullItem.Genres = [] - - for (_,person):(String, JSON) in json["People"] { - if(person["Type"].stringValue == "Director") { - fullItem.Directors.append(person["Name"].string ?? ""); - } else if(person["Type"].stringValue == "Writer") { - fullItem.Writers.append(person["Name"].string ?? ""); - } else if(person["Type"].stringValue == "Actor") { - let cast = CastMember(); - cast.Name = person["Name"].string ?? ""; - cast.Id = person["Id"].string ?? ""; - let imageTag = person["PrimaryImageTag"].string ?? ""; - cast.ImageBlurHash = person["ImageBlurHashes"]["Primary"][imageTag].string ?? ""; - cast.Role = person["Role"].string ?? ""; - cast.Image = URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(cast.Id)/Images/Primary?maxWidth=250&quality=85&tag=\(imageTag)")! - fullItem.Cast.append(cast); + + for (_, person): (String, JSON) in json["People"] { + if person["Type"].stringValue == "Director" { + fullItem.Directors.append(person["Name"].string ?? "") + } else if person["Type"].stringValue == "Writer" { + fullItem.Writers.append(person["Name"].string ?? "") + } else if person["Type"].stringValue == "Actor" { + let cast = CastMember() + cast.Name = person["Name"].string ?? "" + cast.Id = person["Id"].string ?? "" + let imageTag = person["PrimaryImageTag"].string ?? "" + cast.ImageBlurHash = person["ImageBlurHashes"]["Primary"][imageTag].string ?? "" + cast.Role = person["Role"].string ?? "" + cast + .Image = + URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(cast.Id)/Images/Primary?maxWidth=250&quality=85&tag=\(imageTag)")! + fullItem.Cast.append(cast) } } - - //Studios - for (_,studio):(String, JSON) in json["Studios"] { - fullItem.Studios.append(studio["Name"].string ?? ""); + + // Studios + for (_, studio): (String, JSON) in json["Studios"] { + fullItem.Studios.append(studio["Name"].string ?? "") } - - //Genres - for (_,genre):(String, JSON) in json["GenreItems"] { + + // Genres + for (_, genre): (String, JSON) in json["GenreItems"] { let tmpGenre = IVGenre() - tmpGenre.Id = genre["Id"].string ?? ""; - tmpGenre.Name = genre["Name"].string ?? ""; - fullItem.Genres.append(tmpGenre); + tmpGenre.Id = genre["Id"].string ?? "" + tmpGenre.Name = genre["Name"].string ?? "" + fullItem.Genres.append(tmpGenre) } - + _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) { + _favorite.wrappedValue = json["UserData"]["IsFavorite"].bool ?? false + + // Process runtime + let seconds: Int = ((json["RunTimeTicks"].int ?? 0) / 10_000_000) + 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) { + + if fullItem.Progress != 0 { + let remainingSecs = (Double(json["RunTimeTicks"].int ?? 0) - fullItem.Progress) / 10_000_000 + 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" } } - _isLoading.wrappedValue = false; - } catch { - - } - break - case .failure(let error): + _isLoading.wrappedValue = false + } catch {} + case let .failure(error): debugPrint(error) - break } } } - + var body: some View { LoadingView(isShowing: $isLoading) { - VStack(alignment:.leading) { - if(!isLoading) { - if(orientationInfo.orientation == .portrait) { + VStack(alignment: .leading) { + if !isLoading { + if orientationInfo.orientation == .portrait { GeometryReader { geometry in - VStack() { + VStack { WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Backdrop?maxWidth=550&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))!) + 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: UIDevice.current.userInterfaceIdiom == .pad ? 350 : (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets + .trailing, + height: UIDevice.current + .userInterfaceIdiom == .pad ? 350 : + (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets + .trailing) * 0.5625) } - + .opacity(0.3) .aspectRatio(contentMode: .fill) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: UIDevice.current.userInterfaceIdiom == .pad ? 350 : (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, + height: UIDevice.current + .userInterfaceIdiom == .pad ? 350 : + (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?maxWidth=250&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) - }.aspectRatio(contentMode: .fill) - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Spacer() - Text(fullItem.Name).font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) - .fixedSize(horizontal: false, vertical: true) - .offset(y: -4) - HStack() { - Text(String(fullItem.ProductionYear)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Text(fullItem.Runtime).font(.subheadline) - .fontWeight(.medium) + .overlay(HStack { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Primary?maxWidth=250&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) + }.aspectRatio(contentMode: .fill) + .frame(width: 120, height: 180) + .cornerRadius(10) + VStack(alignment: .leading) { + Spacer() + Text(fullItem.Name).font(.headline) + .fontWeight(.semibold) + .foregroundColor(.primary) + .fixedSize(horizontal: false, vertical: true) + .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) + if fullItem.OfficialRating != "" { + Text(fullItem.OfficialRating).font(.subheadline) + .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) - if(fullItem.OfficialRating != "") { - Text(fullItem.OfficialRating).font(.subheadline) + .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) + } + if fullItem.CommunityRating != "0" { + HStack { + Image(systemName: "star").foregroundColor(.secondary) + Text(fullItem.CommunityRating).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) .lineLimit(1) - .padding(EdgeInsets(top: 1, leading: 4, bottom: 1, trailing: 4)) - .overlay( - RoundedRectangle(cornerRadius: 2) - .stroke(Color.secondary, lineWidth: 1) - ) - } - if(fullItem.CommunityRating != "0") { - HStack() { - Image(systemName: "star").foregroundColor(.secondary) - Text(fullItem.CommunityRating).font(.subheadline) - .fontWeight(.semibold) - .foregroundColor(.secondary) - .lineLimit(1) - .offset(x: -7, y: 0.7) - } + .offset(x: -7, y: 0.7) } - }.frame(maxWidth: .infinity, alignment: .leading) - }.offset(x: 0, y: UIDevice.current.userInterfaceIdiom == .pad ? -98 : -46).padding(.trailing, 16) - }.offset(x: 16, y: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40) - , alignment: .bottomLeading) + } + }.frame(maxWidth: .infinity, alignment: .leading) + }.offset(x: 0, y: UIDevice.current.userInterfaceIdiom == .pad ? -98 : -46) + .padding(.trailing, 16) + }.offset(x: 16, y: UIDevice.current.userInterfaceIdiom == .pad ? 135 : 40), + alignment: .bottomLeading) VStack(alignment: .leading) { - HStack() { - //Play button + HStack { + // Play button Button { - self.playbackInfo.itemToPlay = fullItem; - self.playbackInfo.shouldPlay = true; + self.playbackInfo.itemToPlay = fullItem + self.playbackInfo.shouldPlay = true } label: { - HStack() { - Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + 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(red: 172/255, green: 92/255, blue: 195/255)) + .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) .cornerRadius(10) }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) + .frame(width: 120, height: 35) Spacer() - HStack() { - Button() { + HStack { + Button { favorite.toggle() } label: { - if(!favorite) { + 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)) + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) } } - Button() { + Button { watched.toggle() } label: { - if(watched) { - Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) + 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)) + Image(systemName: "xmark.rectangle").foregroundColor(Color.primary) + .font(.system(size: 20)) } } } - }.padding(.leading, 16).padding(.trailing,16) - ScrollView() { + }.padding(.leading, 16).padding(.trailing, 16) + ScrollView { VStack(alignment: .leading) { - if(fullItem.Tagline != "") { - Text(fullItem.Tagline).font(.body).italic().padding(.top, 7).fixedSize(horizontal: false, vertical: true).padding(.leading, 16).padding(.trailing,16) + if fullItem.Tagline != "" { + Text(fullItem.Tagline).font(.body).italic().padding(.top, 7) + .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) + .padding(.trailing, 16) } - Text(fullItem.Overview).font(.footnote).padding(.top, 3).fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16).padding(.trailing,16) - if(fullItem.Genres.count != 0) { + Text(fullItem.Overview).font(.footnote).padding(.top, 3) + .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) + .padding(.trailing, 16) + if !fullItem.Genres.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - HStack() { + HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(fullItem.Genres, id: \.Id) {genre in - NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { + ForEach(fullItem.Genres, id: \.Id) { genre in + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(genres: [ + genre + .Name, + ])), + title: genre.Name) + }) { Text(genre.Name).font(.footnote) } } - }.padding(.leading, 16).padding(.trailing,16) + }.padding(.leading, 16).padding(.trailing, 16) } } - if(fullItem.Cast.count != 0) { + if !fullItem.Cast.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - VStack() { - Spacer().frame(height: 8); - HStack() { + VStack { + Spacer().frame(height: 8) + HStack { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { - VStack() { + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(personIds: [ + cast + .Id, + ])), title: cast.Name) + }) { + VStack { WebImage(url: cast.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: (cast.ImageBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : cast.ImageBlurHash), size: CGSize(width: 16, height: 16))!) + Image(uiImage: UIImage(blurHash: cast + .ImageBlurHash == "" ? + "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : + cast.ImageBlurHash, + size: CGSize(width: 16, + height: 16))!) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) @@ -385,9 +478,11 @@ struct MovieItemView: View { .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .cornerRadius(10) - Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1).frame(width: 100).foregroundColor(Color.primary) - if(cast.Role != "") { - Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) + Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1) + .frame(width: 100).foregroundColor(Color.primary) + if cast.Role != "" { + Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1) + .foregroundColor(Color.secondary).frame(width: 100) } } } @@ -398,28 +493,33 @@ struct MovieItemView: View { } }.padding(.top, -3) } - if(fullItem.Directors.count != 0) { - HStack() { + if !fullItem.Directors.isEmpty { + HStack { Text("Directors:").font(.callout).fontWeight(.semibold) - Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,16) + Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16).padding(.trailing, 16) } - if(fullItem.Writers.count != 0) { - HStack() { + if !fullItem.Writers.isEmpty { + HStack { Text("Writers:").font(.callout).fontWeight(.semibold) - Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,16) + Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16).padding(.trailing, 16) } - if(fullItem.Studios.count != 0) { - HStack() { + if !fullItem.Studios.isEmpty { + HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,16) + Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16).padding(.trailing, 16) } Spacer().frame(height: 3) } } - }.padding(EdgeInsets(top: UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24, leading: 0, bottom: 0, trailing: 0)) + } + .padding(EdgeInsets(top: UIDevice.current.userInterfaceIdiom == .pad ? 54 : 24, leading: 0, bottom: 0, + trailing: 0)) } } } else { @@ -428,14 +528,21 @@ struct MovieItemView: View { WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Backdrop?maxWidth=\(String(Int(geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing)))&quality=80&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: 16, height: 16))!) + Image(uiImage: UIImage(blurHash: fullItem + .BackdropBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem + .BackdropBlurHash, + size: CGSize(width: 16, height: 16))!) .resizable() - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets + .trailing, + height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets + .bottom) } - + .opacity(0.3) .aspectRatio(contentMode: .fill) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, + height: geometry.size.height + geometry.safeAreaInsets.top + geometry.safeAreaInsets.bottom) .edgesIgnoringSafeArea(.all) .blur(radius:2) HStack() { @@ -443,7 +550,10 @@ struct MovieItemView: View { WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Primary?maxWidth=250&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: 16, height: 16))!) + Image(uiImage: UIImage(blurHash: fullItem + .PosterBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : + fullItem.PosterBlurHash, + size: CGSize(width: 16, height: 16))!) .resizable() .frame(width: 120, height: 180) } @@ -452,23 +562,24 @@ struct MovieItemView: View { .shadow(radius: 5) Spacer().frame(height: 15) Button { - self.playbackInfo.itemToPlay = fullItem; - self.playbackInfo.shouldPlay = true; + self.playbackInfo.itemToPlay = fullItem + self.playbackInfo.shouldPlay = true } label: { - HStack() { - Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + 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(red: 172/255, green: 92/255, blue: 195/255)) + .background(Color(red: 172 / 255, green: 92 / 255, blue: 195 / 255)) .cornerRadius(10) }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 35) + .frame(width: 120, height: 35) Spacer() } - ScrollView() { + ScrollView { VStack(alignment: .leading) { - HStack() { + HStack { VStack(alignment: .leading) { Text(fullItem.Name).font(.headline) .fontWeight(.semibold) @@ -476,7 +587,7 @@ struct MovieItemView: View { .fixedSize(horizontal: false, vertical: true) .offset(x: 14, y: 0) Spacer().frame(height: 1) - HStack() { + HStack { Text(String(fullItem.ProductionYear)).font(.subheadline) .fontWeight(.medium) .foregroundColor(.secondary) @@ -485,19 +596,17 @@ struct MovieItemView: View { .fontWeight(.medium) .foregroundColor(.secondary) .lineLimit(1) - if(fullItem.OfficialRating != "") { + if fullItem.OfficialRating != "" { 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) - ) + .overlay(RoundedRectangle(cornerRadius: 2) + .stroke(Color.secondary, lineWidth: 1)) } - if(fullItem.CommunityRating != "0") { - HStack() { + if fullItem.CommunityRating != "0" { + HStack { Image(systemName: "star").foregroundColor(.secondary) Text(fullItem.CommunityRating).font(.subheadline) .fontWeight(.semibold) @@ -508,59 +617,84 @@ struct MovieItemView: View { } Spacer() }.frame(maxWidth: .infinity, alignment: .leading) - .offset(x: 14) + .offset(x: 14) }.frame(maxWidth: .infinity, alignment: .leading) Spacer() - HStack() { - Button() { + HStack { + Button { favorite.toggle() } label: { - if(!favorite) { - Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) + 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)) + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)) + .font(.system(size: 20)) } } - Button() { + Button { watched.toggle() } label: { - if(watched) { - Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) + 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)) + Image(systemName: "xmark.rectangle").foregroundColor(Color.primary) + .font(.system(size: 20)) } } } }.padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if(fullItem.Tagline != "") { - Text(fullItem.Tagline).font(.body).italic().padding(.top, 3).fixedSize(horizontal: false, vertical: true).padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + if fullItem.Tagline != "" { + Text(fullItem.Tagline).font(.body).italic().padding(.top, 3) + .fixedSize(horizontal: false, vertical: true).padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - Text(fullItem.Overview).font(.footnote).padding(.top, 3).fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) - if(fullItem.Genres.count != 0) { + Text(fullItem.Overview).font(.footnote).padding(.top, 3) + .fixedSize(horizontal: false, vertical: true).padding(.bottom, 3).padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + if !fullItem.Genres.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - HStack() { + HStack { Text("Genres:").font(.callout).fontWeight(.semibold) - ForEach(fullItem.Genres, id: \.Id) {genre in - NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { + ForEach(fullItem.Genres, id: \.Id) { genre in + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(genres: [ + genre + .Name, + ])), + title: genre.Name) + }) { Text(genre.Name).font(.footnote) } } - }.padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } } - if(fullItem.Cast.count != 0) { + if !fullItem.Cast.isEmpty { ScrollView(.horizontal, showsIndicators: false) { - VStack() { - Spacer().frame(height: 8); - HStack() { + VStack { + Spacer().frame(height: 8) + HStack { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { - VStack() { + NavigationLink(destination: LazyView { + LibraryView(viewModel: .init(filter: Filter(personIds: [ + cast + .Id, + ])), title: cast.Name) + }) { + VStack { WebImage(url: cast.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: (cast.ImageBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : cast.ImageBlurHash), size: CGSize(width: 16, height: 16))!) + Image(uiImage: UIImage(blurHash: cast + .ImageBlurHash == "" ? + "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : + cast.ImageBlurHash, + size: CGSize(width: 16, + height: 16))!) .resizable() .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) @@ -569,9 +703,11 @@ struct MovieItemView: View { .aspectRatio(contentMode: .fill) .frame(width: 100, height: 100) .cornerRadius(10) - Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1).frame(width: 100).foregroundColor(Color.primary) - if(cast.Role != "") { - Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) + Text(cast.Name).font(.footnote).fontWeight(.regular).lineLimit(1) + .frame(width: 100).foregroundColor(Color.primary) + if cast.Role != "" { + Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1) + .foregroundColor(Color.secondary).frame(width: 100) } } } @@ -580,30 +716,38 @@ struct MovieItemView: View { Spacer().frame(width: 55) } } - }.padding(.top, -3).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? -55 : 0) + }.padding(.top, -3) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? -55 : 0) } - if(fullItem.Directors.count != 0) { - HStack() { + if !fullItem.Directors.isEmpty { + HStack { Text("Directors:").font(.callout).fontWeight(.semibold) - Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + Text(fullItem.Directors.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - if(fullItem.Writers.count != 0) { - HStack() { + if !fullItem.Writers.isEmpty { + HStack { Text("Writers:").font(.callout).fontWeight(.semibold) - Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + Text(fullItem.Writers.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - if(fullItem.Studios.count != 0) { - HStack() { + if !fullItem.Studios.isEmpty { + HStack { Text("Studios:").font(.callout).fontWeight(.semibold) - Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1).foregroundColor(Color.secondary) - }.padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + Text(fullItem.Studios.joined(separator: ", ")).font(.footnote).lineLimit(1) + .foregroundColor(Color.secondary) + }.padding(.leading, 16) + .padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } - Spacer().frame(height: 195); + Spacer().frame(height: 195) }.frame(maxHeight: .infinity) } - }.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55).edgesIgnoringSafeArea(.leading) + }.padding(.top, 16).padding(.leading, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) + .edgesIgnoringSafeArea(.leading) } } } @@ -612,9 +756,9 @@ struct MovieItemView: View { .navigationBarTitleDisplayMode(.inline) .navigationTitle(fullItem.Name) }.onAppear(perform: loadData) - .supportedOrientations(.allButUpsideDown) - .overrideViewPreference(.unspecified) - .preferredColorScheme(.none) - .prefersHomeIndicatorAutoHidden(false) + .supportedOrientations(.allButUpsideDown) + .overrideViewPreference(.unspecified) + .preferredColorScheme(.none) + .prefersHomeIndicatorAutoHidden(false) } } diff --git a/JellyfinPlayer/NextUpView.swift b/JellyfinPlayer/NextUpView.swift index a5117f18f..7b6e7a83e 100644 --- a/JellyfinPlayer/NextUpView.swift +++ b/JellyfinPlayer/NextUpView.swift @@ -39,7 +39,7 @@ struct NextUpView: View { let json = try JSON(data: body) for (_,item):(String, JSON) in json["Items"] { // Do something you want - let itemObj = ResumeItem() + var itemObj = ResumeItem() itemObj.Image = item["SeriesPrimaryImageTag"].string ?? "" itemObj.ImageType = "Primary" itemObj.BlurHash = item["ImageBlurHashes"]["Primary"][itemObj.Image].string ?? "" diff --git a/JellyfinPlayer/SeasonItemView.swift b/JellyfinPlayer/SeasonItemView.swift index 0f449b096..c2028be42 100644 --- a/JellyfinPlayer/SeasonItemView.swift +++ b/JellyfinPlayer/SeasonItemView.swift @@ -123,7 +123,7 @@ struct SeasonItemView: View { episode.ParentId = episode.SeasonId ?? "" episode.CommunityRating = String(json["CommunityRating"].float ?? 0.0) - let rI = ResumeItem() + var rI = ResumeItem() rI.Name = episode.Name rI.Id = episode.Id rI.IndexNumber = episode.IndexNumber diff --git a/JellyfinPlayer/SeriesItemView.swift b/JellyfinPlayer/SeriesItemView.swift index 037eeddfe..279959ab8 100644 --- a/JellyfinPlayer/SeriesItemView.swift +++ b/JellyfinPlayer/SeriesItemView.swift @@ -37,7 +37,7 @@ struct SeriesItemView: View { let json = try JSON(data: body) for (_,item):(String, JSON) in json["Items"] { // Do something you want - let itemObj = ResumeItem() + var itemObj = ResumeItem() itemObj.Type = "Season" itemObj.Id = item["Id"].string ?? "" itemObj.ProductionYear = item["ProductionYear"].int ?? 0 diff --git a/JellyfinPlayer/SwiftUI/LazyView.swift b/JellyfinPlayer/SwiftUI/LazyView.swift new file mode 100644 index 000000000..b0f5630f0 --- /dev/null +++ b/JellyfinPlayer/SwiftUI/LazyView.swift @@ -0,0 +1,16 @@ +// +// LazyView.swift +// JellyfinPlayer +// +// Created by PangMo5 on 2021/05/28. +// + +import Foundation +import SwiftUI + +struct LazyView: View { + var content: () -> Content + var body: some View { + self.content() + } +} diff --git a/JellyfinPlayer/Views/SettingsView.swift b/JellyfinPlayer/Views/SettingsView.swift index 4b520ece0..e619d2a4f 100644 --- a/JellyfinPlayer/Views/SettingsView.swift +++ b/JellyfinPlayer/Views/SettingsView.swift @@ -5,34 +5,44 @@ // Created by Aiden Vigue on 4/29/21. // -import SwiftUI import CoreData +import SwiftUI struct SettingsView: View { - @ObservedObject var viewModel: SettingsViewModel + @ObservedObject + var viewModel: SettingsViewModel + + @Binding + var close: Bool + @Environment(\.managedObjectContext) + private var viewContext + @EnvironmentObject + var globalData: GlobalData + @EnvironmentObject + var jsi: justSignedIn + @State + private var username: String = "" + @State + private var inNetworkStreamBitrate: Int = 40_000_000 + @State + private var outOfNetworkStreamBitrate: Int = 40_000_000 + @State + private var autoSelectSubtitles: Bool = false + @State + private var autoSelectSubtitlesLangcode: String = "none" - @Binding var close: Bool; - @Environment(\.managedObjectContext) private var viewContext - @EnvironmentObject var globalData: GlobalData - @EnvironmentObject var jsi: justSignedIn - @State private var username: String = ""; - @State private var inNetworkStreamBitrate: Int = 40000000; - @State private var outOfNetworkStreamBitrate: Int = 40000000; - @State private var autoSelectSubtitles: Bool = false; - @State private var autoSelectSubtitlesLangcode: String = "none"; - func onAppear() { - _username.wrappedValue = globalData.user?.username ?? ""; + _username.wrappedValue = globalData.user?.username ?? "" let defaults = UserDefaults.standard - _inNetworkStreamBitrate.wrappedValue = defaults.integer(forKey: "InNetworkBandwidth"); - _outOfNetworkStreamBitrate.wrappedValue = defaults.integer(forKey: "OutOfNetworkBandwidth"); - _autoSelectSubtitles.wrappedValue = defaults.bool(forKey: "AutoSelectSubtitles"); - _autoSelectSubtitlesLangcode.wrappedValue = defaults.string(forKey: "AutoSelectSubtitlesLangcode") ?? ""; + _inNetworkStreamBitrate.wrappedValue = defaults.integer(forKey: "InNetworkBandwidth") + _outOfNetworkStreamBitrate.wrappedValue = defaults.integer(forKey: "OutOfNetworkBandwidth") + _autoSelectSubtitles.wrappedValue = defaults.bool(forKey: "AutoSelectSubtitles") + _autoSelectSubtitlesLangcode.wrappedValue = defaults.string(forKey: "AutoSelectSubtitlesLangcode") ?? "" } - + var body: some View { - NavigationView() { - Form() { + NavigationView { + Form { Section(header: Text("Playback settings")) { Picker("Default local quality", selection: $inNetworkStreamBitrate) { ForEach(self.viewModel.bitrates, id: \.self) { bitrate in @@ -42,7 +52,7 @@ struct SettingsView: View { let defaults = UserDefaults.standard defaults.setValue(_inNetworkStreamBitrate.wrappedValue, forKey: "InNetworkBandwidth") } - + Picker("Default remote quality", selection: $outOfNetworkStreamBitrate) { ForEach(self.viewModel.bitrates, id: \.self) { bitrate in Text(bitrate.name).tag(bitrate.value) @@ -52,19 +62,17 @@ struct SettingsView: View { defaults.setValue(_outOfNetworkStreamBitrate.wrappedValue, forKey: "OutOfNetworkBandwidth") } } - + Section(header: Text("Accessibility")) { Toggle("Automatically show subtitles", isOn: $autoSelectSubtitles).onChange(of: autoSelectSubtitles, perform: { _ in let defaults = UserDefaults.standard defaults.setValue(autoSelectSubtitles, forKey: "AutoSelectSubtitles") }) - Picker("Language preferences", selection: $autoSelectSubtitlesLangcode) { - - } + Picker("Language preferences", selection: $autoSelectSubtitlesLangcode) {} } - - Section() { - HStack() { + + Section { + HStack { Text("Signed in as \(username)").foregroundColor(.primary) Spacer() Button { @@ -76,7 +84,7 @@ struct SettingsView: View { } catch _ as NSError { // TODO: handle the error } - + let fetchRequest2: NSFetchRequest = NSFetchRequest(entityName: "SignedInUser") let deleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) @@ -85,7 +93,7 @@ struct SettingsView: View { } catch _ as NSError { // TODO: handle the error } - + globalData.server = nil globalData.user = nil globalData.authToken = "" @@ -106,9 +114,7 @@ struct SettingsView: View { Button { close = false } label: { - HStack() { - Text("Back").font(.callout) - } + Text("Back").font(.callout) } } }