From 62d966f282ceeb91bcaa1be24e26c0cd10f17154 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 18 Nov 2025 21:22:51 -0700 Subject: [PATCH 1/6] WIP --- Shared/Components/ListRowCheckbox.swift | 19 ++- Shared/Components/SelectorView.swift | 16 +- .../NavigationRoute+Library.swift | 6 +- .../Modifiers/NavigationBarCloseButton.swift | 5 + .../Objects/ItemFilter/ItemFilterType.swift | 28 +++- Shared/Views/FilterView.swift | 140 ++++++++++++++++++ Swiftfin/Views/FilterView.swift | 95 ------------ .../PagingLibraryView/PagingLibraryView.swift | 6 +- Swiftfin/Views/SearchView.swift | 2 +- 9 files changed, 196 insertions(+), 121 deletions(-) rename {Swiftfin/Extensions/View => Shared/Extensions/ViewExtensions}/Modifiers/NavigationBarCloseButton.swift (83%) create mode 100644 Shared/Views/FilterView.swift delete mode 100644 Swiftfin/Views/FilterView.swift diff --git a/Shared/Components/ListRowCheckbox.swift b/Shared/Components/ListRowCheckbox.swift index dd3401cb51..3c3693cfe6 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) { + self.showDeselected = showDeselected + } @ViewBuilder var body: some View { @@ -42,13 +42,20 @@ 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) } } } + +extension ListRowCheckbox { + + init() { + self.showDeselected = true + } +} 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/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift b/Shared/Extensions/ViewExtensions/Modifiers/NavigationBarCloseButton.swift similarity index 83% rename from Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift rename to Shared/Extensions/ViewExtensions/Modifiers/NavigationBarCloseButton.swift index 791548f807..b1869b5092 100644 --- a/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift +++ b/Shared/Extensions/ViewExtensions/Modifiers/NavigationBarCloseButton.swift @@ -23,11 +23,16 @@ struct NavigationBarCloseButtonModifier: ViewModifier { Button { action() } label: { + #if os(iOS) Image(systemName: "xmark.circle.fill") .fontWeight(.bold) .symbolRenderingMode(.palette) .foregroundStyle(accentColor.overlayColor, accentColor) .opacity(disabled ? 0.75 : 1) + #else + /// tvOS ignores all styling for Toolbar Buttons + Image(systemName: "xmark") + #endif } .disabled(disabled) } 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..64ce0a9172 --- /dev/null +++ b/Shared/Views/FilterView.swift @@ -0,0 +1,140 @@ +// +// 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 JellyfinAPI +import SwiftUI + +struct FilterView: PlatformView { + + @Router + private var router + + @ObservedObject + private var viewModel: FilterViewModel + + private let types: [ItemFilterType] + + private var isComposite: Bool { + types.count > 1 + } + + private var title: String { + if isComposite { + types.map(\.displayTitle).joined(separator: " & ") + } else { + types.first?.displayTitle ?? L10n.unknown + } + } + + #if os(tvOS) + private var systemImage: String { + types.first?.systemImage ?? "line.3.horizontal.decrease" + } + #endif + + init( + viewModel: FilterViewModel, + types: [ItemFilterType] + ) { + self.viewModel = viewModel + self.types = types + } + + var iOSView: some View { + Form { + ForEach(types) { type in + selectorView(for: type) + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(title) + .navigationBarCloseButton { + router.dismiss() + } + .topBarTrailing { + Button(L10n.reset) { + for type in types { + viewModel.send(.reset(type)) + } + } + .environment( + \.isEnabled, + types.contains { + viewModel.isFilterSelected(type: $0) + } + ) + } + } + + var tvOSView: some View { + #if os(tvOS) + SplitFormWindowView() + .descriptionView { + Image(systemName: systemImage) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 400) + } + .contentView { + 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) + } + ) + } + #endif + } + + @ViewBuilder + private func selectorView(for type: ItemFilterType) -> some View { + + let source = filterSource(type) + + let typeSelection = Binding<[AnyItemFilter]>( + get: { + viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] + }, + set: { newValue in + viewModel.send(.update(type, newValue)) + } + ) + + if source.isEmpty { + Text(L10n.none) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + } else { + Section { + SelectorView( + selection: typeSelection, + sources: source, + type: type.selectorType + ) + } header: { + if isComposite { + Text(type.displayTitle) + } + } + } + } + + private func filterSource(_ type: ItemFilterType) -> [AnyItemFilter] { + viewModel.allFilters[keyPath: type.collectionAnyKeyPath] + } +} 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..25c4d9de9b 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -297,7 +297,11 @@ struct PagingLibraryView: View { viewModel: filterViewModel, types: enabledDrawerFilters ) { - router.route(to: .filter(type: $0.type, viewModel: $0.viewModel)) + if $0.type == .sortBy || $0.type == .sortOrder { + router.route(to: .filter(types: [.sortOrder, .sortBy], viewModel: $0.viewModel)) + } else { + 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() From fea5c88662b81ca0bbfcad01832bc9abc7e77492 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 18 Nov 2025 21:34:45 -0700 Subject: [PATCH 2/6] WIP --- Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift index 25c4d9de9b..4852528087 100644 --- a/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift +++ b/Swiftfin/Views/PagingLibraryView/PagingLibraryView.swift @@ -297,11 +297,7 @@ struct PagingLibraryView: View { viewModel: filterViewModel, types: enabledDrawerFilters ) { - if $0.type == .sortBy || $0.type == .sortOrder { - router.route(to: .filter(types: [.sortOrder, .sortBy], viewModel: $0.viewModel)) - } else { - router.route(to: .filter(types: [$0.type], viewModel: $0.viewModel)) - } + router.route(to: .filter(types: [$0.type], viewModel: $0.viewModel)) } } .onChange(of: defaultDisplayType) { newValue in From 1a68a0a4916e4af03c3fe5abcc7b3afad5e9811e Mon Sep 17 00:00:00 2001 From: Joe Date: Fri, 5 Dec 2025 12:39:48 -0700 Subject: [PATCH 3/6] Remove duplicate `init`s. --- Shared/Components/ListRowCheckbox.swift | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Shared/Components/ListRowCheckbox.swift b/Shared/Components/ListRowCheckbox.swift index 3c3693cfe6..a929c78aff 100644 --- a/Shared/Components/ListRowCheckbox.swift +++ b/Shared/Components/ListRowCheckbox.swift @@ -27,7 +27,7 @@ struct ListRowCheckbox: View { private let size: CGFloat = 24 #endif - init(showDeselected: Bool) { + init(showDeselected: Bool = true) { self.showDeselected = showDeselected } @@ -52,10 +52,3 @@ struct ListRowCheckbox: View { } } } - -extension ListRowCheckbox { - - init() { - self.showDeselected = true - } -} From 59570c1ecafd795c1e2d822c538267decdd8dec7 Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 16 Dec 2025 17:37:25 -0700 Subject: [PATCH 4/6] Cleanup & New Form --- Shared/Views/FilterView.swift | 91 +++++++++-------------------------- 1 file changed, 23 insertions(+), 68 deletions(-) diff --git a/Shared/Views/FilterView.swift b/Shared/Views/FilterView.swift index 64ce0a9172..6464e569a4 100644 --- a/Shared/Views/FilterView.swift +++ b/Shared/Views/FilterView.swift @@ -9,7 +9,7 @@ import JellyfinAPI import SwiftUI -struct FilterView: PlatformView { +struct FilterView: View { @Router private var router @@ -19,24 +19,14 @@ struct FilterView: PlatformView { private let types: [ItemFilterType] - private var isComposite: Bool { - types.count > 1 - } - private var title: String { - if isComposite { + if types.count > 1 { types.map(\.displayTitle).joined(separator: " & ") } else { types.first?.displayTitle ?? L10n.unknown } } - #if os(tvOS) - private var systemImage: String { - types.first?.systemImage ?? "line.3.horizontal.decrease" - } - #endif - init( viewModel: FilterViewModel, types: [ItemFilterType] @@ -45,17 +35,13 @@ struct FilterView: PlatformView { self.types = types } - var iOSView: some View { - Form { + var body: some View { + Form(systemImage: types.first?.systemImage ?? "line.3.horizontal.decrease") { ForEach(types) { type in selectorView(for: type) } } - .navigationBarTitleDisplayMode(.inline) .navigationTitle(title) - .navigationBarCloseButton { - router.dismiss() - } .topBarTrailing { Button(L10n.reset) { for type in types { @@ -69,72 +55,41 @@ struct FilterView: PlatformView { } ) } - } - - var tvOSView: some View { - #if os(tvOS) - SplitFormWindowView() - .descriptionView { - Image(systemName: systemImage) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxWidth: 400) - } - .contentView { - 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) - } - ) - } + #if os(iOS) + .navigationBarCloseButton { + router.dismiss() + } #endif } @ViewBuilder private func selectorView(for type: ItemFilterType) -> some View { - let source = filterSource(type) + let source = viewModel.allFilters[keyPath: type.collectionAnyKeyPath] - let typeSelection = Binding<[AnyItemFilter]>( - get: { - viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] - }, - set: { newValue in - viewModel.send(.update(type, newValue)) - } - ) - - if source.isEmpty { - Text(L10n.none) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - } else { + if source.isNotEmpty { Section { SelectorView( - selection: typeSelection, + selection: Binding<[AnyItemFilter]>( + get: { + viewModel.currentFilters[keyPath: type.collectionAnyKeyPath] + }, + set: { newValue in + viewModel.send(.update(type, newValue)) + } + ), sources: source, type: type.selectorType ) } header: { - if isComposite { + if types.count > 1 { Text(type.displayTitle) } } + } else { + Text(L10n.none) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) } } - - private func filterSource(_ type: ItemFilterType) -> [AnyItemFilter] { - viewModel.allFilters[keyPath: type.collectionAnyKeyPath] - } } From 803e9f0b390dbd40a2123cc0239f15d2ac9931ad Mon Sep 17 00:00:00 2001 From: Joe Date: Tue, 16 Dec 2025 17:43:34 -0700 Subject: [PATCH 5/6] Close Button Cleanup --- Shared/Views/FilterView.swift | 2 -- Swiftfin tvOS/Extensions/View/View-tvOS.swift | 9 +++++++++ .../View}/Modifiers/NavigationBarCloseButton.swift | 5 ----- 3 files changed, 9 insertions(+), 7 deletions(-) rename {Shared/Extensions/ViewExtensions => Swiftfin/Extensions/View}/Modifiers/NavigationBarCloseButton.swift (83%) diff --git a/Shared/Views/FilterView.swift b/Shared/Views/FilterView.swift index 6464e569a4..5b3609128a 100644 --- a/Shared/Views/FilterView.swift +++ b/Shared/Views/FilterView.swift @@ -55,11 +55,9 @@ struct FilterView: View { } ) } - #if os(iOS) .navigationBarCloseButton { router.dismiss() } - #endif } @ViewBuilder 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/Shared/Extensions/ViewExtensions/Modifiers/NavigationBarCloseButton.swift b/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift similarity index 83% rename from Shared/Extensions/ViewExtensions/Modifiers/NavigationBarCloseButton.swift rename to Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift index b1869b5092..791548f807 100644 --- a/Shared/Extensions/ViewExtensions/Modifiers/NavigationBarCloseButton.swift +++ b/Swiftfin/Extensions/View/Modifiers/NavigationBarCloseButton.swift @@ -23,16 +23,11 @@ struct NavigationBarCloseButtonModifier: ViewModifier { Button { action() } label: { - #if os(iOS) Image(systemName: "xmark.circle.fill") .fontWeight(.bold) .symbolRenderingMode(.palette) .foregroundStyle(accentColor.overlayColor, accentColor) .opacity(disabled ? 0.75 : 1) - #else - /// tvOS ignores all styling for Toolbar Buttons - Image(systemName: "xmark") - #endif } .disabled(disabled) } From acb50ec6f48a11e613d428d215f648d4f5e1abff Mon Sep 17 00:00:00 2001 From: Joe Date: Wed, 25 Feb 2026 14:46:28 -0700 Subject: [PATCH 6/6] Merge Fixes --- Shared/Views/FilterView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shared/Views/FilterView.swift b/Shared/Views/FilterView.swift index 5b3609128a..226db2937b 100644 --- a/Shared/Views/FilterView.swift +++ b/Shared/Views/FilterView.swift @@ -3,7 +3,7 @@ // 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 +// Copyright (c) 2026 Jellyfin & Jellyfin Contributors // import JellyfinAPI