diff --git a/Shared/Components/ListRowCheckbox.swift b/Shared/Components/ListRowCheckbox.swift index dd3401cb51..a929c78aff 100644 --- a/Shared/Components/ListRowCheckbox.swift +++ b/Shared/Components/ListRowCheckbox.swift @@ -14,14 +14,12 @@ struct ListRowCheckbox: View { @Default(.accentColor) private var accentColor - // MARK: - Environment Variables - @Environment(\.isEditing) private var isEditing @Environment(\.isSelected) private var isSelected - // MARK: - Sizing Variable + private let showDeselected: Bool #if os(tvOS) private let size: CGFloat = 36 @@ -29,7 +27,9 @@ struct ListRowCheckbox: View { private let size: CGFloat = 24 #endif - // MARK: - Body + init(showDeselected: Bool = true) { + self.showDeselected = showDeselected + } @ViewBuilder var body: some View { @@ -42,13 +42,13 @@ struct ListRowCheckbox: View { .symbolRenderingMode(.palette) .foregroundStyle(accentColor.overlayColor, accentColor) - } else if isEditing { + } else if isEditing, showDeselected { Image(systemName: "circle") .resizable() .fontWeight(.bold) .aspectRatio(1, contentMode: .fit) .frame(width: size, height: size) - .foregroundStyle(.secondary) + .foregroundStyle(Color.secondary) } } } diff --git a/Shared/Components/SelectorView.swift b/Shared/Components/SelectorView.swift index d694602729..1c47d0ffea 100644 --- a/Shared/Components/SelectorView.swift +++ b/Shared/Components/SelectorView.swift @@ -6,7 +6,6 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -import Defaults import SwiftUI enum SelectorType { @@ -16,9 +15,6 @@ enum SelectorType { struct SelectorView: View { - @Default(.accentColor) - private var accentColor - @State private var selectedItems: Set @@ -68,15 +64,9 @@ struct SelectorView: View { label(element) .frame(maxWidth: .infinity, alignment: .leading) - if selectedItems.contains(element) { - Image(systemName: "checkmark.circle.fill") - .resizable() - .fontWeight(.bold) - .aspectRatio(1, contentMode: .fit) - .frame(width: 24, height: 24) - .symbolRenderingMode(.palette) - .foregroundStyle(accentColor.overlayColor, accentColor) - } + ListRowCheckbox(showDeselected: false) + .isEditing(true) + .isSelected(selectedItems.contains(element)) } } } diff --git a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift index 0d90fb2acf..2ad1490e7b 100644 --- a/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift +++ b/Shared/Coordinators/Navigation/NavigationRoute/NavigationRoute+Library.swift @@ -11,16 +11,14 @@ import SwiftUI extension NavigationRoute { - #if os(iOS) - static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute { + static func filter(types: [ItemFilterType], viewModel: FilterViewModel) -> NavigationRoute { NavigationRoute( id: "filter", style: .sheet ) { - FilterView(viewModel: viewModel, type: type) + FilterView(viewModel: viewModel, types: types) } } - #endif static func library( viewModel: PagingLibraryViewModel diff --git a/Shared/Objects/ItemFilter/ItemFilterType.swift b/Shared/Objects/ItemFilter/ItemFilterType.swift index b4e882b1ae..4dbd90b590 100644 --- a/Shared/Objects/ItemFilter/ItemFilterType.swift +++ b/Shared/Objects/ItemFilter/ItemFilterType.swift @@ -6,7 +6,7 @@ // Copyright (c) 2026 Jellyfin & Jellyfin Contributors // -enum ItemFilterType: String, CaseIterable, Storable { +enum ItemFilterType: String, CaseIterable, Storable, Identifiable { case genres case letter @@ -16,6 +16,10 @@ enum ItemFilterType: String, CaseIterable, Storable { case traits case years + var id: String { + rawValue + } + var selectorType: SelectorType { switch self { case .genres, .tags, .traits, .years: @@ -66,3 +70,25 @@ extension ItemFilterType: Displayable { } } } + +extension ItemFilterType: SystemImageable { + + var systemImage: String { + switch self { + case .genres: + "theatermasks" + case .letter: + "character.textbox" + case .sortBy: + "line.3.horizontal.decrease" + case .sortOrder: + "arrow.up.arrow.down" + case .tags: + "tag" + case .traits: + "heart" + case .years: + "calendar" + } + } +} diff --git a/Shared/Views/FilterView.swift b/Shared/Views/FilterView.swift new file mode 100644 index 0000000000..226db2937b --- /dev/null +++ b/Shared/Views/FilterView.swift @@ -0,0 +1,93 @@ +// +// 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) 2026 Jellyfin & Jellyfin Contributors +// + +import JellyfinAPI +import SwiftUI + +struct FilterView: View { + + @Router + private var router + + @ObservedObject + private var viewModel: FilterViewModel + + private let types: [ItemFilterType] + + private var title: String { + if types.count > 1 { + types.map(\.displayTitle).joined(separator: " & ") + } else { + types.first?.displayTitle ?? L10n.unknown + } + } + + init( + viewModel: FilterViewModel, + types: [ItemFilterType] + ) { + self.viewModel = viewModel + self.types = types + } + + var body: some View { + Form(systemImage: types.first?.systemImage ?? "line.3.horizontal.decrease") { + ForEach(types) { type in + selectorView(for: type) + } + } + .navigationTitle(title) + .topBarTrailing { + Button(L10n.reset) { + for type in types { + viewModel.send(.reset(type)) + } + } + .environment( + \.isEnabled, + types.contains { + viewModel.isFilterSelected(type: $0) + } + ) + } + .navigationBarCloseButton { + router.dismiss() + } + } + + @ViewBuilder + private func selectorView(for type: ItemFilterType) -> some View { + + let source = viewModel.allFilters[keyPath: type.collectionAnyKeyPath] + + if source.isNotEmpty { + Section { + SelectorView( + selection: Binding<[AnyItemFilter]>( + get: { + viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] + }, + set: { newValue in + viewModel.send(.update(type, newValue)) + } + ), + sources: source, + type: type.selectorType + ) + } header: { + if types.count > 1 { + Text(type.displayTitle) + } + } + } else { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } + } +} diff --git a/Swiftfin tvOS/Extensions/View/View-tvOS.swift b/Swiftfin tvOS/Extensions/View/View-tvOS.swift index 95ca8ad579..3d1adf764d 100644 --- a/Swiftfin tvOS/Extensions/View/View-tvOS.swift +++ b/Swiftfin tvOS/Extensions/View/View-tvOS.swift @@ -40,6 +40,15 @@ extension View { func prefersStatusBarHidden(_ hidden: Bool) -> some View { self } + + /// - Important: This does nothing on tvOS. + @ViewBuilder + func navigationBarCloseButton( + disabled: Bool = false, + _ action: @escaping () -> Void + ) -> some View { + self + } } extension EnvironmentValues { diff --git a/Swiftfin/Views/FilterView.swift b/Swiftfin/Views/FilterView.swift deleted file mode 100644 index fa0f2fa903..0000000000 --- a/Swiftfin/Views/FilterView.swift +++ /dev/null @@ -1,95 +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) 2026 Jellyfin & Jellyfin Contributors -// - -import JellyfinAPI -import SwiftUI - -// TODO: multiple filter types? -// - for sort order and sort by combined -struct FilterView: View { - - // MARK: - Binded Variable - - @Binding - private var selection: [AnyItemFilter] - - // MARK: - Environment & Observed Objects - - @Router - private var router - - @ObservedObject - private var viewModel: FilterViewModel - - // MARK: - Filter Type - - private let type: ItemFilterType - - // MARK: - Filter Sources - - private var filterSource: [AnyItemFilter] { - viewModel.allFilters[keyPath: type.collectionAnyKeyPath] - } - - // MARK: - Body - - var body: some View { - contentView - .navigationTitle(type.displayTitle) - .navigationBarTitleDisplayMode(.inline) - .navigationBarCloseButton { - router.dismiss() - } - .topBarTrailing { - Button(L10n.reset) { - viewModel.send(.reset(type)) - } - .environment( - \.isEnabled, - viewModel.isFilterSelected(type: type) - ) - } - } - - // MARK: - Filter Content - - @ViewBuilder - private var contentView: some View { - if filterSource.isEmpty { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } else { - SelectorView( - selection: $selection, - sources: filterSource, - type: type.selectorType - ) - } - } -} - -extension FilterView { - - init( - viewModel: FilterViewModel, - type: ItemFilterType - ) { - - let selectionBinding: Binding<[AnyItemFilter]> = Binding { - viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] - } set: { newValue in - viewModel.send(.update(type, newValue)) - } - - self.init( - selection: selectionBinding, - viewModel: viewModel, - type: type - ) - } -} diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 19c46d15d8..4852528087 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -297,7 +297,7 @@ struct PagingLibraryView: View { viewModel: filterViewModel, types: enabledDrawerFilters ) { - router.route(to: .filter(type: $0.type, viewModel: $0.viewModel)) + router.route(to: .filter(types: [$0.type], viewModel: $0.viewModel)) } } .onChange(of: defaultDisplayType) { newValue in diff --git a/Swiftfin/Views/SearchView.swift b/Swiftfin/Views/SearchView.swift index 18bd6c400a..dd7c7b72c3 100644 --- a/Swiftfin/Views/SearchView.swift +++ b/Swiftfin/Views/SearchView.swift @@ -215,7 +215,7 @@ struct SearchView: View { viewModel: viewModel.filterViewModel, types: enabledDrawerFilters ) { - router.route(to: .filter(type: $0.type, viewModel: $0.viewModel)) + router.route(to: .filter(types: [$0.type], viewModel: $0.viewModel)) } .onFirstAppear { viewModel.getSuggestions()