diff --git a/JellyfinPlayer.xcodeproj/project.pbxproj b/JellyfinPlayer.xcodeproj/project.pbxproj index 3b01a499..591aea64 100644 --- a/JellyfinPlayer.xcodeproj/project.pbxproj +++ b/JellyfinPlayer.xcodeproj/project.pbxproj @@ -41,9 +41,12 @@ 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 */; }; + 6213388E265F777C00A81A2A /* LibraryViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388D265F777C00A81A2A /* LibraryViewModel.swift */; }; + 62133890265F83A900A81A2A /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6213388F265F83A900A81A2A /* LibraryListView.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 */; }; @@ -103,7 +106,10 @@ 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 = ""; }; + 6213388D265F777C00A81A2A /* LibraryViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryViewModel.swift; sourceTree = ""; }; + 6213388F265F83A900A81A2A /* LibraryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryListView.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 = ""; }; @@ -150,6 +156,7 @@ 5377CBF3263B596A003A4E83 /* JellyfinPlayer */ = { isa = PBXGroup; children = ( + 6273DD4A265F4794009C1D0B /* Domains */, 6273DD46265F419B009C1D0B /* APIs */, AE8C3157265D6F5E008AA076 /* Resources */, AE8C3152265D607B008AA076 /* ViewModels */, @@ -179,6 +186,7 @@ 53987CA526572F0700E7EA70 /* SeriesItemView.swift */, 53987CA72657424A00E7EA70 /* EpisodeItemView.swift */, 53192D5C265AA78A008A4215 /* DeviceProfileBuilder.swift */, + 6213388F265F83A900A81A2A /* LibraryListView.swift */, ); path = JellyfinPlayer; sourceTree = ""; @@ -199,6 +207,22 @@ name = Frameworks; sourceTree = ""; }; + 6213388B265F776B00A81A2A /* Library */ = { + isa = PBXGroup; + children = ( + 6213388C265F777100A81A2A /* ViewModels */, + ); + path = Library; + sourceTree = ""; + }; + 6213388C265F777100A81A2A /* ViewModels */ = { + isa = PBXGroup; + children = ( + 6213388D265F777C00A81A2A /* LibraryViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; 6273DD46265F419B009C1D0B /* APIs */ = { isa = PBXGroup; children = ( @@ -207,6 +231,31 @@ 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 = ( @@ -342,6 +391,7 @@ 535BAE9F2649E569005FA86D /* ItemView.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 */, @@ -350,6 +400,7 @@ 53A089D0264DA9DA00D57806 /* MovieItemView.swift in Sources */, 53E4E649263F725B00F67C6B /* MultiSelector.swift in Sources */, 53E4E647263F6CF100F67C6B /* LibraryFilterView.swift in Sources */, + 6213388E265F777C00A81A2A /* LibraryViewModel.swift in Sources */, 6273DD48265F41B3009C1D0B /* JellyfinAPI.swift in Sources */, 53892777263CBB000035E14B /* JellyApiTypings.swift in Sources */, 5377CBF7263B596A003A4E83 /* ContentView.swift in Sources */, @@ -359,6 +410,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 */, diff --git a/JellyfinPlayer/APIs/JellyfinAPI.swift b/JellyfinPlayer/APIs/JellyfinAPI.swift index dfaf84de..592ccc5e 100644 --- a/JellyfinPlayer/APIs/JellyfinAPI.swift +++ b/JellyfinPlayer/APIs/JellyfinAPI.swift @@ -8,8 +8,26 @@ 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 = "Name" + case dateCreated = "DateCreated" } enum ASC: String { @@ -17,25 +35,67 @@ enum ASC: String { case ascending = "Ascending" } +enum FilterType: String { + case isFavorite = "IsFavorite" +} + +struct Filter { + var imageTypes = [ImageType]() + var fields = [Field]() + var itemTypes = [ItemType]() + var filterTypes = [FilterType]() + var sort: SortType? + var asc: ASC? + var parentID: String? + var imageTypeLimit: Int? + var recursive = true + var genres = [String]() + var personIds = [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: ",") + return parameters + } +} + enum JellyfinAPI { - case search(globalData: GlobalData, url: URL, query: String, sort: SortType = .name, asc: ASC = .descending) + 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 .search(_, url, _, _, _): - return url + case let .items(global, _, _), + let .search(global, _, _, _): + return URL(string: global.server?.baseURI ?? "")! } } var path: String { - return "" + switch self { + case let .items(global, _, _), + let .search(global, _, _, _): + return "/Users/\(global.user?.user_id ?? "")/Items" + } } var method: Moya.Method { switch self { - case .search: + case .items, .search: return .get } } @@ -46,19 +106,27 @@ extension JellyfinAPI: TargetType { var task: Task { switch self { - case let .search(_, _, query, sort, asc): - var parameters = [String: Any]() - parameters["searchTerm"] = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) - parameters["SortBy"] = sort.rawValue - parameters["SortOrder"] = asc.rawValue + case let .search(_, filter, searchQuery, page): + var parameters = filter.toParamters + parameters["searchTerm"] = searchQuery.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) + parameters["StartIndex"] = page * 100 + parameters["Limit"] = 100 + return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) + case let .items(_, filter, page): + var parameters = filter.toParamters + parameters["StartIndex"] = page * 100 + parameters["Limit"] = 100 return .requestParameters(parameters: parameters, encoding: JSONEncoding.default) } } var headers: [String: String]? { switch self { - case let .search(globalData, _, _, _, _): - return ["X-Emby-Authorization": globalData.authHeader] + case let .items(global, _, _), + let .search(global, _, _, _): + return [ + "X-Emby-Authorization": global.authHeader, + ] } } } diff --git a/JellyfinPlayer/ContentView.swift b/JellyfinPlayer/ContentView.swift index a88cf509..a32d9dda 100644 --- a/JellyfinPlayer/ContentView.swift +++ b/JellyfinPlayer/ContentView.swift @@ -197,9 +197,9 @@ struct ContentView: View { 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: LibraryView(prefill: library_id, names: [library_id: library_names[library_id] ?? ""], libraries: [library_id], filter: "&SortBy=DateCreated&SortOrder=Descending")) { Text("See All").font(.subheadline).fontWeight(.bold) - } +// } }.padding(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16)) LatestMediaView(library: library_id) }.padding(EdgeInsets(top: 4, leading: 0, bottom: 0, trailing: 0)) @@ -224,9 +224,8 @@ struct ContentView: View { Image(systemName: "house") }) .tag("Home") - - NavigationView() { - LibraryView(prefill: "", names: library_names, libraries: libraries) + NavigationView { + LibraryListView(libraryNames: library_names, libraryIDs: libraries) } .navigationViewStyle(StackNavigationViewStyle()) .tabItem({ diff --git a/JellyfinPlayer/Domains/Library/ViewModels/LibraryViewModel.swift b/JellyfinPlayer/Domains/Library/ViewModels/LibraryViewModel.swift new file mode 100644 index 00000000..b472c7e4 --- /dev/null +++ b/JellyfinPlayer/Domains/Library/ViewModels/LibraryViewModel.swift @@ -0,0 +1,138 @@ +// +// 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(configuration: NetworkLoggerPlugin.Configuration(logOptions: .verbose))]) + + var prefillID: String + @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() + + fileprivate var cancellables = Set() + + init(prefillID: String, + filter: Filter? = nil) + { + self.prefillID = prefillID + + if let unwrappedFilter = filter { + self.filter = unwrappedFilter + } else { + self.filter = Filter(imageTypes: [.primary, .backdrop, .thumb, .banner], + fields: [.primaryImageAspectRatio, .basicSyncInfo], + itemTypes: [.movie, .series], + sort: .dateCreated, + asc: .descending, + parentID: prefillID, + imageTypeLimit: 1, + recursive: true) + } + } + + func requestNextPage() { + page += 1 + requestItems() + } + + func requestPreviousPage() { + page -= 1 + requestItems() + } + + func requestInitItems() { + page = 1 + requestItems() + } + + fileprivate func requestItems() { + isLoading = true + provider.requestPublisher(.items(globalData: globalData, filter: filter, page: page)) + // .map(ResumeItem.self) TO DO + .print() + .sink(receiveCompletion: { _ in + self.isLoading = false + }, receiveValue: { response in + self.items.removeAll() + let body = response.data + var totalCount = 0 + do { + let json = try JSON(data: body) + totalCount = 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 + + self.items.append(itemObj) + } + } catch {} + + if totalCount > 100 { + if self.page > 1 { + self.isHiddenPreviousButton = false + } + if totalCount > (self.page * 100) { + self.isHiddenNextButton = false + } + } else { + self.isHiddenNextButton = true + self.isHiddenPreviousButton = true + } + }) + .store(in: &cancellables) + } +} diff --git a/JellyfinPlayer/Domains/Search/ViewModels/LibrarySearchViewModel.swift b/JellyfinPlayer/Domains/Search/ViewModels/LibrarySearchViewModel.swift new file mode 100644 index 00000000..25978768 --- /dev/null +++ b/JellyfinPlayer/Domains/Search/ViewModels/LibrarySearchViewModel.swift @@ -0,0 +1,100 @@ +// +// 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 + .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: { _ in + self.isLoading = false + }, receiveValue: { response in + let body = response.data + 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 + + self.items.append(itemObj) + } + } catch {} + }) + .store(in: &cancellables) + } +} diff --git a/JellyfinPlayer/EpisodeItemView.swift b/JellyfinPlayer/EpisodeItemView.swift index 25a89310..5f293bf3 100644 --- a/JellyfinPlayer/EpisodeItemView.swift +++ b/JellyfinPlayer/EpisodeItemView.swift @@ -309,9 +309,9 @@ struct EpisodeItemView: View { 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)) { +// NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { Text(genre.Name).font(.footnote) - } +// } } }.padding(.leading, 16).padding(.trailing,16) } @@ -323,7 +323,7 @@ struct EpisodeItemView: View { HStack() { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { +// 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 @@ -342,7 +342,7 @@ struct EpisodeItemView: View { Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) } } - } +// } Spacer().frame(width: 10) } Spacer().frame(width: 16) @@ -493,9 +493,9 @@ struct EpisodeItemView: View { 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)) { +// NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { Text(genre.Name).font(.footnote) - } +// } } }.padding(.leading, 16).padding(.trailing, UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } @@ -507,7 +507,7 @@ struct EpisodeItemView: View { HStack() { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { +// 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 @@ -526,7 +526,7 @@ struct EpisodeItemView: View { Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) } } - } +// } Spacer().frame(width: 10) } Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) diff --git a/JellyfinPlayer/JellyfinPlayerApp.swift b/JellyfinPlayer/JellyfinPlayerApp.swift index 6f7230ac..c6561826 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? diff --git a/JellyfinPlayer/LibraryListView.swift b/JellyfinPlayer/LibraryListView.swift new file mode 100644 index 00000000..182e05ea --- /dev/null +++ b/JellyfinPlayer/LibraryListView.swift @@ -0,0 +1,65 @@ +// +// 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 + @State + private var libraryIDs: [String] = [] + @State + private var libraryNames: [String: String] = [:] + @State + private var viewDidLoad: Bool = false + @State + private var closeSearch: Bool = false + + init(libraryNames: [String: String], libraryIDs: [String]) { + self._libraryNames = State(initialValue: libraryNames) + self._libraryIDs = State(initialValue: libraryIDs) + } + + func listOnAppear() { + if viewDidLoad == false { + viewDidLoad = true + libraryIDs.append("favorites") + libraryNames["favorites"] = "Favorites" + + libraryIDs.append("genres") + libraryNames["genres"] = "Genres - WIP" + } + } + + var body: some View { + List(libraryIDs, id: \.self) { id in + if id != "genres" { + NavigationLink(destination: LibraryView(viewModel: .init(prefillID: id), title: libraryNames[id] ?? "")) { + Text(libraryNames[id] ?? "").foregroundColor(Color.primary) + } + } else { + // NavigationLink(destination: LibraryView(prefill: id, names: libraryNames, libraries: library_ids)) { + Text(libraryNames[id] ?? "").foregroundColor(Color.primary) + // } + } + } + .onAppear(perform: listOnAppear) + .navigationTitle("All Media") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { +// NavigationLink(destination: LibrarySearchView(viewModel: .init(filter: .init()), +// close: $closeSearch), +// isActive: $closeSearch) { + Image(systemName: "magnifyingglass") +// } + } + } + } +} diff --git a/JellyfinPlayer/LibrarySearchView.swift b/JellyfinPlayer/LibrarySearchView.swift index bd8f8d3a..d32ce602 100644 --- a/JellyfinPlayer/LibrarySearchView.swift +++ b/JellyfinPlayer/LibrarySearchView.swift @@ -5,193 +5,156 @@ // 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 + + @Binding + var close: Bool + @State + var open: Bool = false + @State + private var onlyUnplayed: Bool = false + @State + private var viewDidLoad: Bool = false + @State + var linkedItem = ResumeItem() + func onAppear() { 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? + + @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; - } - } - + 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() + viewModel.globalData = globalData + if viewModel.items.isEmpty { + recalcTracks() + viewModel.requestInitItems() } } - - 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() - } - - @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.. 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(); - } - .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") - } - Button { - showFiltersPopover = true - } label: { - Image(systemName: "line.horizontal.3.decrease") - } - } - }.sheet( 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) - } + .gesture(DragGesture().onChanged { value in + if value.translation.height > 0 { + print("Scroll down") } else { - NavigationLink(destination: LibraryView(prefill: id, names: library_names, libraries: library_ids)) { - Text(library_names[id] ?? "").foregroundColor(Color.primary) + print("Scroll up") + } + }) + .onChange(of: isPortrait) { _ in + recalcTracks() + } + } + .overrideViewPreference(.unspecified) + .onAppear(perform: onAppear) + .navigationTitle(title) + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + if viewModel.isHiddenPreviousButton { + Button { + viewModel.requestPreviousPage() + } label: { + Image(systemName: "chevron.left") } } - }.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") + if viewModel.isHiddenNextButton { + Button { + viewModel.requestNextPage() + } label: { + Image(systemName: "chevron.right") } } + NavigationLink(destination: LibrarySearchView(viewModel: .init(filter: viewModel.filter), close: $closeSearch), + isActive: $closeSearch) { + Image(systemName: "magnifyingglass") + } + Button { + showFiltersPopover = true + } label: { + Image(systemName: "line.horizontal.3.decrease") + } } - + } +// .sheet(isPresented: self.$showFiltersPopover) { +// LibraryFilterView(library: selected_library_id, output: $filterString, close: $showFiltersPopover) +// .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 7f752525..82ea2804 100644 --- a/JellyfinPlayer/MovieItemView.swift +++ b/JellyfinPlayer/MovieItemView.swift @@ -357,9 +357,9 @@ struct MovieItemView: View { 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)) { +// NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { Text(genre.Name).font(.footnote) - } +// } } }.padding(.leading, 16).padding(.trailing,16) } @@ -371,7 +371,7 @@ struct MovieItemView: View { HStack() { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { +// 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 @@ -390,7 +390,7 @@ struct MovieItemView: View { Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) } } - } +// } Spacer().frame(width: 10) } Spacer().frame(width: 16) @@ -540,9 +540,9 @@ struct MovieItemView: View { 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)) { +// NavigationLink(destination: LibraryView(extraParams: "&Genres=\(genre.Name.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")", title: genre.Name)) { Text(genre.Name).font(.footnote) - } +// } } }.padding(.leading, 16).padding(.trailing,UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55) } @@ -554,7 +554,7 @@ struct MovieItemView: View { HStack() { Spacer().frame(width: 16) ForEach(fullItem.Cast, id: \.Id) { cast in - NavigationLink(destination: LibraryView(extraParams: "&PersonIds=\(cast.Id)", title: cast.Name)) { +// 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 @@ -573,7 +573,7 @@ struct MovieItemView: View { Text(cast.Role).font(.caption).fontWeight(.medium).lineLimit(1).foregroundColor(Color.secondary).frame(width: 100) } } - } +// } Spacer().frame(width: 10) } Spacer().frame(width: UIDevice.current.userInterfaceIdiom == .pad ? 16 : 55)