diff --git a/Shared/Strings/Strings.swift b/Shared/Strings/Strings.swift index dec35a28c..e6db15e74 100644 --- a/Shared/Strings/Strings.swift +++ b/Shared/Strings/Strings.swift @@ -1018,6 +1018,14 @@ internal enum L10n { internal static let regular = L10n.tr("Localizable", "regular", fallback: "Regular") /// Release Date internal static let releaseDate = L10n.tr("Localizable", "releaseDate", fallback: "Release Date") + /// Remember layout + internal static let rememberLayout = L10n.tr("Localizable", "rememberLayout", fallback: "Remember layout") + /// Remember layout for individual libraries + internal static let rememberLayoutFooter = L10n.tr("Localizable", "rememberLayoutFooter", fallback: "Remember layout for individual libraries") + /// Remember sorting + internal static let rememberSorting = L10n.tr("Localizable", "rememberSorting", fallback: "Remember sorting") + /// Remember sorting for individual libraries + internal static let rememberSortingFooter = L10n.tr("Localizable", "rememberSortingFooter", fallback: "Remember sorting for individual libraries") /// Remixer internal static let remixer = L10n.tr("Localizable", "remixer", fallback: "Remixer") /// Remote connections diff --git a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift index c91bfeda1..b319408a1 100644 --- a/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift @@ -12,32 +12,45 @@ import JellyfinAPI import SwiftUI // TODO: Figure out proper tab bar handling with the collection offset -// TODO: list columns -// TODO: list row view (LibraryRow) // TODO: fix paging for next item focusing the tab struct PagingLibraryView: View { @Default(.Customization.Library.cinematicBackground) private var cinematicBackground - @Default(.Customization.Library.posterType) - private var posterType - @Default(.Customization.Library.displayType) - private var viewType - @Default(.Customization.showPosterLabels) - private var showPosterLabels + @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 @@ -45,22 +58,33 @@ struct PagingLibraryView: View { 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 initialPosterType = Defaults[.Customization.Library.posterType] - let initialViewType = Defaults[.Customization.Library.displayType] - let listColumnCount = Defaults[.Customization.Library.listColumnCount] + 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, - displayType: initialViewType, - listColumnCount: listColumnCount + viewType: initialDisplayType, + listColumnCount: initialListColumnCount ) ) } - // MARK: onSelect + // MARK: On Select private func onSelect(_ element: Element) { switch element { @@ -73,29 +97,36 @@ struct PagingLibraryView: View { } } + // MARK: Select Item + private func select(item: BaseItemDto) { switch item.type { case .collectionFolder, .folder: let viewModel = ItemLibraryViewModel(parent: item) 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: layout + // MARK: Make Layout private static func makeLayout( posterType: PosterDisplayType, - displayType: LibraryDisplayType, + viewType: LibraryDisplayType, listColumnCount: Int ) -> CollectionVGridLayout { - switch (posterType, displayType) { + switch (posterType, viewType) { case (.landscape, .grid): return .columns(5, insets: .init(50), itemSpacing: 50, lineSpacing: 50) case (.portrait, .grid): @@ -105,6 +136,47 @@ struct PagingLibraryView: View { } } + // 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 { @@ -112,6 +184,11 @@ struct PagingLibraryView: View { 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 @@ -124,6 +201,9 @@ struct PagingLibraryView: View { } } + // MARK: Portrait Grid Item View + + @ViewBuilder private func portraitGridItemView(item: Element) -> some View { PosterButton(item: item, type: .portrait) .content { @@ -131,6 +211,11 @@ struct PagingLibraryView: View { 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 @@ -143,6 +228,8 @@ struct PagingLibraryView: View { } } + // MARK: List Item View + @ViewBuilder private func listItemView(item: Element, posterType: PosterDisplayType) -> some View { LibraryRow(item: item, posterType: posterType) @@ -156,13 +243,31 @@ struct PagingLibraryView: View { } } + // MARK: Error View + @ViewBuilder - private var contentView: some View { + private func errorView(with error: some Error) -> some View { + Text(error.localizedDescription) + /* ErrorView(error: error) + .onRetry { + viewModel.send(.refresh) + } */ + } + + // MARK: Grid View + + @ViewBuilder + private var gridView: some View { CollectionVGrid( uniqueElements: viewModel.elements, layout: layout ) { item in - switch (posterType, viewType) { + + 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): @@ -174,55 +279,146 @@ struct PagingLibraryView: View { .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.clear + if cinematicBackground { CinematicBackgroundView(viewModel: cinematicBackgroundViewModel) .visible(presentBackground) .blurred() } - WrappedView { - Group { - switch viewModel.state { - case let .error(error): - Text(error.localizedDescription) - case .initial, .refreshing: - ProgressView() - case .content: - if viewModel.elements.isEmpty { - L10n.noResults.text - } else { - contentView - } - } - } + 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 ?? "") - .onFirstAppear { - if viewModel.state == .initial { - viewModel.send(.refresh) - } + .onChange(of: focusedItem) { + setCinematicBackground() } - .onChange(of: focusedItem) { _, newValue in - guard let newValue else { - withAnimation { - presentBackground = false - } - return + .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 } - cinematicBackgroundViewModel.select(item: newValue) + if Defaults[.Customization.Library.rememberSort] { + let newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)] + .mutating(\.sortBy, with: newValue.sortBy) + .mutating(\.sortOrder, with: newValue.sortOrder) - if !presentBackground { - withAnimation { - presentBackground = true + 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) + } + } } } diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift new file mode 100644 index 000000000..6b37aaeb9 --- /dev/null +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/Components/Sections/LibrarySection.swift @@ -0,0 +1,80 @@ +// +// 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 Defaults +import SwiftUI + +extension CustomizeViewsSettings { + + struct LibrarySection: View { + + @Default(.Customization.Library.randomImage) + private var libraryRandomImage + @Default(.Customization.Library.showFavorites) + private var showFavorites + + @Default(.Customization.Library.cinematicBackground) + private var cinematicBackground + @Default(.Customization.Library.displayType) + private var libraryDisplayType + @Default(.Customization.Library.posterType) + private var libraryPosterType + @Default(.Customization.Library.listColumnCount) + private var listColumnCount + + @Default(.Customization.Library.rememberLayout) + private var rememberLibraryLayout + @Default(.Customization.Library.rememberSort) + private var rememberLibrarySort + + @EnvironmentObject + private var router: CustomizeSettingsCoordinator.Router + + @State + private var isPresentingNextUpDays = false + + var body: some View { + Section(L10n.media) { + + Toggle(L10n.randomImage, isOn: $libraryRandomImage) + + Toggle(L10n.showFavorites, isOn: $showFavorites) + } + + Section(L10n.library) { + Toggle(L10n.cinematicBackground, isOn: $cinematicBackground) + + InlineEnumToggle(title: L10n.posters, selection: $libraryPosterType) + + InlineEnumToggle(title: L10n.library, selection: $libraryDisplayType) + + if libraryDisplayType == .list { + ChevronButton( + L10n.columns, + subtitle: listColumnCount.description + ) + .onSelect { + router.route(to: \.listColumnSettings, $listColumnCount) + } + } + } + + Section { + Toggle(L10n.rememberLayout, isOn: $rememberLibraryLayout) + } footer: { + Text(L10n.rememberLayoutFooter) + } + + Section { + Toggle(L10n.rememberSorting, isOn: $rememberLibrarySort) + } footer: { + Text(L10n.rememberSortingFooter) + } + } + } +} diff --git a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift index 2d0573da9..049ad5916 100644 --- a/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ b/Swiftfin tvOS/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -31,19 +31,6 @@ struct CustomizeViewsSettings: View { @Default(.Customization.Library.displayType) private var libraryViewType - @Default(.Customization.Library.cinematicBackground) - private var cinematicBackground - @Default(.Customization.Library.randomImage) - private var libraryRandomImage - @Default(.Customization.Library.showFavorites) - private var showFavorites - @Default(.Customization.Library.displayType) - private var libraryDisplayType - @Default(.Customization.Library.posterType) - private var libraryPosterType - @Default(.Customization.Library.listColumnCount) - private var listColumnCount - @EnvironmentObject private var router: CustomizeSettingsCoordinator.Router @@ -84,26 +71,7 @@ struct CustomizeViewsSettings: View { InlineEnumToggle(title: L10n.search, selection: $searchPosterType) } - Section(L10n.library) { - - Toggle(L10n.cinematicBackground, isOn: $cinematicBackground) - - Toggle(L10n.randomImage, isOn: $libraryRandomImage) - - Toggle(L10n.showFavorites, isOn: $showFavorites) - - InlineEnumToggle(title: L10n.posters, selection: $libraryPosterType) - InlineEnumToggle(title: L10n.library, selection: $libraryDisplayType) - if libraryDisplayType == .list { - ChevronButton( - L10n.columns, - subtitle: listColumnCount.description - ) - .onSelect { - router.route(to: \.listColumnSettings, $listColumnCount) - } - } - } + LibrarySection() ItemSection() diff --git a/Swiftfin.xcodeproj/project.pbxproj b/Swiftfin.xcodeproj/project.pbxproj index 5eb254c5f..1353a9ba4 100644 --- a/Swiftfin.xcodeproj/project.pbxproj +++ b/Swiftfin.xcodeproj/project.pbxproj @@ -181,6 +181,7 @@ 4EA78B202D2B5AA30093BFCE /* ItemPhotoPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */; }; 4EA78B232D2B5CFC0093BFCE /* ItemPhotoCropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */; }; 4EA78B252D2B5DBD0093BFCE /* ItemImagePickerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */; }; + 4EAE340C2D42B857006FBAD3 /* LibrarySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAE340B2D42B852006FBAD3 /* LibrarySection.swift */; }; 4EB132EF2D2CF6D600B5A8E5 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */; }; 4EB132F02D2CF6D600B5A8E5 /* ImageType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */; }; 4EB1404C2C8E45B1008691F3 /* StreamSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */; }; @@ -1342,6 +1343,7 @@ 4EA78B1F2D2B5A9E0093BFCE /* ItemPhotoPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPhotoPickerView.swift; sourceTree = ""; }; 4EA78B222D2B5CEF0093BFCE /* ItemPhotoCropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemPhotoCropView.swift; sourceTree = ""; }; 4EA78B242D2B5DB20093BFCE /* ItemImagePickerCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemImagePickerCoordinator.swift; sourceTree = ""; }; + 4EAE340B2D42B852006FBAD3 /* LibrarySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibrarySection.swift; sourceTree = ""; }; 4EB132EE2D2CF6D300B5A8E5 /* ImageType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageType.swift; sourceTree = ""; }; 4EB1404B2C8E45B1008691F3 /* StreamSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamSection.swift; sourceTree = ""; }; 4EB1A8C92C9A765800F43898 /* ActiveSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveSessionsView.swift; sourceTree = ""; }; @@ -2524,6 +2526,7 @@ children = ( 4E699BBF2CB34775007CBD5D /* HomeSection.swift */, 4E97D1822D064748004B89AD /* ItemSection.swift */, + 4EAE340B2D42B852006FBAD3 /* LibrarySection.swift */, ); path = Sections; sourceTree = ""; @@ -5638,6 +5641,7 @@ 4E884C652CEBB301004CF6AD /* LearnMoreModal.swift in Sources */, E1B4E4372CA7795200DC49DE /* OrderedDictionary.swift in Sources */, E1AD104E26D96CE3003E4A08 /* BaseItemDto.swift in Sources */, + 4EAE340C2D42B857006FBAD3 /* LibrarySection.swift in Sources */, E118959E289312020042947B /* BaseItemPerson+Poster.swift in Sources */, 4E4DAC3D2D11F94400E13FF9 /* LocalServerButton.swift in Sources */, 62E632DD267D2E130063E547 /* SearchViewModel.swift in Sources */, diff --git a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift index cbb8d1b44..26ccb35d2 100644 --- a/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift +++ b/Swiftfin/Views/SettingsView/CustomizeViewsSettings/CustomizeViewsSettings.swift @@ -163,15 +163,15 @@ struct CustomizeViewsSettings: View { HomeSection() Section { - Toggle("Remember layout", isOn: $rememberLibraryLayout) + Toggle(L10n.rememberLayout, isOn: $rememberLibraryLayout) } footer: { - Text("Remember layout for individual libraries") + Text(L10n.rememberLayoutFooter) } Section { - Toggle("Remember sorting", isOn: $rememberLibrarySort) + Toggle(L10n.rememberSorting, isOn: $rememberLibrarySort) } footer: { - Text("Remember sorting for individual libraries") + Text(L10n.rememberSortingFooter) } Section { diff --git a/Translations/en.lproj/Localizable.strings b/Translations/en.lproj/Localizable.strings index 3212d1741..a14893e87 100644 --- a/Translations/en.lproj/Localizable.strings +++ b/Translations/en.lproj/Localizable.strings @@ -1456,6 +1456,18 @@ /// Release Date "releaseDate" = "Release Date"; +/// Remember layout +"rememberLayout" = "Remember layout"; + +/// Remember layout for individual libraries +"rememberLayoutFooter" = "Remember layout for individual libraries"; + +/// Remember sorting +"rememberSorting" = "Remember sorting"; + +/// Remember sorting for individual libraries +"rememberSortingFooter" = "Remember sorting for individual libraries"; + /// Remixer "remixer" = "Remixer";