From 437b71960b11a6fb4dfdb2b391d5d106369e2e26 Mon Sep 17 00:00:00 2001 From: Aiden Vigue Date: Sat, 15 May 2021 21:27:09 -0400 Subject: [PATCH] Add movie item image description --- JellyfinPlayer.xcodeproj/project.pbxproj | 8 +- .../xcshareddata/swiftpm/Package.resolved | 8 +- JellyfinPlayer/ContentView.swift | 16 +- .../JellyfinHLSResourceLoaderDelegate.swift | 148 -------- JellyfinPlayer/LibrarySearchView.swift | 183 +++++++++ JellyfinPlayer/LibraryView.swift | 38 +- JellyfinPlayer/MovieItemView.swift | 352 +++++++++++++----- 7 files changed, 499 insertions(+), 254 deletions(-) delete mode 100644 JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift create mode 100644 JellyfinPlayer/LibrarySearchView.swift diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 9b52b4ac..b01de757 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 5338F754263B65E10014BF09 /* SwiftyRequest in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F753263B65E10014BF09 /* SwiftyRequest */; }; 5338F757263B7E2E0014BF09 /* KeychainSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 5338F756263B7E2E0014BF09 /* KeychainSwift */; }; 535BAE9F2649E569005FA86D /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAE9E2649E569005FA86D /* ItemView.swift */; }; - 535BAEA32649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA22649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift */; }; 535BAEA5264A151C005FA86D /* VLCPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA4264A151C005FA86D /* VLCPlayer.swift */; }; 535BAEA7264A18AA005FA86D /* PlayerDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 535BAEA6264A18AA005FA86D /* PlayerDemo.swift */; }; 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5377CBF4263B596A003A4E83 /* JellyfinPlayerApp.swift */; }; @@ -39,6 +38,7 @@ 53E4E645263F6BC000F67C6B /* PartialSheet in Frameworks */ = {isa = PBXBuildFile; productRef = 53E4E644263F6BC000F67C6B /* PartialSheet */; }; 53E4E647263F6CF100F67C6B /* LibraryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */; }; 53E4E649263F725B00F67C6B /* MultiSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53E4E648263F725B00F67C6B /* MultiSelector.swift */; }; + 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* LibrarySearchView.swift */; }; 53FF7F2A263CF3F500585C35 /* LatestMediaView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */; }; /* End PBXBuildFile section */ @@ -59,7 +59,6 @@ /* Begin PBXFileReference section */ 5338F74D263B61370014BF09 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 535BAE9E2649E569005FA86D /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; - 535BAEA22649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JellyfinHLSResourceLoaderDelegate.swift; sourceTree = ""; }; 535BAEA4264A151C005FA86D /* VLCPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VLCPlayer.swift; sourceTree = ""; }; 535BAEA6264A18AA005FA86D /* PlayerDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerDemo.swift; sourceTree = ""; }; 5377CBF1263B596A003A4E83 /* JellyfinPlayer.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = JellyfinPlayer.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -82,6 +81,7 @@ 53DF641D263D9C0600A7CD1A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53E4E648263F725B00F67C6B /* MultiSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiSelector.swift; sourceTree = ""; }; + 53EE24E5265060780068F029 /* LibrarySearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySearchView.swift; sourceTree = ""; }; 53FF7F29263CF3F500585C35 /* LatestMediaView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestMediaView.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -147,9 +147,9 @@ 53E4E648263F725B00F67C6B /* MultiSelector.swift */, 535BAE9E2649E569005FA86D /* ItemView.swift */, 53A089CF264DA9DA00D57806 /* MovieItemView.swift */, - 535BAEA22649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift */, 535BAEA4264A151C005FA86D /* VLCPlayer.swift */, 535BAEA6264A18AA005FA86D /* PlayerDemo.swift */, + 53EE24E5265060780068F029 /* LibrarySearchView.swift */, ); path = JellyfinPlayer; sourceTree = ""; @@ -278,9 +278,9 @@ 5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */, 5389277C263CC3DB0035E14B /* BlurHashDecode.swift in Sources */, 539B2DA5263BA5B8007FF1A4 /* SettingsView.swift in Sources */, - 535BAEA32649E96A005FA86D /* JellyfinHLSResourceLoaderDelegate.swift in Sources */, 5338F74E263B61370014BF09 /* ConnectToServerView.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, + 53EE24E6265060780068F029 /* LibrarySearchView.swift in Sources */, 53892772263C8C6F0035E14B /* LoadingView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d310ae3d..1e67e2c2 100644 --- a/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/JellyfinPlayer.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -96,8 +96,8 @@ "repositoryURL": "https://github.com/apple/swift-nio.git", "state": { "branch": null, - "revision": "21782f3bdc9581148d38d0ccaab6ec952ccda56b", - "version": "2.28.0" + "revision": "d161bf658780b209c185994528e7e24376cf7283", + "version": "2.29.0" } }, { @@ -114,8 +114,8 @@ "repositoryURL": "https://github.com/apple/swift-nio-ssl.git", "state": { "branch": null, - "revision": "3d576964a1ace80d2a3f8bab96cab03e5ee074dc", - "version": "2.12.0" + "revision": "6363cdf6d2fb863e82434f3c4618f4e896e37569", + "version": "2.13.1" } }, { diff --git a/JellyfinPlayer/ContentView.swift b/JellyfinPlayer/ContentView.swift index cc928130..da54b118 100644 --- a/JellyfinPlayer/ContentView.swift +++ b/JellyfinPlayer/ContentView.swift @@ -179,6 +179,7 @@ struct ContentView: View { @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] = []; @@ -256,9 +257,14 @@ struct ContentView: View { } break - case .failure( _): - _isLoading.wrappedValue = false; - _isSignInErrored.wrappedValue = true; + case .failure( let error): + if(error.response?.status.code == 401) { + _isLoading.wrappedValue = false; + _isSignInErrored.wrappedValue = true; + } else { + _isLoading.wrappedValue = false; + _isNetworkErrored.wrappedValue = true; + } } } } @@ -323,10 +329,14 @@ struct ContentView: View { Image(systemName: "folder") }) .tag("All Media") + } }.environmentObject(globalData) .onAppear(perform: startup) .navigationViewStyle(StackNavigationViewStyle()) + .alert(isPresented: $isNetworkErrored) { + Alert(title: Text("Network Error"), message: Text("Couldn't connect to Jellyfin"), dismissButton: .default(Text("Ok"))) + } } } diff --git a/JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift b/JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift deleted file mode 100644 index 4a32f447..00000000 --- a/JellyfinPlayer/JellyfinHLSResourceLoaderDelegate.swift +++ /dev/null @@ -1,148 +0,0 @@ -// -// JellyfinHLSResourceLoaderDelegate.swift - -import Foundation -import AVFoundation - -class JellyfinHLSResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate, URLSessionDataDelegate, URLSessionTaskDelegate { - - typealias Completion = (URL?) -> Void - - private static let SchemeSuffix = "icpt" - - // MARK: - Properties - // MARK: Public - - var completion: Completion? - - lazy var streamingAssetURL: URL = { - guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { - fatalError() - } - components.scheme = (components.scheme ?? "") + JellyfinHLSResourceLoaderDelegate.SchemeSuffix - guard let retURL = components.url else { - fatalError() - } - return retURL - }() - - // MARK: Private - - private let url: URL - private var infoResponse: URLResponse? - private var urlSession: URLSession? - private lazy var mediaData = Data() - private var loadingRequests = [AVAssetResourceLoadingRequest]() - - // MARK: - Life Cycle Methods - - init(withURL url: URL) { - self.url = url - - super.init() - } - - // MARK: - Public Methods - - func invalidate() { - self.loadingRequests.forEach { $0.finishLoading() } - self.invalidateURLSession() - } - - // MARK: - AVAssetResourceLoaderDelegate - - func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool { - if self.urlSession == nil { - self.urlSession = self.createURLSession() - let task = self.urlSession!.dataTask(with: self.url) - task.resume() - } - - self.loadingRequests.append(loadingRequest) - - return true - } - - func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) { - if let index = self.loadingRequests.firstIndex(of: loadingRequest) { - self.loadingRequests.remove(at: index) - } - } - - // MARK: - URLSessionDataDelegate - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) { - self.infoResponse = response - self.processRequests() - - completionHandler(.allow) - } - - func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { - self.mediaData.append(data) - self.processRequests() - } - - // MARK: - Private Methods - - private func createURLSession() -> URLSession { - let config = URLSessionConfiguration.default - let operationQueue = OperationQueue() - operationQueue.maxConcurrentOperationCount = 1 - return URLSession(configuration: config, delegate: self, delegateQueue: operationQueue) - } - - private func invalidateURLSession() { - self.urlSession?.invalidateAndCancel() - self.urlSession = nil - } - - private func isInfo(request: AVAssetResourceLoadingRequest) -> Bool { - return request.contentInformationRequest != nil - } - - private func fillInfoRequest(request: inout AVAssetResourceLoadingRequest, response: URLResponse) { - request.contentInformationRequest?.isByteRangeAccessSupported = true - request.contentInformationRequest?.contentType = response.mimeType - request.contentInformationRequest?.contentLength = response.expectedContentLength - } - - private func processRequests() { - var finishedRequests = Set() - self.loadingRequests.forEach { - var request = $0 - if self.isInfo(request: request), let response = self.infoResponse { - self.fillInfoRequest(request: &request, response: response) - } - if let dataRequest = request.dataRequest, self.checkAndRespond(forRequest: dataRequest) { - finishedRequests.insert(request) - request.finishLoading() - } - } - - self.loadingRequests = self.loadingRequests.filter { !finishedRequests.contains($0) } - } - - private func checkAndRespond(forRequest dataRequest: AVAssetResourceLoadingDataRequest) -> Bool { - let downloadedData = self.mediaData - let downloadedDataLength = Int64(downloadedData.count) - - let requestRequestedOffset = dataRequest.requestedOffset - let requestRequestedLength = Int64(dataRequest.requestedLength) - let requestCurrentOffset = dataRequest.currentOffset - - if downloadedDataLength < requestCurrentOffset { - return false - } - - let downloadedUnreadDataLength = downloadedDataLength - requestCurrentOffset - let requestUnreadDataLength = requestRequestedOffset + requestRequestedLength - requestCurrentOffset - let respondDataLength = min(requestUnreadDataLength, downloadedUnreadDataLength) - - dataRequest.respond(with: downloadedData.subdata(in: Range(NSMakeRange(Int(requestCurrentOffset), Int(respondDataLength)))!)) - - let requestEndOffset = requestRequestedOffset + requestRequestedLength - - return requestCurrentOffset >= requestEndOffset - } -} diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift new file mode 100644 index 00000000..64bfa878 --- /dev/null +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -0,0 +1,183 @@ +// +// LibrarySearchView.swift +// JellyfinPlayer +// +// Created by Aiden Vigue on 5/2/21. +// + +import SwiftUI +import SwiftyJSON +import SwiftyRequest +import ExyteGrid +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(); + } + }; + + func onAppear() { + _isLoading.wrappedValue = true; + _items.wrappedValue = []; + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + _url.wrappedValue + "&searchTerm=" + searchQuery) + 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; + } + } + + @Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass? + @Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass? + + var isPortrait: Bool { + let result = verticalSizeClass == .regular && horizontalSizeClass == .compact + return result + } + + var tracks: [GridTrack] { + self.isPortrait ? 3 : 6 + } + + var body: some View { + VStack() { + NavigationLink(destination: ItemView(item: linkedItem), isActive: $open) { + EmptyView(); + }; + Spacer().frame(height:6); + TextField("Search", text: $searchQuery, onEditingChanged: { _ in + print("changed") + }, onCommit: { + self.onAppear() + }) + .padding(.horizontal, 10) + .foregroundColor(Color.secondary) + .textFieldStyle(RoundedBorderTextFieldStyle()) + LoadingView(isShowing: $isLoading) { + GeometryReader { geometry in + Grid(tracks: self.tracks, spacing: GridSpacing(horizontal: 0, vertical: 20)) { + ForEach(items, id: \.Id) { item in + Button() { + _linkedItem.wrappedValue = item; + _close.wrappedValue = false; + _open.wrappedValue = true; + } label: { + VStack(alignment: .leading) { + if(item.Type == "Movie") { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")) + .resizable() + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: 100, height: 150) + .cornerRadius(10) + } + .frame(width:100, height: 150) + .cornerRadius(10) + .shadow(radius: 5) + } else { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(item.Id)/Images/\(item.ImageType)?fillWidth=300&fillHeight=450&quality=90&tag=\(item.Image)")) + .resizable() + .placeholder { + Image(uiImage: UIImage(blurHash: (item.BlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : item.BlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: 100, height: 150) + .cornerRadius(10) + } + .frame(width:100, height: 150) + .cornerRadius(10).overlay( + ZStack { + Text("\(String(item.ItemBadge ?? 0))") + .font(.caption) + .padding(3) + .foregroundColor(.white) + }.background(Color.black) + .opacity(0.8) + .cornerRadius(10.0) + .padding(3), alignment: .topTrailing + ) + .shadow(radius: 5) + } + Text(item.Name) + .font(.caption) + .fontWeight(.semibold) + .foregroundColor(.primary) + .lineLimit(1) + Text(String(item.ProductionYear)) + .foregroundColor(.secondary) + .font(.caption) + .fontWeight(.medium) + }.frame(width: 100) + } + } + }.gridContentMode(.scroll) + } + } + }.onAppear(perform: onAppear) + .navigationBarTitle("Search", displayMode: .inline) + } +} diff --git a/JellyfinPlayer/LibraryView.swift b/JellyfinPlayer/LibraryView.swift index 5c8f6804..d9e62712 100644 --- a/JellyfinPlayer/LibraryView.swift +++ b/JellyfinPlayer/LibraryView.swift @@ -26,8 +26,12 @@ struct LibraryView: View { @State private var viewDidLoad: Bool = false; @State private var filterString: String = "&SortBy=SortName&SortOrder=Descending"; - @State private var showFiltersPopover: Bool = false - + @State private var showFiltersPopover: Bool = false; + @State private var showSearchPopover: Bool = false; + @State private var extraParam: String = ""; + @State private var title: String = ""; + @State private var url: String = ""; + @State private var closeSearch: Bool = false; var gridItems: [GridItem] = [GridItem(.adaptive(minimum: 150, maximum: 400))] @@ -35,7 +39,6 @@ struct LibraryView: View { _prefill_id = State(wrappedValue: prefill ?? "") _library_names = State(wrappedValue: names) _library_ids = State(wrappedValue: libraries) - //print("prefilling w/ \(prefill ?? "") aka \(names[prefill ?? ""] ?? "nil")") } init(prefill: String?, names: [String: String], libraries: [String], filter: String) { @@ -43,7 +46,19 @@ struct LibraryView: View { _library_names = State(wrappedValue: names) _library_ids = State(wrappedValue: libraries) _filterString = State(wrappedValue: filter); - //print("prefilling w/ \(prefill ?? "") aka \(names[prefill ?? ""] ?? "nil")") + } + + init(filter: String, extraParams: String, title: String) { + _prefill_id = State(wrappedValue: "erwt"); + _filterString = State(wrappedValue: filter); + _extraParam = State(wrappedValue: extraParams); + _title = State(wrappedValue: title) + } + + init(extraParams: String, title: String) { + _prefill_id = State(wrappedValue: "erwt"); + _extraParam = State(wrappedValue: extraParams); + _title = State(wrappedValue: title) } @State var items: [ResumeItem] = [] @@ -62,9 +77,13 @@ struct LibraryView: View { func loadItems() { _isLoading.wrappedValue = true; - let url = "/Users/\(globalData.user?.user_id ?? "")/Items?Limit=\(endIndex)&StartIndex=\(startIndex)&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb%2CBanner&IncludeItemTypes=Movie,Series\(selected_library_id == "favorites" ? "&Filters=IsFavorite" : "&ParentId=" + selected_library_id)\(filterString)" + if(_extraParam.wrappedValue == "") { + _url.wrappedValue = "/Users/\(globalData.user?.user_id ?? "")/Items?Limit=\(endIndex)&StartIndex=\(startIndex)&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb%2CBanner&IncludeItemTypes=Movie,Series\(selected_library_id == "favorites" ? "&Filters=IsFavorite" : "&ParentId=" + selected_library_id)\(filterString)" + } else { + _url.wrappedValue = "/Users/\(globalData.user?.user_id ?? "")/Items?Limit=\(endIndex)&StartIndex=\(startIndex)&Recursive=true&Fields=PrimaryImageAspectRatio%2CBasicSyncInfo&ImageTypeLimit=1&EnableImageTypes=Primary%2CBackdrop%2CThumb%2CBanner&IncludeItemTypes=Movie,Series\(filterString)\(extraParam)" + } - let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + url) + let request = RestRequest(method: .get, url: (globalData.server?.baseURI ?? "") + _url.wrappedValue) request.headerParameters["X-Emby-Authorization"] = globalData.authHeader request.contentType = "application/json" request.acceptType = "application/json" @@ -228,11 +247,10 @@ struct LibraryView: View { items = []; loadItems(); } - .navigationTitle(library_names[prefill_id] ?? "Library") + .navigationTitle(extraParam == "" ? (library_names[prefill_id] ?? "Library") : title) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { - Button { - } label: { + NavigationLink(destination: LibrarySearchView(url: url, close: $closeSearch), isActive: $closeSearch) { Image(systemName: "magnifyingglass") } Button { @@ -241,7 +259,7 @@ struct LibraryView: View { Image(systemName: "line.horizontal.3.decrease") } } - }.popover( isPresented: self.$showFiltersPopover, arrowEdge: .bottom) { LibraryFilterView(library: selected_library_id, output: $filterString, close: $showFiltersPopover) } + }.popover( isPresented: self.$showFiltersPopover, arrowEdge: .bottom) { LibraryFilterView(library: selected_library_id, output: $filterString, close: $showFiltersPopover).environmentObject(self.globalData) } } else { List(library_ids, id:\.self) { id in if(id != "genres") { diff --git a/JellyfinPlayer/MovieItemView.swift b/JellyfinPlayer/MovieItemView.swift index d78bda3c..3e59be2c 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -35,6 +35,18 @@ class DetailItem: ObservableObject { @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] = []; +} + +class IVGenre: ObservableObject { + @Published var Id: String = ""; + @Published var Name: String = ""; } class CastMember: ObservableObject { @@ -53,8 +65,54 @@ struct MovieItemView: View { @State private var playing: Bool = false; @State private var vc: PreferenceUIHostingController? = nil; @State private var progressString: String = ""; - @State private var watched: Bool = false; - @State private var favorite: Bool = false; + @State private var watched: Bool = false { + didSet { + 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" + 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 + } + } else { + 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 + } + } + } + }; + @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)") + request.headerParameters["X-Emby-Authorization"] = globalData.authHeader + request.contentType = "application/json" + request.acceptType = "application/json" + + request.responseData() { (result: Result, RestError>) in + } + } else { + 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 + } + } + } + }; init(item: ResumeItem) { self.item = item; @@ -106,6 +164,46 @@ struct MovieItemView: View { 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.ParentId = json["ParentId"].string ?? "" + //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?fillHeight=744&fillWidth=496&quality=96&tag=\(imageTag)")! + fullItem.Cast.append(cast); + } + } + + //Studios + for (_,studio):(String, JSON) in json["Studios"] { + fullItem.Studios.append(studio["Name"].string ?? ""); + } + + //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); + } + _watched.wrappedValue = fullItem.Watched _favorite.wrappedValue = json["UserData"]["IsFavorite"].bool ?? false; @@ -147,49 +245,51 @@ struct MovieItemView: View { PlayerDemo(item: fullItem, playing: $playing).onAppear(perform: lockOrientations) } else { LoadingView(isShowing: $isLoading) { - ScrollView() { - VStack(alignment:.leading) { - if(!isLoading) { - GeometryReader { geometry in - VStack() { - WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Backdrop?maxWidth=3840&quality=90&tag=\(fullItem.Backdrop)")!) - .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size - .placeholder { - Image(uiImage: UIImage(blurHash: (fullItem.BackdropBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem.BackdropBlurHash), size: CGSize(width: 32, height: 32))!) - .resizable() - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) - } - .opacity(0.3) - .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) - .shadow(radius: 5) - .overlay( - HStack() { - WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Primary?fillWidth=300&fillHeight=450&quality=90&tag=\(fullItem.Poster)")!) - .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size - .placeholder { - Image(uiImage: UIImage(blurHash: (fullItem.PosterBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem.PosterBlurHash), size: CGSize(width: 32, height: 32))!) - .resizable() - .frame(width: 120, height: 180) - .cornerRadius(10) - } - .frame(width: 120, height: 180) - .cornerRadius(10) - VStack(alignment: .leading) { - Spacer() - Text(fullItem.Name).font(.headline) - .fontWeight(.semibold) - .foregroundColor(.primary) + VStack(alignment:.leading) { + if(!isLoading) { + GeometryReader { geometry in + VStack() { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Backdrop?maxWidth=3840&quality=90&tag=\(fullItem.Backdrop)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (fullItem.BackdropBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem.BackdropBlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + } + + .opacity(0.4) + .frame(width: geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing, height: (geometry.size.width + geometry.safeAreaInsets.leading + geometry.safeAreaInsets.trailing) * 0.5625) + .aspectRatio(contentMode: .fill) + .shadow(radius: 5) + .overlay( + HStack() { + WebImage(url: URL(string: "\(globalData.server?.baseURI ?? "")/Items/\(fullItem.Id)/Images/Primary?fillWidth=300&fillHeight=450&quality=90&tag=\(fullItem.Poster)")!) + .resizable() // Resizable like SwiftUI.Image, you must use this modifier or the view will use the image bitmap size + .placeholder { + Image(uiImage: UIImage(blurHash: (fullItem.PosterBlurHash == "" ? "W$H.4}D%bdo#a#xbtpxVW?W?jXWsXVt7Rjf5axWqxbWXnhada{s-" : fullItem.PosterBlurHash), size: CGSize(width: 32, height: 32))!) + .resizable() + .frame(width: 120, height: 180) + .cornerRadius(10) + }.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) - .offset(y: -4) - HStack() { - Text(String(fullItem.ProductionYear)).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) - Text(fullItem.Runtime).font(.subheadline) - .fontWeight(.medium) - .foregroundColor(.secondary) - .lineLimit(1) + Text(fullItem.Runtime).font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.secondary) + .lineLimit(1) + if(fullItem.OfficialRating != "") { Text(fullItem.OfficialRating).font(.subheadline) .fontWeight(.semibold) .foregroundColor(.secondary) @@ -200,56 +300,138 @@ struct MovieItemView: View { .stroke(Color.secondary, lineWidth: 1) ) } - - }.offset(x: 0, y: -46) - }.offset(x: 16, y: 40) - , alignment: .bottomLeading) - VStack(alignment: .leading) { - HStack() { - //Play button - Button() { - playing = true; - } label: { - HStack() { - Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) - Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) + if(fullItem.CommunityRating != "") { + HStack() { + Image(systemName: "star").foregroundColor(.secondary) + Text(fullItem.CommunityRating).font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.secondary) + .lineLimit(1) + .offset(x: -7, y: 0.7) + } + } } - .frame(width: 120, height: 35) - .background(Color(UIColor.systemBlue)) - .cornerRadius(10) - }.buttonStyle(PlainButtonStyle()) - .frame(width: 120, height: 25) - Spacer() + + }.offset(x: 0, y: -46) + }.offset(x: 16, y: 40) + , alignment: .bottomLeading) + VStack(alignment: .leading) { + HStack() { + //Play button + Button() { + playing = true; + } label: { HStack() { - Button() { - favorite.toggle() - } label: { - if(!favorite) { - Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) - } else { - Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)).font(.system(size: 20)) - } + Text(fullItem.Progress == 0 ? "Play" : "\(progressString) left").foregroundColor(Color.white).font(.callout).fontWeight(.semibold) + Image(systemName: "play.fill").foregroundColor(Color.white).font(.system(size: 20)) + } + .frame(width: 120, height: 35) + .background(Color(UIColor.systemBlue)) + .cornerRadius(10) + }.buttonStyle(PlainButtonStyle()) + .frame(width: 120, height: 25) + Spacer() + HStack() { + Button() { + favorite.toggle() + } label: { + if(!favorite) { + Image(systemName: "heart").foregroundColor(Color.primary).font(.system(size: 20)) + } else { + Image(systemName: "heart.fill").foregroundColor(Color(UIColor.systemRed)).font(.system(size: 20)) } - Button() { - watched.toggle() - } label: { - if(watched) { - Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) - } else { - Image(systemName: "xmark.rectangle").foregroundColor(Color.primary).font(.system(size: 20)) - } + } + Button() { + watched.toggle() + } label: { + if(watched) { + Image(systemName: "checkmark.rectangle.fill").foregroundColor(Color.primary).font(.system(size: 20)) + } else { + Image(systemName: "xmark.rectangle").foregroundColor(Color.primary).font(.system(size: 20)) } } } - Text(fullItem.Tagline).font(.body).italic().padding(.top, 7).fixedSize(horizontal: false, vertical: true) - Text(fullItem.Overview).font(.footnote).padding(.top, 3).fixedSize(horizontal: false, vertical: true) - }.padding(EdgeInsets(top: 24, leading: 16, bottom: 0, trailing: 16)) - } + }.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) + } + 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) { + ScrollView(.horizontal, showsIndicators: false) { + 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)) { + Text(genre.Name).font(.footnote) + } + } + }.padding(.leading, 16).padding(.trailing,16) + } + } + if(fullItem.Cast.count != 0) { + ScrollView(.horizontal, showsIndicators: false) { + 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() { + 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: 32, height: 32))!) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) + .cornerRadius(10) + } + .aspectRatio(contentMode: .fill) + .frame(width: 100, height: 100) + .cornerRadius(10).shadow(radius: 6) + 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) + } + } + } + Spacer().frame(width: 10) + } + Spacer().frame(width: 16) + } + } + }.padding(.top, -3) + } + if(fullItem.Directors.count != 0) { + 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) + } + if(fullItem.Writers.count != 0) { + 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) + } + if(fullItem.Studios.count != 0) { + 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) + } + Spacer().frame(height: 3) + } + } + }.padding(EdgeInsets(top: 24, leading: 0, bottom: 0, trailing: 0)) } } } - }.navigationBarTitleDisplayMode(.inline) - .navigationTitle("Details") + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle("Movie Details") .supportedOrientations(.allButUpsideDown) .prefersHomeIndicatorAutoHidden(false) .withHostingWindow() { window in