From 3b755adf87b672681011bb6d3d368c1eedadb60c Mon Sep 17 00:00:00 2001 From: Ethan Pippin Date: Mon, 29 Aug 2022 08:58:38 -0600 Subject: [PATCH] iOS/iPadOS - Library List View (#542) --- Shared/Objects/LibraryViewType.swift | 25 ++++++++ .../SwiftfinStore/SwiftfinStoreDefaults.swift | 6 +- Shared/ViewModels/LibraryViewModel.swift | 8 +-- Swiftfin tvOS/Views/LibraryView.swift | 2 +- Swiftfin.xcodeproj/project.pbxproj | 36 +++++++++-- .../Components/LibraryItemRow.swift | 58 +++++++++++++++++ .../Views/{ => LibraryView}/LibraryView.swift | 64 +++++++++++++++---- .../SettingsView/CustomizeViewsSettings.swift | 30 ++++++--- 8 files changed, 200 insertions(+), 29 deletions(-) create mode 100644 Shared/Objects/LibraryViewType.swift create mode 100644 Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift rename Swiftfin/Views/{ => LibraryView}/LibraryView.swift (53%) diff --git a/Shared/Objects/LibraryViewType.swift b/Shared/Objects/LibraryViewType.swift new file mode 100644 index 00000000..e811d9ae --- /dev/null +++ b/Shared/Objects/LibraryViewType.swift @@ -0,0 +1,25 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import Defaults +import Foundation + +enum LibraryViewType: String, CaseIterable, Defaults.Serializable { + case grid + case list + + // TODO: localize after organization + var localizedName: String { + switch self { + case .grid: + return "Grid" + case .list: + return "List" + } + } +} diff --git a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift index 3c3782b8..41ecd2c9 100644 --- a/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift +++ b/Shared/SwiftfinStore/SwiftfinStoreDefaults.swift @@ -37,11 +37,15 @@ extension Defaults.Keys { static let latestInLibraryPosterType = Key("latestInLibraryPosterType", default: .portrait, suite: .generalSuite) static let recommendedPosterType = Key("recommendedPosterType", default: .portrait, suite: .generalSuite) static let searchPosterType = Key("searchPosterType", default: .portrait, suite: .generalSuite) - static let libraryPosterType = Key("libraryPosterType", default: .portrait, suite: .generalSuite) enum Episodes { static let useSeriesLandscapeBackdrop = Key("useSeriesBackdrop", default: true, suite: .generalSuite) } + + enum Library { + static let viewType = Key("Customization.Library.viewType", default: .grid, suite: .generalSuite) + static let gridPosterType = Key("Customization.Library.gridPosterType", default: .portrait, suite: .generalSuite) + } } // Video player / overlay settings diff --git a/Shared/ViewModels/LibraryViewModel.swift b/Shared/ViewModels/LibraryViewModel.swift index e0a2875b..9eaf6986 100644 --- a/Shared/ViewModels/LibraryViewModel.swift +++ b/Shared/ViewModels/LibraryViewModel.swift @@ -13,8 +13,8 @@ import UIKit final class LibraryViewModel: ViewModel { - @Default(.Customization.libraryPosterType) - var libraryPosterType + @Default(.Customization.Library.gridPosterType) + var libraryGridPosterType @Published var items: [BaseItemDto] = [] @@ -35,8 +35,8 @@ final class LibraryViewModel: ViewModel { var studio: NameGuidPair? private var pageItemSize: Int { - let height = libraryPosterType == .portrait ? libraryPosterType.width * 1.5 : libraryPosterType.width / 1.77 - return UIScreen.itemsFillableOnScreen(width: libraryPosterType.width, height: height) + let height = libraryGridPosterType == .portrait ? libraryGridPosterType.width * 1.5 : libraryGridPosterType.width / 1.77 + return UIScreen.itemsFillableOnScreen(width: libraryGridPosterType.width, height: height) } var enabledFilterType: [FilterType] { diff --git a/Swiftfin tvOS/Views/LibraryView.swift b/Swiftfin tvOS/Views/LibraryView.swift index a9952565..4e953045 100644 --- a/Swiftfin tvOS/Views/LibraryView.swift +++ b/Swiftfin tvOS/Views/LibraryView.swift @@ -20,7 +20,7 @@ struct LibraryView: View { @State private var scrollViewOffset: CGPoint = .zero - @Default(.Customization.libraryPosterType) + @Default(.Customization.Library.gridPosterType) var libraryPosterType @ViewBuilder diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 72fa88f3..8a3e49f8 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -83,7 +83,6 @@ 53ABFDEB2679753200886593 /* ConnectToServerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53ABFDEA2679753200886593 /* ConnectToServerView.swift */; }; 53ABFDED26799D7700886593 /* ActivityIndicator in Frameworks */ = {isa = PBXBuildFile; productRef = 53ABFDEC26799D7700886593 /* ActivityIndicator */; }; 53CD2A40268A49C2002ABD4E /* ItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CD2A3F268A49C2002ABD4E /* ItemView.swift */; }; - 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53DF641D263D9C0600A7CD1A /* LibraryView.swift */; }; 53EE24E6265060780068F029 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53EE24E5265060780068F029 /* SearchView.swift */; }; 5D1603FC278A3D5800D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */; }; @@ -306,6 +305,11 @@ E13DD3FA2717E961009D4DAF /* UserListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3F82717E961009D4DAF /* UserListViewModel.swift */; }; E13DD3FC2717EAE8009D4DAF /* UserListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD3FB2717EAE8009D4DAF /* UserListView.swift */; }; E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */; }; + E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; + E13F05ED28BC9000003499D2 /* LibraryViewType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EB28BC9000003499D2 /* LibraryViewType.swift */; }; + E13F05F128BC9016003499D2 /* LibraryItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */; }; + E13F05F228BC9016003499D2 /* LibraryItemRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */; }; + E13F05F328BC9016003499D2 /* LibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E13F05F028BC9016003499D2 /* LibraryView.swift */; }; E1546777289AF46E00087E35 /* CollectionItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546776289AF46E00087E35 /* CollectionItemView.swift */; }; E154677A289AF48200087E35 /* CollectionItemContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1546779289AF48200087E35 /* CollectionItemContentView.swift */; }; E168BD10289A4162001A6922 /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD08289A4162001A6922 /* HomeView.swift */; }; @@ -616,7 +620,6 @@ 53ABFDEA2679753200886593 /* ConnectToServerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConnectToServerView.swift; sourceTree = ""; }; 53CD2A3F268A49C2002ABD4E /* ItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemView.swift; sourceTree = ""; }; 53D5E3DC264B47EE00BADDC8 /* MobileVLCKit.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = MobileVLCKit.xcframework; path = Carthage/Build/MobileVLCKit.xcframework; sourceTree = ""; }; - 53DF641D263D9C0600A7CD1A /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryFilterView.swift; sourceTree = ""; }; 53EE24E5265060780068F029 /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = ""; }; 5D1603FB278A3D5700D22B99 /* SubtitleSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtitleSize.swift; sourceTree = ""; }; @@ -769,6 +772,9 @@ E13DD3F82717E961009D4DAF /* UserListViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListViewModel.swift; sourceTree = ""; }; E13DD3FB2717EAE8009D4DAF /* UserListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListView.swift; sourceTree = ""; }; E13DD4012717EE79009D4DAF /* UserListCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserListCoordinator.swift; sourceTree = ""; }; + E13F05EB28BC9000003499D2 /* LibraryViewType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryViewType.swift; sourceTree = ""; }; + E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryItemRow.swift; sourceTree = ""; }; + E13F05F028BC9016003499D2 /* LibraryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; E1546776289AF46E00087E35 /* CollectionItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemView.swift; sourceTree = ""; }; E1546779289AF48200087E35 /* CollectionItemContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionItemContentView.swift; sourceTree = ""; }; E168BD08289A4162001A6922 /* HomeView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = ""; }; @@ -1151,6 +1157,7 @@ E19169CD272514760085832A /* HTTPScheme.swift */, E1C925F328875037002A7A66 /* ItemViewType.swift */, E1E1644328BC60C600323B0A /* LibraryItem.swift */, + E13F05EB28BC9000003499D2 /* LibraryViewType.swift */, E1AA331E2782639D00F6439C /* OverlayType.swift */, E1C925F62887504B002A7A66 /* PanDirectionGestureRecognizer.swift */, E193D4DA27193CCA00900D82 /* PillStackable.swift */, @@ -1681,7 +1688,7 @@ E1EBCB45278BD595009FE6E9 /* ItemOverviewView.swift */, E14F7D0A26DB3714007C3AE6 /* ItemView */, 53E4E646263F6CF100F67C6B /* LibraryFilterView.swift */, - 53DF641D263D9C0600A7CD1A /* LibraryView.swift */, + E13F05EE28BC9016003499D2 /* LibraryView */, C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */, C400DB6C27FE8E65007B65FE /* LiveTVChannelItemWideElement.swift */, C400DB6927FE894F007B65FE /* LiveTVChannelsView.swift */, @@ -1700,6 +1707,15 @@ path = Views; sourceTree = ""; }; + E13F05EE28BC9016003499D2 /* LibraryView */ = { + isa = PBXGroup; + children = ( + E1C55AB228BD051700A9AD88 /* Components */, + E13F05F028BC9016003499D2 /* LibraryView.swift */, + ); + path = LibraryView; + sourceTree = ""; + }; E14F7D0A26DB3714007C3AE6 /* ItemView */ = { isa = PBXGroup; children = ( @@ -2008,6 +2024,14 @@ path = ContinueWatchingView; sourceTree = ""; }; + E1C55AB228BD051700A9AD88 /* Components */ = { + isa = PBXGroup; + children = ( + E13F05EF28BC9016003499D2 /* LibraryItemRow.swift */, + ); + path = Components; + sourceTree = ""; + }; E1C812CF277AE4C700918266 /* VideoPlayerCoordinator */ = { isa = PBXGroup; children = ( @@ -2472,6 +2496,7 @@ 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, 536D3D81267BDFC60004248C /* PortraitItemElement.swift in Sources */, 5D1603FD278A40DB00D22B99 /* SubtitleSize.swift in Sources */, + E13F05F228BC9016003499D2 /* LibraryItemRow.swift in Sources */, E103A6A7278AB6D700820EC7 /* CinematicResumeCardView.swift in Sources */, 62E1DCC4273CE19800C9AE76 /* URLExtensions.swift in Sources */, E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */, @@ -2527,6 +2552,7 @@ E193D5512719432400900D82 /* ServerDetailViewModel.swift in Sources */, C4E5081B2703F82A0045C9AB /* MediaView.swift in Sources */, E193D53B27193F9200900D82 /* SettingsCoordinator.swift in Sources */, + E13F05ED28BC9000003499D2 /* LibraryViewType.swift in Sources */, E18E021C2887492B0022598C /* BlurView.swift in Sources */, E1E5D5442783BB5100692DFE /* ItemDetailsView.swift in Sources */, E10D87E327852FD000BD264C /* EpisodesRowManager.swift in Sources */, @@ -2646,7 +2672,6 @@ E107BB9327880A8F00354E07 /* CollectionItemViewModel.swift in Sources */, 532175402671EE4F005491E6 /* LibraryFilterView.swift in Sources */, E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */, - 53DF641E263D9C0600A7CD1A /* LibraryView.swift in Sources */, E11CEB8D28999B4A003E74C7 /* FontExtensions.swift in Sources */, E1C812CE277AE43100918266 /* VideoPlayerViewModel.swift in Sources */, E11895A9289383BC0042947B /* ScrollViewOffsetModifier.swift in Sources */, @@ -2654,6 +2679,7 @@ E18CE0B228A229E70092E7F1 /* UserDtoExtensions.swift in Sources */, E18E01F0288747230022598C /* AttributeHStack.swift in Sources */, 6334175B287DDFB9000603CE /* QuickConnectSettingsView.swift in Sources */, + E13F05F128BC9016003499D2 /* LibraryItemRow.swift in Sources */, E18E0205288749200022598C /* AppIcon.swift in Sources */, E168BD10289A4162001A6922 /* HomeView.swift in Sources */, E18E01AB288746AF0022598C /* PillHStack.swift in Sources */, @@ -2753,7 +2779,9 @@ 09389CC726819B4600AE350E /* VideoPlayerModel.swift in Sources */, E1D4BF872719D27100A11E64 /* Bitrates.swift in Sources */, 6220D0B726D5EE1100B8E046 /* SearchCoordinator.swift in Sources */, + E13F05F328BC9016003499D2 /* LibraryView.swift in Sources */, E13DD3EF27178F87009D4DAF /* SwiftfinNotificationCenter.swift in Sources */, + E13F05EC28BC9000003499D2 /* LibraryViewType.swift in Sources */, 5377CBF5263B596A003A4E83 /* JellyfinPlayerApp.swift in Sources */, E13DD4022717EE79009D4DAF /* UserListCoordinator.swift in Sources */, E1FCD09626C47118007C8DCF /* ErrorMessage.swift in Sources */, diff --git a/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift b/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift new file mode 100644 index 00000000..43140bb5 --- /dev/null +++ b/Swiftfin/Views/LibraryView/Components/LibraryItemRow.swift @@ -0,0 +1,58 @@ +// +// Swiftfin is subject to the terms of the Mozilla Public +// License, v2.0. If a copy of the MPL was not distributed with this +// file, you can obtain one at https://mozilla.org/MPL/2.0/. +// +// Copyright (c) 2022 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct LibraryItemRow: View { + + @EnvironmentObject + private var router: LibraryCoordinator.Router + + let item: BaseItemDto + + var body: some View { + Button { + router.route(to: \.item, item) + } label: { + HStack(alignment: .bottom) { + PosterButton(item: item, type: .portrait) + .scaleItem(0.6) + .content { _ in } + + VStack(alignment: .leading) { + Text(item.displayName) + .foregroundColor(.primary) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + DotHStack { + if let premiereYear = item.premiereDateYear { + Text(premiereYear) + } + + if let runtime = item.getItemRuntime() { + Text(runtime) + } + + if let officialRating = item.officialRating { + Text(officialRating) + } + } + .font(.caption) + .foregroundColor(Color(UIColor.lightGray)) + } + .padding(.vertical) + + Spacer() + } + } + } +} diff --git a/Swiftfin/Views/LibraryView.swift b/Swiftfin/Views/LibraryView/LibraryView.swift similarity index 53% rename from Swiftfin/Views/LibraryView.swift rename to Swiftfin/Views/LibraryView/LibraryView.swift index ee6432e7..f6e00ecb 100644 --- a/Swiftfin/Views/LibraryView.swift +++ b/Swiftfin/Views/LibraryView/LibraryView.swift @@ -13,12 +13,14 @@ import SwiftUI struct LibraryView: View { @EnvironmentObject - private var libraryRouter: LibraryCoordinator.Router + private var router: LibraryCoordinator.Router @ObservedObject var viewModel: LibraryViewModel - @Default(.Customization.libraryPosterType) - private var libraryPosterType + @Default(.Customization.Library.gridPosterType) + private var libraryGridPosterType + @Default(.Customization.Library.viewType) + private var libraryViewType @ViewBuilder private var loadingView: some View { @@ -31,21 +33,41 @@ struct LibraryView: View { } private var gridLayout: NSCollectionLayoutSection.GridLayoutMode { - if libraryPosterType == .landscape && UIDevice.isPhone { + if libraryGridPosterType == .landscape && UIDevice.isPhone { return .fixedNumberOfColumns(2) } else { - return .adaptive(withMinItemSize: libraryPosterType.width + (UIDevice.isIPad ? 10 : 0)) + return .adaptive(withMinItemSize: libraryGridPosterType.width + (UIDevice.isIPad ? 10 : 0)) } } @ViewBuilder - private var libraryItemsView: some View { + private var libraryListView: some View { CollectionView(items: viewModel.items) { _, item, _ in - PosterButton(item: item, type: libraryPosterType) + LibraryItemRow(item: item) + .padding() + } + .layout { _, layoutEnvironment in + .list(using: .init(appearance: .plain), layoutEnvironment: layoutEnvironment) + } + .willReachEdge(insets: .init(top: 0, leading: 0, bottom: 200, trailing: 0)) { edge in + if !viewModel.isLoading && edge == .bottom { + viewModel.requestNextPageAsync() + } + } + .configure { configuration in + configuration.showsVerticalScrollIndicator = false + } + .ignoresSafeArea() + } + + @ViewBuilder + private var libraryGridView: some View { + CollectionView(items: viewModel.items) { _, item, _ in + PosterButton(item: item, type: libraryGridPosterType) .onSelect { item in - libraryRouter.route(to: \.item, item) + router.route(to: \.item, item) } - .scaleItem(libraryPosterType == .landscape && UIDevice.isPhone ? 0.8 : 1) + .scaleItem(libraryGridPosterType == .landscape && UIDevice.isPhone ? 0.8 : 1) } .layout { _, layoutEnvironment in .grid( @@ -71,15 +93,35 @@ struct LibraryView: View { } else if viewModel.items.isEmpty { noResultsView } else { - libraryItemsView + switch libraryViewType { + case .grid: + libraryGridView + case .list: + libraryListView + } } } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItemGroup(placement: .navigationBarTrailing) { + Button { + switch libraryViewType { + case .grid: + libraryViewType = .list + case .list: + libraryViewType = .grid + } + } label: { + switch libraryViewType { + case .grid: + Image(systemName: "list.dash") + case .list: + Image(systemName: "square.grid.2x2") + } + } Button { - libraryRouter + router .route(to: \.filter, ( filters: $viewModel.filters, enabledFilterType: viewModel.enabledFilterType, diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift index 4cf99f14..a028ee9c 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings.swift @@ -33,12 +33,15 @@ struct CustomizeViewsSettings: View { var recommendedPosterType @Default(.Customization.searchPosterType) var searchPosterType - @Default(.Customization.libraryPosterType) - var libraryPosterType @Default(.Customization.Episodes.useSeriesLandscapeBackdrop) var useSeriesLandscapeBackdrop + @Default(.Customization.Library.gridPosterType) + var libraryGridPosterType + @Default(.Customization.Library.viewType) + var libraryViewType + var body: some View { List { Section { @@ -96,12 +99,6 @@ struct CustomizeViewsSettings: View { Text(type.localizedName).tag(type.rawValue) } } - - Picker(L10n.library, selection: $libraryPosterType) { - ForEach(PosterType.allCases, id: \.self) { type in - Text(type.localizedName).tag(type.rawValue) - } - } } header: { // TODO: localize after organization Text("Posters") @@ -114,6 +111,23 @@ struct CustomizeViewsSettings: View { // TODO: localize after organization Text("Episode Landscape Poster") } + + Section { + Picker(L10n.library, selection: $libraryGridPosterType) { + ForEach(PosterType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } + + Picker(L10n.items, selection: $libraryViewType) { + ForEach(LibraryViewType.allCases, id: \.self) { type in + Text(type.localizedName).tag(type.rawValue) + } + } + } header: { + // TODO: localize after organization + Text("Library") + } } .navigationTitle(L10n.customize) }