jellyflood/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift

506 lines
18 KiB
Swift

//
// 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) 2024 Jellyfin & Jellyfin Contributors
//
import CollectionVGrid
import Defaults
import JellyfinAPI
import SwiftUI
// TODO: need to think about better design for views that may not support current library display type
// - ex: channels/albums when in portrait/landscape
// - just have the supported view embedded in a container view?
// TODO: could bottom (defaults + stored) `onChange` copies be cleaned up?
// - more could be cleaned up if there was a "switcher" property wrapper that takes two
// sources and a switch and holds the current expected value
// - or if Defaults values were moved to StoredValues and each key would return/respond to
// what values they should have
// TODO: when there are no filters sometimes navigation bar will be clear until popped back to
/*
Note: Currently, it is a conscious decision to not have grid posters have subtitle content.
This is due to episodes, which have their `S_E_` subtitles, and these can be alongside
other items that don't have a subtitle which requires the entire library to implement
subtitle content but that doesn't look appealing. Until a solution arrives grid posters
will not have subtitle content.
There should be a solution since there are contexts where subtitles are desirable and/or
we can have subtitle content for other items.
Note: For `rememberLayout` and `rememberSort`, there are quirks for observing changes while a
library is open and the setting has been changed. For simplicity, do not enforce observing
changes and doing proper updates since there is complexity with what "actual" settings
should be applied.
*/
struct PagingLibraryView<Element: Poster>: View {
@Default(.Customization.Library.enabledDrawerFilters)
private var enabledDrawerFilters
@Default(.Customization.Library.rememberLayout)
private var rememberLayout
@Default
private var defaultDisplayType: LibraryDisplayType
@Default
private var defaultListColumnCount: Int
@Default
private var defaultPosterType: PosterDisplayType
@Default(.Customization.Library.letterPickerEnabled)
private var letterPickerEnabled
@Default(.Customization.Library.letterPickerOrientation)
private var letterPickerOrientation
@EnvironmentObject
private var router: LibraryCoordinator<Element>.Router
@State
private var layout: CollectionVGridLayout
@State
private var safeArea: EdgeInsets = .zero
@StoredValue
private var displayType: LibraryDisplayType
@StoredValue
private var listColumnCount: Int
@StoredValue
private var posterType: PosterDisplayType
@StateObject
private var collectionVGridProxy: CollectionVGridProxy<Element> = .init()
@StateObject
private var viewModel: PagingLibraryViewModel<Element>
// MARK: init
init(viewModel: PagingLibraryViewModel<Element>) {
// have to set these properties manually to get proper initial layout
self._defaultDisplayType = Default(.Customization.Library.displayType)
self._defaultListColumnCount = Default(.Customization.Library.listColumnCount)
self._defaultPosterType = Default(.Customization.Library.posterType)
self._displayType = StoredValue(.User.libraryDisplayType(parentID: viewModel.parent?.id))
self._listColumnCount = StoredValue(.User.libraryListColumnCount(parentID: viewModel.parent?.id))
self._posterType = StoredValue(.User.libraryPosterType(parentID: viewModel.parent?.id))
self._viewModel = StateObject(wrappedValue: viewModel)
let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType
.wrappedValue
let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? _listColumnCount
.wrappedValue : _defaultListColumnCount.wrappedValue
let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue
if UIDevice.isPhone {
layout = Self.phoneLayout(
posterType: initialPosterType,
viewType: initialDisplayType
)
} else {
layout = Self.padLayout(
posterType: initialPosterType,
viewType: initialDisplayType,
listColumnCount: initialListColumnCount
)
}
}
// MARK: onSelect
private func onSelect(_ element: Element) {
switch element {
case let element as BaseItemDto:
select(item: element)
case let element as BaseItemPerson:
select(person: element)
default:
assertionFailure("Used an unexpected type within a `PagingLibaryView`?")
}
}
private func select(item: BaseItemDto) {
switch item.type {
case .collectionFolder, .folder:
let viewModel = ItemLibraryViewModel(parent: item, filters: .default)
router.route(to: \.library, viewModel)
case .person:
let viewModel = ItemLibraryViewModel(parent: item)
router.route(to: \.library, viewModel)
default:
router.route(to: \.item, item)
}
}
private func select(person: BaseItemPerson) {
let viewModel = ItemLibraryViewModel(parent: person)
router.route(to: \.library, viewModel)
}
// MARK: layout
// TODO: rename old "viewType" paramter to "displayType" and sort
private static func padLayout(
posterType: PosterDisplayType,
viewType: LibraryDisplayType,
listColumnCount: Int
) -> CollectionVGridLayout {
switch (posterType, viewType) {
case (.landscape, .grid):
.minWidth(200)
case (.portrait, .grid):
.minWidth(150)
case (_, .list):
.columns(listColumnCount, insets: .zero, itemSpacing: 0, lineSpacing: 0)
}
}
private static func phoneLayout(
posterType: PosterDisplayType,
viewType: LibraryDisplayType
) -> CollectionVGridLayout {
switch (posterType, viewType) {
case (.landscape, .grid):
.columns(2)
case (.portrait, .grid):
.columns(3)
case (_, .list):
.columns(1, insets: .zero, itemSpacing: 0, lineSpacing: 0)
}
}
// MARK: item view
// Note: if parent is a folders then other items will have labels,
// so an empty content view is necessary
@ViewBuilder
private func landscapeGridItemView(item: Element) -> some View {
PosterButton(item: item, type: .landscape)
.content {
if item.showTitle {
PosterButton.TitleContentView(item: item)
.backport
.lineLimit(1, reservesSpace: true)
} else if viewModel.parent?.libraryType == .folder {
PosterButton.TitleContentView(item: item)
.backport
.lineLimit(1, reservesSpace: true)
.hidden()
}
}
.onSelect {
onSelect(item)
}
}
@ViewBuilder
private func portraitGridItemView(item: Element) -> some View {
PosterButton(item: item, type: .portrait)
.content {
if item.showTitle {
PosterButton.TitleContentView(item: item)
.backport
.lineLimit(1, reservesSpace: true)
} else if viewModel.parent?.libraryType == .folder {
PosterButton.TitleContentView(item: item)
.backport
.lineLimit(1, reservesSpace: true)
.hidden()
}
}
.onSelect {
onSelect(item)
}
}
@ViewBuilder
private func listItemView(item: Element, posterType: PosterDisplayType) -> some View {
LibraryRow(item: item, posterType: posterType)
.onSelect {
onSelect(item)
}
}
@ViewBuilder
private func errorView(with error: some Error) -> some View {
ErrorView(error: error)
.onRetry {
viewModel.send(.refresh)
}
}
@ViewBuilder
private var gridView: some View {
CollectionVGrid(
$viewModel.elements,
layout: $layout
) { item in
let displayType = Defaults[.Customization.Library.rememberLayout] ? _displayType.wrappedValue : _defaultDisplayType
.wrappedValue
let posterType = Defaults[.Customization.Library.rememberLayout] ? _posterType.wrappedValue : _defaultPosterType.wrappedValue
switch (posterType, displayType) {
case (.landscape, .grid):
landscapeGridItemView(item: item)
case (.portrait, .grid):
portraitGridItemView(item: item)
case (_, .list):
listItemView(item: item, posterType: posterType)
}
}
.onReachedBottomEdge(offset: .offset(300)) {
viewModel.send(.getNextPage)
}
.proxy(collectionVGridProxy)
.scrollIndicatorsVisible(false)
}
@ViewBuilder
private var innerContent: some View {
switch viewModel.state {
case .content:
if viewModel.elements.isEmpty {
L10n.noResults.text
} else {
gridView
}
case .initial, .refreshing:
DelayedProgressView()
default:
AssertionFailureView("Expected view for unexpected state")
}
}
@ViewBuilder
private var contentView: some View {
if letterPickerEnabled, let filterViewModel = viewModel.filterViewModel {
ZStack(alignment: letterPickerOrientation.alignment) {
innerContent
.padding(letterPickerOrientation.edge, 35)
.frame(maxWidth: .infinity)
LetterPickerBar(viewModel: filterViewModel)
.padding(.top, safeArea.top)
.padding(.bottom, safeArea.bottom)
.padding(letterPickerOrientation.edge, 10)
}
} else {
innerContent
}
}
// MARK: body
// TODO: becoming too large for typechecker during development, should break up somehow
var body: some View {
ZStack {
Color.clear
switch viewModel.state {
case .content, .initial, .refreshing:
contentView
case let .error(error):
errorView(with: error)
}
}
.animation(.linear(duration: 0.1), value: viewModel.state)
.ignoresSafeArea()
.onSizeChanged { _, safeArea in
self.safeArea = safeArea
}
.navigationTitle(viewModel.parent?.displayTitle ?? "")
.navigationBarTitleDisplayMode(.inline)
.ifLet(viewModel.filterViewModel) { view, filterViewModel in
view.navigationBarFilterDrawer(
viewModel: filterViewModel,
types: enabledDrawerFilters
) {
router.route(to: \.filter, $0)
}
}
.onChange(of: defaultDisplayType) { newValue in
guard !Defaults[.Customization.Library.rememberLayout] else { return }
if UIDevice.isPhone {
layout = Self.phoneLayout(
posterType: defaultPosterType,
viewType: newValue
)
} else {
layout = Self.padLayout(
posterType: defaultPosterType,
viewType: newValue,
listColumnCount: defaultListColumnCount
)
}
}
.onChange(of: defaultListColumnCount) { newValue in
guard !Defaults[.Customization.Library.rememberLayout] else { return }
if UIDevice.isPad {
layout = Self.padLayout(
posterType: defaultPosterType,
viewType: defaultDisplayType,
listColumnCount: newValue
)
}
}
.onChange(of: defaultPosterType) { newValue in
guard !Defaults[.Customization.Library.rememberLayout] else { return }
if UIDevice.isPhone {
if defaultDisplayType == .list {
collectionVGridProxy.layout()
} else {
layout = Self.phoneLayout(
posterType: newValue,
viewType: defaultDisplayType
)
}
} else {
if defaultDisplayType == .list {
collectionVGridProxy.layout()
} else {
layout = Self.padLayout(
posterType: newValue,
viewType: defaultDisplayType,
listColumnCount: defaultListColumnCount
)
}
}
}
.onChange(of: displayType) { newValue in
if UIDevice.isPhone {
layout = Self.phoneLayout(
posterType: posterType,
viewType: newValue
)
} else {
layout = Self.padLayout(
posterType: posterType,
viewType: newValue,
listColumnCount: listColumnCount
)
}
}
.onChange(of: listColumnCount) { newValue in
if UIDevice.isPad {
layout = Self.padLayout(
posterType: posterType,
viewType: displayType,
listColumnCount: newValue
)
}
}
.onChange(of: posterType) { newValue in
if UIDevice.isPhone {
if displayType == .list {
collectionVGridProxy.layout()
} else {
layout = Self.phoneLayout(
posterType: newValue,
viewType: displayType
)
}
} else {
if displayType == .list {
collectionVGridProxy.layout()
} else {
layout = Self.padLayout(
posterType: newValue,
viewType: displayType,
listColumnCount: listColumnCount
)
}
}
}
.onChange(of: rememberLayout) { newValue in
let newDisplayType = newValue ? displayType : defaultDisplayType
let newListColumnCount = newValue ? listColumnCount : defaultListColumnCount
let newPosterType = newValue ? posterType : defaultPosterType
if UIDevice.isPhone {
layout = Self.phoneLayout(
posterType: newPosterType,
viewType: newDisplayType
)
} else {
layout = Self.padLayout(
posterType: newPosterType,
viewType: newDisplayType,
listColumnCount: newListColumnCount
)
}
}
.onChange(of: viewModel.filterViewModel?.currentFilters) { newValue in
guard let newValue, let id = viewModel.parent?.id else { return }
if Defaults[.Customization.Library.rememberSort] {
let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)]
.mutating(\.sortBy, with: newValue.sortBy)
.mutating(\.sortOrder, with: newValue.sortOrder)
StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters
}
}
.onReceive(viewModel.events) { event in
switch event {
case let .gotRandomItem(item):
switch item {
case let item as BaseItemDto:
router.route(to: \.item, item)
case let item as BaseItemPerson:
let viewModel = ItemLibraryViewModel(parent: item, filters: .default)
router.route(to: \.library, viewModel)
default:
assertionFailure("Used an unexpected type within a `PagingLibaryView`?")
}
}
}
.onFirstAppear {
if viewModel.state == .initial {
viewModel.send(.refresh)
}
}
.topBarTrailing {
if viewModel.backgroundStates.contains(.gettingNextPage) {
ProgressView()
}
Menu {
if Defaults[.Customization.Library.rememberLayout] {
LibraryViewTypeToggle(
posterType: $posterType,
viewType: $displayType,
listColumnCount: $listColumnCount
)
} else {
LibraryViewTypeToggle(
posterType: $defaultPosterType,
viewType: $defaultDisplayType,
listColumnCount: $defaultListColumnCount
)
}
Button(L10n.random, systemImage: "dice.fill") {
viewModel.send(.getRandomItem)
}
.disabled(viewModel.elements.isEmpty)
} label: {
Image(systemName: "ellipsis.circle")
}
}
}
}