// // 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) 2025 Jellyfin & Jellyfin Contributors // import CollectionVGrid import Defaults import JellyfinAPI import SwiftUI // TODO: Figure out proper tab bar handling with the collection offset // TODO: fix paging for next item focusing the tab struct PagingLibraryView: View { @Default(.Customization.Library.cinematicBackground) private var cinematicBackground @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 @EnvironmentObject private var router: LibraryCoordinator.Router @State private var focusedItem: Element? @State private var presentBackground = false @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 = .init() @StateObject private var viewModel: PagingLibraryViewModel @StateObject private var cinematicBackgroundViewModel: CinematicBackgroundView.ViewModel = .init() init(viewModel: PagingLibraryViewModel) { 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 self._layout = State( initialValue: Self.makeLayout( posterType: initialPosterType, viewType: initialDisplayType, listColumnCount: initialListColumnCount ) ) } // MARK: On Select 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`?") } } // MARK: Select Item private func select(item: BaseItemDto) { switch item.type { case .collectionFolder, .folder, .channelFolderItem: 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) } } // MARK: Select Person private func select(person: BaseItemPerson) { let viewModel = ItemLibraryViewModel(parent: person) router.route(to: \.library, viewModel) } // MARK: Make Layout private static func makeLayout( posterType: PosterDisplayType, viewType: LibraryDisplayType, listColumnCount: Int ) -> CollectionVGridLayout { switch (posterType, viewType) { case (.landscape, .grid): return .columns(5, insets: .init(50), itemSpacing: 50, lineSpacing: 50) case (.portrait, .grid): return .columns(7, insets: .init(50), itemSpacing: 50, lineSpacing: 50) case (_, .list): return .columns(listColumnCount, insets: .init(50), itemSpacing: 50, lineSpacing: 50) } } // MARK: Set Default Layout private func setDefaultLayout() { layout = Self.makeLayout( posterType: defaultPosterType, viewType: defaultDisplayType, listColumnCount: defaultListColumnCount ) } // MARK: Set Custom Layout private func setCustomLayout() { layout = Self.makeLayout( posterType: posterType, viewType: displayType, listColumnCount: listColumnCount ) } // MARK: Set Cinematic Background private func setCinematicBackground() { guard let focusedItem else { withAnimation { presentBackground = false } return } cinematicBackgroundViewModel.select(item: focusedItem) if !presentBackground { withAnimation { presentBackground = true } } } // MARK: Landscape Grid Item View 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() } } .onFocusChanged { newValue in if newValue { focusedItem = item } } .onSelect { onSelect(item) } } // MARK: Portrait Grid Item View @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() } } .onFocusChanged { newValue in if newValue { focusedItem = item } } .onSelect { onSelect(item) } } // MARK: List Item View @ViewBuilder private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { LibraryRow(item: item, posterType: posterType) .onFocusChanged { newValue in if newValue { focusedItem = item } } .onSelect { onSelect(item) } } // MARK: Error View @ViewBuilder private func errorView(with error: some Error) -> some View { ErrorView(error: error) .onRetry { viewModel.send(.refresh) } } // MARK: Grid View @ViewBuilder private var gridView: some View { CollectionVGrid( uniqueElements: 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: .rows(3)) { viewModel.send(.getNextPage) } .proxy(collectionVGridProxy) .scrollIndicatorsVisible(false) } // MARK: Inner Content View @ViewBuilder private var innerContent: some View { switch viewModel.state { case .content: if viewModel.elements.isEmpty { L10n.noResults.text } else { gridView } case .initial, .refreshing: ProgressView() default: AssertionFailureView("Expected view for unexpected state") } } // MARK: Content View @ViewBuilder private var contentView: some View { innerContent // These exist here to alleviate type-checker issues .onChange(of: posterType) { setCustomLayout() } .onChange(of: displayType) { setCustomLayout() } .onChange(of: listColumnCount) { setCustomLayout() } // Logic for LetterPicker. Enable when ready /* if letterPickerEnabled, let filterViewModel = viewModel.filterViewModel { ZStack(alignment: letterPickerOrientation.alignment) { innerContent .padding(letterPickerOrientation.edge, LetterPickerBar.size + 10) .frame(maxWidth: .infinity) LetterPickerBar(viewModel: filterViewModel) .padding(.top, safeArea.top) .padding(.bottom, safeArea.bottom) .padding(letterPickerOrientation.edge, 10) } } else { innerContent } // These exist here to alleviate type-checker issues .onChange(of: posterType) { setCustomLayout() } .onChange(of: displayType) { setCustomLayout() } .onChange(of: listColumnCount) { setCustomLayout() }*/ } // MARK: Body var body: some View { ZStack { Color(red: 0.15, green: 0.05, blue: 0.1) .ignoresSafeArea() if cinematicBackground { CinematicBackgroundView(viewModel: cinematicBackgroundViewModel) .visible(presentBackground) .blurred() } switch viewModel.state { case .content, .initial, .refreshing: contentView case let .error(error): errorView(with: error) } } .animation(.linear(duration: 0.1), value: viewModel.state) .ignoresSafeArea() .navigationTitle(viewModel.parent?.displayTitle ?? "") .onChange(of: focusedItem) { setCinematicBackground() } .onChange(of: rememberLayout) { if rememberLayout { setCustomLayout() } else { setDefaultLayout() } } .onChange(of: defaultPosterType) { guard !Defaults[.Customization.Library.rememberLayout] else { return } setDefaultLayout() } .onChange(of: defaultDisplayType) { guard !Defaults[.Customization.Library.rememberLayout] else { return } setDefaultLayout() } .onChange(of: defaultListColumnCount) { guard !Defaults[.Customization.Library.rememberLayout] else { return } setDefaultLayout() } .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) } } } }