iOS/iPadOS - Transition library views to Collection Views (#536)

This commit is contained in:
Ethan Pippin 2022-08-26 18:23:36 -06:00 committed by GitHub
parent 8f42e20f0b
commit 5d0f933a2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 214 additions and 393 deletions

View File

@ -42,7 +42,7 @@ final class LibraryCoordinator: NavigationCoordinatable {
@ViewBuilder
func makeStart() -> some View {
LibraryView(viewModel: self.viewModel, title: title)
LibraryView(viewModel: self.viewModel)
}
func makeSearch(viewModel: LibrarySearchViewModel) -> SearchCoordinator {

View File

@ -46,7 +46,7 @@ extension View {
}
}
func poster(type: PosterType, width: CGFloat) -> some View {
func posterStyle(type: PosterType, width: CGFloat) -> some View {
Group {
switch type {
case .portrait:

View File

@ -7,12 +7,21 @@
//
import Defaults
import Foundation
import SwiftUI
enum PosterType: String, CaseIterable, Defaults.Serializable {
case portrait
case landscape
var width: CGFloat {
switch self {
case .portrait:
return Width.portrait
case .landscape:
return Width.landscape
}
}
var localizedName: String {
switch self {
case .portrait:
@ -21,4 +30,18 @@ enum PosterType: String, CaseIterable, Defaults.Serializable {
return "Landscape"
}
}
enum Width {
#if os(tvOS)
static let portrait = 250.0
static let landscape = 490.0
#else
@ScaledMetric(relativeTo: .largeTitle)
static var portrait = 100.0
@ScaledMetric(relativeTo: .largeTitle)
static var landscape = 200.0
#endif
}
}

View File

@ -12,10 +12,12 @@ import JellyfinAPI
struct LibraryFilters: Codable, Hashable {
var filters: [ItemFilter] = []
var sortOrder: [APISortOrder] = [.descending]
var sortOrder: [APISortOrder] = [.ascending]
var withGenres: [NameGuidPair] = []
var tags: [String] = []
var sortBy: [SortBy] = [.name]
static let `default` = LibraryFilters()
}
public enum SortBy: String, Codable, CaseIterable {

View File

@ -36,6 +36,7 @@ extension Defaults.Keys {
static let recentlyAddedPosterType = Key<PosterType>("recentlyAddedPosterType", default: .portrait, suite: .generalSuite)
static let latestInLibraryPosterType = Key<PosterType>("latestInLibraryPosterType", default: .portrait, suite: .generalSuite)
static let recommendedPosterType = Key<PosterType>("recommendedPosterType", default: .portrait, suite: .generalSuite)
static let libraryPosterType = Key<PosterType>("libraryPosterType", default: .portrait, suite: .generalSuite)
enum Episodes {
static let useSeriesLandscapeBackdrop = Key<Bool>("useSeriesBackdrop", default: true, suite: .generalSuite)

View File

@ -8,26 +8,16 @@
import Combine
import Defaults
import Foundation
import JellyfinAPI
import SwiftUICollection
import UIKit
typealias LibraryRow = CollectionRow<Int, LibraryRowCell>
struct LibraryRowCell: Hashable {
let id = UUID()
let item: BaseItemDto?
var loadingCell: Bool = false
}
final class LibraryViewModel: ViewModel {
@Default(.Customization.libraryPosterType)
var libraryPosterType
@Published
var items: [BaseItemDto] = []
@Published
var rows: [LibraryRow] = []
@Published
var totalPages = 0
@Published
@ -43,8 +33,11 @@ final class LibraryViewModel: ViewModel {
var person: BaseItemPerson?
var genre: NameGuidPair?
var studio: NameGuidPair?
private let columns: Int
private let pageItemSize: Int
private var pageItemSize: Int {
let height = libraryPosterType == .portrait ? libraryPosterType.width * 1.5 : libraryPosterType.width / 1.77
return UIScreen.itemsFillableOnScreen(width: libraryPosterType.width, height: height)
}
var enabledFilterType: [FilterType] {
if genre == nil {
@ -59,18 +52,13 @@ final class LibraryViewModel: ViewModel {
person: BaseItemPerson? = nil,
genre: NameGuidPair? = nil,
studio: NameGuidPair? = nil,
filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name]),
columns: Int = 7
filters: LibraryFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], sortBy: [.name])
) {
self.parentID = parentID
self.person = person
self.genre = genre
self.studio = studio
self.filters = filters
self.columns = columns
// Size is typical size of portrait items
self.pageItemSize = UIScreen.itemsFillableOnScreen(width: 130, height: 185)
super.init()
@ -137,7 +125,6 @@ final class LibraryViewModel: ViewModel {
self.totalPages = Int(totalPages)
self.hasNextPage = self.currentPage < self.totalPages - 1
self.items.append(contentsOf: response.items ?? [])
self.rows = self.calculateRows(for: self.items)
})
.store(in: &cancellables)
}
@ -146,50 +133,13 @@ final class LibraryViewModel: ViewModel {
currentPage += 1
requestItemsAsync(with: filters)
}
// tvOS calculations for collection view
private func calculateRows(for itemList: [BaseItemDto]) -> [LibraryRow] {
guard !itemList.isEmpty else { return [] }
let rowCount = itemList.count / columns
var calculatedRows = [LibraryRow]()
for i in 0 ... rowCount {
let firstItemIndex = i * columns
var lastItemIndex = firstItemIndex + columns
if lastItemIndex > itemList.count {
lastItemIndex = itemList.count
}
var rowCells = [LibraryRowCell]()
for item in itemList[firstItemIndex ..< lastItemIndex] {
let newCell = LibraryRowCell(item: item)
rowCells.append(newCell)
}
if i == rowCount, hasNextPage {
var loadingCell = LibraryRowCell(item: nil)
loadingCell.loadingCell = true
rowCells.append(loadingCell)
}
calculatedRows.append(LibraryRow(
section: i,
items: rowCells
))
}
return calculatedRows
}
}
extension UIScreen {
static func itemsFillableOnScreen(width: CGFloat, height: CGFloat) -> Int {
let screenSize = UIScreen.main.bounds.height * UIScreen.main.bounds.width
let itemSize = width * height
#if os(tvOS)
return Int(screenSize / itemSize) * 2
#else
return Int(screenSize / itemSize)
#endif
return Int(screenSize / itemSize)
}
}

View File

@ -12,6 +12,14 @@ import JellyfinAPI
import Stinsen
import SwiftUICollection
typealias LibraryRow = CollectionRow<Int, LibraryRowCell>
struct LibraryRowCell: Hashable {
let id = UUID()
let item: BaseItemDto?
var loadingCell: Bool = false
}
final class TVLibrariesViewModel: ViewModel {
@Published

View File

@ -1,20 +0,0 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.765",
"green" : "0.361",
"red" : "0.667"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -8,10 +8,12 @@
import SwiftUI
struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: View {
enum PosterButtonWidth {
static let landscape = 490.0
static let portrait = 250.0
}
private let landscapePosterWidth = 490.0
private let portraitPosterWidth = 250.0
struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: View {
private let item: Item
private let type: PosterType
@ -26,9 +28,9 @@ struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu
private var itemWidth: CGFloat {
switch type {
case .portrait:
return portraitPosterWidth * itemScale
return PosterButtonWidth.portrait * itemScale
case .landscape:
return landscapePosterWidth * itemScale
return PosterButtonWidth.landscape * itemScale
}
}
@ -62,10 +64,10 @@ struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu
switch type {
case .portrait:
ImageView(item.portraitPosterImageSource(maxWidth: itemWidth))
.poster(type: type, width: itemWidth)
.posterStyle(type: type, width: itemWidth)
case .landscape:
ImageView(item.landscapePosterImageSources(maxWidth: itemWidth, single: singleImage))
.poster(type: type, width: itemWidth)
.posterStyle(type: type, width: itemWidth)
}
}
.buttonStyle(CardButtonStyle())
@ -74,7 +76,7 @@ struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu
})
.overlay {
imageOverlay(item)
.poster(type: type, width: itemWidth)
.posterStyle(type: type, width: itemWidth)
}
.posterShadow()

View File

@ -44,7 +44,7 @@ struct LatestInLibraryView: View {
.font(.title3)
}
}
.poster(type: .portrait, width: 250)
.posterStyle(type: .portrait, width: 250)
}
.buttonStyle(PlainButtonStyle())
}

View File

@ -6,95 +6,65 @@
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import JellyfinAPI
import CollectionView
import Defaults
import Introspect
import SwiftUI
import SwiftUICollection
struct LibraryView: View {
@EnvironmentObject
private var libraryRouter: LibraryCoordinator.Router
@StateObject
@ObservedObject
var viewModel: LibraryViewModel
var title: String
// MARK: tracks for grid
var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
@State
var isShowingSearchView = false
@State
var isShowingFilterView = false
private var scrollViewOffset: CGPoint = .zero
@Default(.Customization.libraryPosterType)
var libraryPosterType
@ViewBuilder
private var loadingView: some View {
ProgressView()
}
@ViewBuilder
private var noResultsView: some View {
L10n.noResults.text
}
@ViewBuilder
private var libraryItemsView: some View {
CollectionView(items: viewModel.items) { _, item, _ in
PosterButton(item: item, type: libraryPosterType)
.onSelect { item in
libraryRouter.route(to: \.item, item)
}
}
.layout { _, layoutEnvironment in
.grid(
layoutEnvironment: layoutEnvironment,
layoutMode: .fixedNumberOfColumns(6),
lineSpacing: 50
)
}
.willReachEdge(insets: .init(top: 0, leading: 0, bottom: 600, trailing: 0)) { edge in
if !viewModel.isLoading && edge == .bottom {
viewModel.requestNextPageAsync()
}
}
.scrollViewOffset($scrollViewOffset)
.ignoresSafeArea()
}
var body: some View {
if viewModel.rows.isEmpty && viewModel.isLoading == true {
ProgressView()
} else if !viewModel.rows.isEmpty {
CollectionView(rows: viewModel.rows) { _, _ in
let itemSize = NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .fractionalHeight(1)
)
let item = NSCollectionLayoutItem(layoutSize: itemSize)
let groupSize = NSCollectionLayoutSize(
widthDimension: .absolute(200),
heightDimension: .absolute(300)
)
let group = NSCollectionLayoutGroup.horizontal(
layoutSize: groupSize,
subitems: [item]
)
let header =
NSCollectionLayoutBoundarySupplementaryItem(
layoutSize: NSCollectionLayoutSize(
widthDimension: .fractionalWidth(1),
heightDimension: .absolute(44)
),
elementKind: UICollectionView.elementKindSectionHeader,
alignment: .topLeading
)
let section = NSCollectionLayoutSection(group: group)
section.contentInsets = NSDirectionalEdgeInsets(top: 30, leading: 0, bottom: 80, trailing: 80)
section.interGroupSpacing = 48
section.orthogonalScrollingBehavior = .continuous
section.boundarySupplementaryItems = [header]
return section
} cell: { _, cell in
GeometryReader { _ in
if let item = cell.item {
Button {
libraryRouter.route(to: \.item, item)
} label: {
PortraitItemElement(item: item)
}
.buttonStyle(PlainNavigationLinkButtonStyle())
.onAppear {
if item == viewModel.items.last && viewModel.hasNextPage {
viewModel.requestNextPageAsync()
}
}
} else if cell.loadingCell {
ProgressView()
.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity, alignment: .center)
}
}
} supplementaryView: { _, indexPath in
HStack {
Spacer()
}.accessibilityIdentifier("\(indexPath.section).\(indexPath.row)")
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea(.all)
} else {
VStack {
L10n.noResults.text
Button {} label: {
L10n.refresh.text
}
Group {
if viewModel.isLoading && viewModel.items.isEmpty {
loadingView
} else if viewModel.items.isEmpty {
noResultsView
} else {
libraryItemsView
}
}
}

View File

@ -223,7 +223,6 @@
C4BE07892728448B003F4AD1 /* LiveTVChannelsCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE07872728448B003F4AD1 /* LiveTVChannelsCoordinator.swift */; };
C4BE078C272844AF003F4AD1 /* LiveTVChannelsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078A272844AF003F4AD1 /* LiveTVChannelsView.swift */; };
C4BE078E27298818003F4AD1 /* LiveTVHomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BE078D27298817003F4AD1 /* LiveTVHomeView.swift */; };
C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = C4D0CE4A2848570700345D11 /* ASCollectionView */; };
C4E5081B2703F82A0045C9AB /* LibraryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E508172703E8190045C9AB /* LibraryListView.swift */; };
C4E5081D2703F8370045C9AB /* LibrarySearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5081C2703F8370045C9AB /* LibrarySearchView.swift */; };
C4E5598928124C10003DECA5 /* LiveTVChannelItemElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5598828124C10003DECA5 /* LiveTVChannelItemElement.swift */; };
@ -250,7 +249,6 @@
E10EAA53277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; };
E10EAA54277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */; };
E1101177281B1E8A006A3584 /* Puppy in Frameworks */ = {isa = PBXBuildFile; productRef = E1101176281B1E8A006A3584 /* Puppy */; };
E111DE222790BB46008118A3 /* DetectBottomScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */; };
E1171A1928A2212600FA1AF5 /* QuickConnectView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */; };
E118959D289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; };
E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */ = {isa = PBXBuildFile; fileRef = E118959C289312020042947B /* BaseItemPerson+Poster.swift */; };
@ -321,6 +319,8 @@
E168BD14289A4162001A6922 /* LatestInLibraryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0E289A4162001A6922 /* LatestInLibraryView.swift */; };
E168BD15289A4162001A6922 /* HomeErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E168BD0F289A4162001A6922 /* HomeErrorView.swift */; };
E16AA60828A364A6009A983C /* PosterButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = E16AA60728A364A6009A983C /* PosterButton.swift */; };
E1734D7C28B9577700C66367 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1734D7B28B9577700C66367 /* CollectionView */; };
E1734D7E28B9578100C66367 /* CollectionView in Frameworks */ = {isa = PBXBuildFile; productRef = E1734D7D28B9578100C66367 /* CollectionView */; };
E173DA5026D048D600CC4EB7 /* ServerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA4F26D048D600CC4EB7 /* ServerDetailView.swift */; };
E173DA5226D04AAF00CC4EB7 /* ColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5126D04AAF00CC4EB7 /* ColorExtension.swift */; };
E173DA5426D050F500CC4EB7 /* ServerDetailViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E173DA5326D050F500CC4EB7 /* ServerDetailViewModel.swift */; };
@ -741,7 +741,6 @@
E10D87E127852FD000BD264C /* EpisodesRowManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EpisodesRowManager.swift; sourceTree = "<group>"; };
E10EAA4E277BBCC4000269ED /* CGSizeExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGSizeExtensions.swift; sourceTree = "<group>"; };
E10EAA52277BBD17000269ED /* BaseItemDto+VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemDto+VideoPlayerViewModel.swift"; sourceTree = "<group>"; };
E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetectBottomScrollView.swift; sourceTree = "<group>"; };
E1171A1828A2212600FA1AF5 /* QuickConnectView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickConnectView.swift; sourceTree = "<group>"; };
E118959C289312020042947B /* BaseItemPerson+Poster.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BaseItemPerson+Poster.swift"; sourceTree = "<group>"; };
E11895A8289383BC0042947B /* ScrollViewOffsetModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewOffsetModifier.swift; sourceTree = "<group>"; };
@ -944,6 +943,7 @@
62666E1927E501D000EC0ECD /* CoreFoundation.framework in Frameworks */,
62666E2E27E5021400EC0ECD /* Security.framework in Frameworks */,
53ABFDDC267972BF00886593 /* TVServices.framework in Frameworks */,
E1734D7E28B9578100C66367 /* CollectionView in Frameworks */,
62666E1F27E501DF00EC0ECD /* CoreText.framework in Frameworks */,
E13DD3CD27164CA7009D4DAF /* CoreStore in Frameworks */,
62666E1527E501C800EC0ECD /* AVFoundation.framework in Frameworks */,
@ -961,12 +961,12 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C4D0CE4B2848570700345D11 /* ASCollectionView in Frameworks */,
62666E3E27E503FA00EC0ECD /* MediaAccessibility.framework in Frameworks */,
62666DFF27E5016400EC0ECD /* CFNetwork.framework in Frameworks */,
E13DD3D327168E65009D4DAF /* Defaults in Frameworks */,
E1101177281B1E8A006A3584 /* Puppy in Frameworks */,
E1002B682793CFBA00E47059 /* Algorithms in Frameworks */,
E1734D7C28B9577700C66367 /* CollectionView in Frameworks */,
62666E1127E501B900EC0ECD /* UIKit.framework in Frameworks */,
62666DF727E5012C00EC0ECD /* MobileVLCKit.xcframework in Frameworks */,
62666E0327E5017100EC0ECD /* CoreMedia.framework in Frameworks */,
@ -1409,7 +1409,6 @@
53F866422687A45400DCD1D7 /* Components */ = {
isa = PBXGroup;
children = (
E111DE212790BB46008118A3 /* DetectBottomScrollView.swift */,
E18E01A7288746AF0022598C /* DotHStack.swift */,
E18E01A5288746AF0022598C /* PillHStack.swift */,
E16AA60728A364A6009A983C /* PosterButton.swift */,
@ -2155,6 +2154,7 @@
E13AF3B728A0C598009093AB /* NukeExtensions */,
E13AF3B928A0C598009093AB /* NukeUI */,
E13AF3BB28A0C59E009093AB /* BlurHashKit */,
E1734D7D28B9578100C66367 /* CollectionView */,
);
productName = "JellyfinPlayer tvOS";
productReference = 535870602669D21600D05A09 /* Swiftfin tvOS.app */;
@ -2190,11 +2190,11 @@
E1002B672793CFBA00E47059 /* Algorithms */,
62666E3827E502CE00EC0ECD /* SwizzleSwift */,
E1101176281B1E8A006A3584 /* Puppy */,
C4D0CE4A2848570700345D11 /* ASCollectionView */,
C409CE9D285044C800CABC12 /* SwiftUICollection */,
E19E6E0428A0B958005C10C8 /* Nuke */,
E19E6E0628A0B958005C10C8 /* NukeUI */,
E19E6E0928A0BEFF005C10C8 /* BlurHashKit */,
E1734D7B28B9577700C66367 /* CollectionView */,
);
productName = JellyfinPlayer;
productReference = 5377CBF1263B596A003A4E83 /* Swiftfin iOS.app */;
@ -2257,10 +2257,10 @@
E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */,
62666E3727E502CE00EC0ECD /* XCRemoteSwiftPackageReference "SwizzleSwift" */,
E1101175281B1E8A006A3584 /* XCRemoteSwiftPackageReference "Puppy" */,
C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */,
C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */,
E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */,
E19E6E0828A0BEFF005C10C8 /* XCRemoteSwiftPackageReference "BlurHashKit" */,
E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */,
);
productRefGroup = 5377CBF2263B596A003A4E83 /* Products */;
projectDirPath = "";
@ -2610,7 +2610,6 @@
625CB5772678C34300530A6E /* ConnectToServerViewModel.swift in Sources */,
C4BE07852728446F003F4AD1 /* LiveTVChannelsViewModel.swift in Sources */,
E1FA891B289A302300176FEB /* iPadOSCollectionItemView.swift in Sources */,
E111DE222790BB46008118A3 /* DetectBottomScrollView.swift in Sources */,
5D160403278A41FD00D22B99 /* VLCPlayer+subtitles.swift in Sources */,
E11895B32893844A0042947B /* BackgroundParallaxHeaderModifier.swift in Sources */,
536D3D78267BD5C30004248C /* ViewModel.swift in Sources */,
@ -3225,14 +3224,6 @@
kind = branch;
};
};
C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apptekstudios/ASCollectionView";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 2.0.0;
};
};
E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-algorithms.git";
@ -3297,6 +3288,14 @@
minimumVersion = 6.0.0;
};
};
E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/LePips/CollectionView";
requirement = {
branch = main;
kind = branch;
};
};
E19E6E0328A0B958005C10C8 /* XCRemoteSwiftPackageReference "Nuke" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/kean/Nuke";
@ -3369,11 +3368,6 @@
package = C409CE9A284EA6EA00CABC12 /* XCRemoteSwiftPackageReference "SwiftUICollection" */;
productName = SwiftUICollection;
};
C4D0CE4A2848570700345D11 /* ASCollectionView */ = {
isa = XCSwiftPackageProductDependency;
package = C4D0CE492848570700345D11 /* XCRemoteSwiftPackageReference "ASCollectionView" */;
productName = ASCollectionView;
};
E1002B672793CFBA00E47059 /* Algorithms */ = {
isa = XCSwiftPackageProductDependency;
package = E1002B662793CFBA00E47059 /* XCRemoteSwiftPackageReference "swift-algorithms" */;
@ -3454,6 +3448,16 @@
package = E13DD3D127168E65009D4DAF /* XCRemoteSwiftPackageReference "Defaults" */;
productName = Defaults;
};
E1734D7B28B9577700C66367 /* CollectionView */ = {
isa = XCSwiftPackageProductDependency;
package = E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */;
productName = CollectionView;
};
E1734D7D28B9578100C66367 /* CollectionView */ = {
isa = XCSwiftPackageProductDependency;
package = E1734D7A28B9577700C66367 /* XCRemoteSwiftPackageReference "CollectionView" */;
productName = CollectionView;
};
E178857C278037FD0094FBCF /* JellyfinAPI */ = {
isa = XCSwiftPackageProductDependency;
package = E10EAA43277BB646000269ED /* XCRemoteSwiftPackageReference "jellyfin-sdk-swift" */;

View File

@ -18,15 +18,6 @@
"version" : "0.6.5"
}
},
{
"identity" : "ascollectionview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apptekstudios/ASCollectionView",
"state" : {
"revision" : "4288744ba484c1062c109c0f28d72b629d321d55",
"version" : "2.1.1"
}
},
{
"identity" : "blurhashkit",
"kind" : "remoteSourceControl",
@ -36,6 +27,15 @@
"version" : "1.1.0"
}
},
{
"identity" : "collectionview",
"kind" : "remoteSourceControl",
"location" : "https://github.com/LePips/CollectionView",
"state" : {
"branch" : "main",
"revision" : "1dbf31d860626f8debdbb08201517a4684d226c6"
}
},
{
"identity" : "combineext",
"kind" : "remoteSourceControl",
@ -63,15 +63,6 @@
"version" : "6.3.0"
}
},
{
"identity" : "differencekit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/ra1028/DifferenceKit",
"state" : {
"revision" : "073b9671ce2b9b5b96398611427a1f929927e428",
"version" : "1.3.0"
}
},
{
"identity" : "jellyfin-sdk-swift",
"kind" : "remoteSourceControl",

View File

@ -1,101 +0,0 @@
//
// 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 SwiftUI
// https://stackoverflow.com/questions/56573373/swiftui-get-size-of-child
struct ChildSizeReader<Content: View>: View {
@Binding
var size: CGSize
let content: () -> Content
var body: some View {
ZStack {
content()
.background(GeometryReader { proxy in
Color.clear
.preference(key: SizePreferenceKey.self, value: proxy.size)
})
}
.onPreferenceChange(SizePreferenceKey.self) { preferences in
self.size = preferences
}
}
}
struct SizePreferenceKey: PreferenceKey {
typealias Value = CGSize
static var defaultValue: Value = .zero
static func reduce(value _: inout Value, nextValue: () -> Value) {
_ = nextValue()
}
}
struct ViewOffsetKey: PreferenceKey {
typealias Value = CGFloat
static var defaultValue = CGFloat.zero
static func reduce(value: inout Value, nextValue: () -> Value) {
value += nextValue()
}
}
struct DetectBottomScrollView<Content: View>: View {
private let spaceName = "scroll"
@State
private var wholeSize: CGSize = .zero
@State
private var scrollViewSize: CGSize = .zero
@State
private var previousDidReachBottom = false
let content: () -> Content
let didReachBottom: (Bool) -> Void
init(
content: @escaping () -> Content,
didReachBottom: @escaping (Bool) -> Void
) {
self.content = content
self.didReachBottom = didReachBottom
}
var body: some View {
ChildSizeReader(size: $wholeSize) {
ScrollView {
ChildSizeReader(size: $scrollViewSize) {
content()
.background(GeometryReader { proxy in
Color.clear.preference(
key: ViewOffsetKey.self,
value: -1 * proxy.frame(in: .named(spaceName)).origin.y
)
})
.onPreferenceChange(
ViewOffsetKey.self,
perform: { value in
if value >= scrollViewSize.height - wholeSize.height {
if !previousDidReachBottom {
previousDidReachBottom = true
didReachBottom(true)
}
} else {
if previousDidReachBottom {
previousDidReachBottom = false
didReachBottom(false)
}
}
}
)
}
}
.coordinateSpace(name: spaceName)
}
}
}

View File

@ -10,12 +10,6 @@ import SwiftUI
struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu: View>: View {
@ScaledMetric(relativeTo: .largeTitle)
private var landscapePosterWidth = 200.0
@ScaledMetric(relativeTo: .largeTitle)
private var portraitPosterWidth = 100.0
private let item: Item
private let type: PosterType
private let itemScale: CGFloat
@ -27,12 +21,7 @@ struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu
private let singleImage: Bool
private var itemWidth: CGFloat {
switch type {
case .portrait:
return portraitPosterWidth * itemScale
case .landscape:
return landscapePosterWidth * itemScale
}
type.width * itemScale
}
private init(
@ -72,10 +61,10 @@ struct PosterButton<Item: Poster, Content: View, ImageOverlay: View, ContextMenu
.contextMenu(menuItems: {
contextMenu(item)
})
.poster(type: type, width: itemWidth)
.posterStyle(type: type, width: itemWidth)
.overlay {
imageOverlay(item)
.poster(type: type, width: itemWidth)
.posterStyle(type: type, width: itemWidth)
}
.posterShadow()

View File

@ -6,30 +6,19 @@
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import Stinsen
import CollectionView
import Defaults
import SwiftUI
struct LibraryView: View {
@EnvironmentObject
private var libraryRouter: LibraryCoordinator.Router
@StateObject
@ObservedObject
var viewModel: LibraryViewModel
var title: String
// MARK: tracks for grid
var defaultFilters = LibraryFilters(filters: [], sortOrder: [.ascending], withGenres: [], tags: [], sortBy: [.name])
@State
private var tracks: [GridItem] = Array(
repeating: .init(.flexible(), alignment: .top),
count: Int(UIScreen.main.bounds.size.width) / 125
)
func recalcTracks() {
tracks = Array(repeating: .init(.flexible(), alignment: .top), count: Int(UIScreen.main.bounds.size.width) / 125)
}
@Default(.Customization.libraryPosterType)
var libraryPosterType
@ViewBuilder
private var loadingView: some View {
@ -41,42 +30,48 @@ struct LibraryView: View {
L10n.noResults.text
}
private var gridLayout: NSCollectionLayoutSection.GridLayoutMode {
if libraryPosterType == .landscape && UIDevice.isPhone {
return .fixedNumberOfColumns(2)
} else {
return .adaptive(withMinItemSize: libraryPosterType.width + (UIDevice.isIPad ? 10 : 0))
}
}
@ViewBuilder
private var libraryItemsView: some View {
DetectBottomScrollView {
VStack {
LazyVGrid(columns: tracks) {
ForEach(viewModel.items, id: \.id) { item in
PosterButton(item: item, type: .portrait)
.onSelect { item in
libraryRouter.route(to: \.item, item)
}
}
CollectionView(items: viewModel.items) { _, item, _ in
PosterButton(item: item, type: libraryPosterType)
.onSelect { item in
libraryRouter.route(to: \.item, item)
}
.ignoresSafeArea()
.listRowSeparator(.hidden)
.onRotate { _ in
recalcTracks()
}
Spacer()
.frame(height: 30)
}
} didReachBottom: { newValue in
if newValue && viewModel.hasNextPage {
.scaleItem(libraryPosterType == .landscape && UIDevice.isPhone ? 0.8 : 1)
}
.layout { _, layoutEnvironment in
.grid(
layoutEnvironment: layoutEnvironment,
layoutMode: gridLayout
)
}
.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()
}
var body: some View {
Group {
if viewModel.isLoading && viewModel.items.isEmpty {
ProgressView()
} else if !viewModel.items.isEmpty {
libraryItemsView
} else {
loadingView
} else if viewModel.items.isEmpty {
noResultsView
} else {
libraryItemsView
}
}
.navigationBarTitleDisplayMode(.inline)
@ -93,7 +88,7 @@ struct LibraryView: View {
} label: {
Image(systemName: "line.horizontal.3.decrease.circle")
}
.foregroundColor(viewModel.filters == defaultFilters ? .accentColor : Color(UIColor.systemOrange))
.foregroundColor(viewModel.filters == .default ? .accentColor : Color(UIColor.systemOrange))
Button {
libraryRouter.route(to: \.search, .init(parentID: viewModel.parentID))

View File

@ -6,7 +6,6 @@
// Copyright (c) 2022 Jellyfin & Jellyfin Contributors
//
import ASCollectionView
import Foundation
import JellyfinAPI
import SwiftUI
@ -37,29 +36,30 @@ struct LiveTVChannelsView: View {
if viewModel.isLoading == true {
ProgressView()
} else if !viewModel.channelPrograms.isEmpty {
ASCollectionView(data: viewModel.channelPrograms, dataID: \.self) { channelProgram, _ in
makeCellView(channelProgram)
}
.layout {
.grid(
layoutMode: .fixedNumberOfColumns(columns),
itemSpacing: 16,
lineSpacing: 4,
itemSize: .absolute(144)
)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.ignoresSafeArea()
.onAppear {
viewModel.startScheduleCheckTimer()
self.checkOrientation()
}
.onDisappear {
viewModel.stopScheduleCheckTimer()
}
.onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
self.checkOrientation()
}
Text("nothing")
// ASCollectionView(data: viewModel.channelPrograms, dataID: \.self) { channelProgram, _ in
// makeCellView(channelProgram)
// }
// .layout {
// .grid(
// layoutMode: .fixedNumberOfColumns(columns),
// itemSpacing: 16,
// lineSpacing: 4,
// itemSize: .absolute(144)
// )
// }
// .frame(maxWidth: .infinity, maxHeight: .infinity)
// .ignoresSafeArea()
// .onAppear {
// viewModel.startScheduleCheckTimer()
// self.checkOrientation()
// }
// .onDisappear {
// viewModel.stopScheduleCheckTimer()
// }
// .onReceive(NotificationCenter.default.publisher(for: UIDevice.orientationDidChangeNotification)) { _ in
// self.checkOrientation()
// }
} else {
VStack {
Text("No results.")

View File

@ -31,6 +31,8 @@ struct CustomizeViewsSettings: View {
var latestInLibraryPosterType
@Default(.Customization.recommendedPosterType)
var recommendedPosterType
@Default(.Customization.libraryPosterType)
var libraryPosterType
@Default(.Customization.Episodes.useSeriesLandscapeBackdrop)
var useSeriesLandscapeBackdrop
@ -74,7 +76,7 @@ struct CustomizeViewsSettings: View {
}
}
Picker(L10n.library, selection: $latestInLibraryPosterType) {
Picker(L10n.latestWithString(L10n.library), selection: $latestInLibraryPosterType) {
ForEach(PosterType.allCases, id: \.self) { type in
Text(type.localizedName).tag(type.rawValue)
}
@ -87,6 +89,11 @@ struct CustomizeViewsSettings: View {
// }
// }
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")