Skip to content

Libray Filters and Sorting#1770

Open
hqueiroga wants to merge 36 commits intojellyfin:mainfrom
hqueiroga:libray-filters-and-sorting
Open

Libray Filters and Sorting#1770
hqueiroga wants to merge 36 commits intojellyfin:mainfrom
hqueiroga:libray-filters-and-sorting

Conversation

@hqueiroga
Copy link
Contributor

Added a Library header, with the name of the library and Filter / Sorting functionality

Simulator Screenshot - Apple TV 4K (3rd generation) (at 1080p) - 2025-10-20 at 10 43 37

Library has the filtering / sorting options menu:

Simulator Screenshot - Apple TV 4K (3rd generation) (at 1080p) - 2025-10-20 at 10 44 20
Simulator Screenshot - Apple TV 4K (3rd generation) (at 1080p) - 2025-10-20 at 10 44 32
Simulator Screenshot - Apple TV 4K (3rd generation) (at 1080p) - 2025-10-20 at 10 44 36

Sorting / Filtering options can be "remembered" by library at the settings screen:

Simulator Screenshot - Apple TV 4K (3rd generation) (at 1080p) - 2025-10-20 at 10 46 14

@JPKribs JPKribs added enhancement New feature or request tvOS Impacts tvOS labels Oct 20, 2025
@hqueiroga
Copy link
Contributor Author

@JPKribs should I take any action here? There are a lot of Lint errors on objects I haven't changed.

@JPKribs
Copy link
Member

JPKribs commented Oct 23, 2025

@JPKribs should I take any action here? There are a lot of Lint errors on objects I haven't changed.

Hi, sorry not at this time. We have a couple balls in the air right now but I'm hoping to have these cleaned up by the start of the weekend.

I have a PR that fixes the linting. It's all on the other side of Swiftfin so I it should be an easy merge with Main when that's in.

Otherwise, I think we want to get our SDK to 10.11 to resolve those issues first.

I will keep you in the loop! Sorry about the delay, you caught us during the Jellyfin Server update week so we have some busy admin before we're able to look back at PRs.

@JPKribs
Copy link
Member

JPKribs commented Oct 24, 2025

Hi @hqueiroga thank you for your patience! This should be ready for you now. There are some localization changes and build items now we have this on Main. Sorry for any breakages but this should be valid to resume work for!

Please feel free to reach out if you have any questions!

@hqueiroga
Copy link
Contributor Author

Thanks @JPKribs, no problem at all :)

@JPKribs JPKribs linked an issue Oct 24, 2025 that may be closed by this pull request
…rting

# Conflicts:
#	Shared/Coordinators/Navigation/NavigationInjectionView.swift
#	Swiftfin tvOS/Extensions/View/View-tvOS.swift
#	Swiftfin tvOS/Views/FilterView.swift
#	Swiftfin tvOS/Views/PagingLibraryView/PagingLibraryView.swift
#	Translations/en.lproj/Localizable.strings
…s-and-sorting"

This reverts commit 99a08be, reversing
changes made to bccea37.
# Conflicts:
#	Translations/en.lproj/Localizable.strings
@hqueiroga
Copy link
Contributor Author

@JPKribs I've made changes on the UI, and fixed remaining problems after the merge. Everything looks ok to me :)
In time, I personally think that the tvOS version should be like the iOS one, keeping only the Home, Search and Media tabs.

As users can have multiple Libraries with same media type (i.e. 2 movies libraries... one for movies and another for kids approved contents. or, 2 tv shows libraries... one for tv shows and another for animes, for example). In these scenarios, having options such as "TV Shows" and "Movies", mixing-up all content, doesn't make much sense... well, at least on my humble opinion.

This is how the screens look

Simulator Screenshot - Apple TV 4K (3rd generation) - 2025-10-26 at 23 54 33 Simulator Screenshot - Apple TV 4K (3rd generation) - 2025-10-26 at 23 43 29 Simulator Screenshot - Apple TV 4K (3rd generation) - 2025-10-26 at 23 43 36

Copy link
Contributor Author

@hqueiroga hqueiroga left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All done!

@JPKribs
Copy link
Member

JPKribs commented Oct 28, 2025

@JPKribs I've made changes on the UI, and fixed remaining problems after the merge. Everything looks ok to me :) In time, I personally think that the tvOS version should be like the iOS one, keeping only the Home, Search and Media tabs.

As users can have multiple Libraries with same media type (i.e. 2 movies libraries... one for movies and another for kids approved contents. or, 2 tv shows libraries... one for tv shows and another for animes, for example). In these scenarios, having options such as "TV Shows" and "Movies", mixing-up all content, doesn't make much sense... well, at least on my humble opinion.

Sorry for the wait getting back to you. For many users, the TV Shows and Movies are the only way they access their content so we will want to find a placement for our filtering that works for both of these locations. The major motivation of this PR was to make sure we should be able to tap into the FilterViewModel from our Type libraries:

#1412

There was a period of time where I was working on this where I was trying to use some of the horizontal space that we have available. I'm curious what your thought is on something like this: #1407

Phone have more vertical room and limited horizontal room, so the filters at the top are best. TVs (or at least modern TVs) are the exact inverse. Most of why I bring this us is your current filters look great for our Libraries but the Type-Libraries this is harder to make work since we have the tab bar at the top. I'm not trying to force us in that direction, but it's something to consider if we have trouble finding a hope for the filters on the main tabs.

If you check my closed WIP PRs, you will find that I've looked at tvOS filters a couple of times and this always seems to be the issue that I get stuck on. I'd love to get this over that roadblock and get this in our next release! Would love to hear your thoughts on this so we can make this a reality!

Copy link
Member

@JPKribs JPKribs left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was a first pass but I think some of the suggestions are more structural. Please let me know if you have any questions!

@JPKribs
Copy link
Member

JPKribs commented Oct 28, 2025

Also, as an FYI, if you are interested in getting some of this out sooner, I think the remember library filtering work would make sense as their own PR with just the iOS CustomizeSettingsView UI components. Then you can hook into that work for this PR later on as I think we might have a couple iterations of this by the time the dust settles.

@hqueiroga
Copy link
Contributor Author

hqueiroga commented Oct 28, 2025

@JPKribs thanks for the feedback. I will work on the requested changes. About creating a separate PR for the Customize screen, unless you really want that released beforehand, I see no problem on waiting for the rest of the changes... otherwise it will be just a useless setting on screen :)

My original goal was to add the libray title and filter options scrolling with the library items... so we could reuse it on the Typed Libraries too and not using an overlay with fixed height at the top.... however, I could not do it, as the grid component has it's own scrolling implemented and I broke the library screen every time I tried. If I could have some help with that it would be great!

In terms of usability.. As a Jellyfin user myself, I would 99% of the time filter my library before start scrolling it. i.e. I want to filter by unwatched first, then I want to see some commedy movies... After selected I would browse the titles I have... so I think that scrolling the tile + filter options would be ideal. This already happens with other Jellyfin competitors I'd not mention here... lol

I like the idea of the letter picker on the right too... I saw some commented code.

About libraries and navigation, I like the top menu bar (with the tabs) as it's very handy, however the options are all hard-coded, so not possible to "pin" other libraries to that (and probably we don't even have space for it). So, was this a design choice to keep like this instead the Jellyfin browser version, with a sidebar where we could pin / unpin items?

@hqueiroga
Copy link
Contributor Author

@JPKribs I have done all the modifications. Also managed to add the header on all libraries and instead of putting it on an overlay with fixed position, it now scrolls with the rest of the screen. To accomplish that I need to add a Header view support to the CollectionVGrid component from @LePips... a PR is opened there too... it doesn't affect the component at all, but allows devs to add a custom header view to that before the grid items are displayed,.

@JPKribs
Copy link
Member

JPKribs commented Nov 11, 2025

@JPKribs I have done all the modifications. Also managed to add the header on all libraries and instead of putting it on an overlay with fixed position, it now scrolls with the rest of the screen. To accomplish that I need to add a Header view support to the CollectionVGrid component from @LePips... a PR is opened there too... it doesn't affect the component at all, but allows devs to add a custom header view to that before the grid items are displayed,.

Sounds good! Thank you for making these changes! I will take a look this week and let you know if there is anything.

I'll defer to @LePips for the changes made on LePips/CollectionVGrid#1 since that is their package. There isn't a good way to mark an external PR as blocking but we should consider that blocking for this PR so we can make sure we're always on a release version of CollectionVGrid.

Thank you again for your contribution and I hope to reach out soon this week if I have any notes!

@hqueiroga
Copy link
Contributor Author

@JPKribs please let me know when you have any inputs about this one. Building for tvOS version is failling because of the LePips/CollectionVGrid#1 change.

@JPKribs
Copy link
Member

JPKribs commented Nov 18, 2025

@JPKribs please let me know when you have any inputs about this one. Building for tvOS version is failling because of the LePips/CollectionVGrid#1 change.

Sorry, I've been trying to wrap up some other items and got sidetracked! I will look at this tomorrow and get back to you with any feedback. I think my primary concern is we will want a solution that works for "Type" libraries like Movies or Shows, normal libraries, and the SearchView. Just so we can maintain the solution in a single place instead of needing multiple spots. Not saying your's doesn't do this, more just thinking out loud and I want have that recognized as the biggest hurdle IMO for landing this feature in the right spot.

I haven't had a chance to fully look this over but I can give you an update tomorrow evening MST after I've checked it out!

@JPKribs
Copy link
Member

JPKribs commented Nov 18, 2025

I'm looking at this now but I am going to lean on @LePips for the items below. I don't want to lead you astray with this and he is the owner of the package (and much more experienced with Swift/UI) so his insight into doing this correctly is invaluable.


I want to preface everything below by saying that I've taken a few cracks at filters for tvOS in the past and I have found that there is a lot of pre-work that we need to do for this. A large reason why this is an item missing from tvOS for so long is that there are a lot of elements that SwiftUI on tvOS either does not have or has only gotten recently. What you have now looks and works great but I think, to make this maintainable and scalable moving forward, there is some additional prework we need to do. If you are interested, I would be happy to help divide and conquer this with you if you'd like to split up the work. Just let me know! I don't want to step on any toes if you aren't interested in collaborating on this!


I don't like moving the title outside the built in functionality. As we start working on accessibility features, it's important we stay consistent where we so we don't have to duplicate work.

I don't think the CollectionVGrid.header modifier is the way to go. I think this would be better if we started capturing the offset position or something of that nature from CollectionVGrid. This kills 2 birds with 1 stone in allowing us to appropriately show/hide titles while also allowing us to do the same for filters. This would also be a more broad item that is reusable elsewhere and this even has some outstanding TODOs for it.

If we have a offset/scroll position we could then move the filter into the .toolbar. Ideally, we'd want to get navigationBarFilterDrawer working for both iOS and tvOS so we can share that across. I have a kind of half-baked version of this where I am just getting the item index and determining if we need to hide the toolbar. I don't consider this a permanent solution as the calculation per change is kind of redundant and it's wasted processing but this gives an idea of what I thinking using the .toolbar. Toolbar is exactly where you are currently working with the .header but built into SwiftUI so this alleviates maintaining this feature and keeps us more standard with SwiftUI as they make changes. The filter itself is all screwed up since I didn't want to fully port this over for testing but in concept this is what I am thinking for hiding/displaying the filters:

Simulator.Screen.Recording.-.Apple.TV.4K.3rd.generation.-.2025-11-18.at.11.58.05.mov
Code
//
// 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

struct PagingLibraryView<Element: Poster & Identifiable>: View {

    @Default(.Customization.Library.cinematicBackground)
    private var cinematicBackground
    @Default(.Customization.Library.enabledDrawerFilters)
    private var enabledDrawerFilters
    @Default(.Customization.Library.rememberLayout)
    private var rememberLayout

    @Default(.Customization.Library.displayType)
    private var defaultDisplayType: LibraryDisplayType
    @Default(.Customization.Library.listColumnCount)
    private var defaultListColumnCount: Int
    @Default(.Customization.Library.posterType)
    private var defaultPosterType: PosterDisplayType

    @FocusedValue(\.focusedPoster)
    private var focusedPoster

    @Router
    private var router

    @State
    private var presentBackground = false
    @State
    private var layout: CollectionVGridLayout
    @State
    private var safeArea: EdgeInsets = .zero

    @State
    private var toolbarVisible = true

    @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<Element>

    @StateObject
    private var cinematicBackgroundProxy: CinematicBackgroundView.Proxy = .init()

    init(viewModel: PagingLibraryViewModel<Element>) {

        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 defaultDisplayType = Defaults[.Customization.Library.displayType]
        let defaultListColumnCount = Defaults[.Customization.Library.listColumnCount]
        let defaultPosterType = Defaults[.Customization.Library.posterType]

        let displayType = StoredValues[.User.libraryDisplayType(parentID: viewModel.parent?.id)]
        let listColumnCount = StoredValues[.User.libraryListColumnCount(parentID: viewModel.parent?.id)]
        let posterType = StoredValues[.User.libraryPosterType(parentID: viewModel.parent?.id)]

        let initialDisplayType = Defaults[.Customization.Library.rememberLayout] ? displayType : defaultDisplayType
        let initialListColumnCount = Defaults[.Customization.Library.rememberLayout] ? listColumnCount : defaultListColumnCount
        let initialPosterType = Defaults[.Customization.Library.rememberLayout] ? posterType : defaultPosterType

        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(item: BaseItemDto(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: viewModel))
        default:
            router.route(to: .item(item: item))
        }
    }

    // MARK: Select Person

    private func select(person: BaseItemPerson) {
        let viewModel = ItemLibraryViewModel(parent: person)
        router.route(to: .library(viewModel: 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), (.square, .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 focusedPoster else {
            withAnimation {
                presentBackground = false
            }
            return
        }

        cinematicBackgroundProxy.select(item: focusedPoster)

        if !presentBackground {
            withAnimation {
                presentBackground = true
            }
        }
    }

    // MARK: Landscape Grid Item View

    private func landscapeGridItemView(item: Element) -> some View {
        PosterButton(
            item: item,
            type: .landscape
        ) {
            onSelect(item)
        } label: {
            if item.showTitle {
                PosterButton<Element>.TitleContentView(item: item)
                    .lineLimit(1, reservesSpace: true)
            } else if viewModel.parent?.libraryType == .folder {
                PosterButton<Element>.TitleContentView(item: item)
                    .lineLimit(1, reservesSpace: true)
                    .hidden()
            }
        }
    }

    // MARK: Portrait Grid Item View

    @ViewBuilder
    private func portraitGridItemView(item: Element) -> some View {
        PosterButton(
            item: item,
            type: .portrait
        ) {
            onSelect(item)
        } label: {
            if item.showTitle {
                PosterButton<Element>.TitleContentView(item: item)
                    .lineLimit(1, reservesSpace: true)
            } else if viewModel.parent?.libraryType == .folder {
                PosterButton<Element>.TitleContentView(item: item)
                    .lineLimit(1, reservesSpace: true)
                    .hidden()
            }
        }
    }

    // MARK: List Item View

    @ViewBuilder
    private func listItemView(item: Element, posterType: PosterDisplayType) -> some View {
        LibraryRow(
            item: item,
            posterType: posterType
        ) {
            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), (.square, .grid):
                portraitGridItemView(item: item)
            case (_, .list):
                listItemView(item: item, posterType: posterType)
            }
        }
        .onReachedBottomEdge(offset: .rows(3)) {
            viewModel.send(.getNextPage)
        }
        .proxy(collectionVGridProxy)
        .scrollIndicators(.hidden)
    }

    private func updateToolbarVisibility(for index: Int) {
        let columns: Int
        switch (posterType, displayType) {
        case (.landscape, .grid):
            columns = 5
        case (.portrait, .grid), (.square, .grid):
            columns = 7
        case (_, .list):
            columns = listColumnCount
        }

        let currentRow = index / columns
        let newToolbarVisible = currentRow < 1

        if toolbarVisible != newToolbarVisible {
            withAnimation {
                toolbarVisible = newToolbarVisible
            }
        }
    }

    // MARK: Inner Content View

    @ViewBuilder
    private var innerContent: some View {
        switch viewModel.state {
        case .content:
            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 {
            if cinematicBackground {
                CinematicBackgroundView(viewModel: cinematicBackgroundProxy)
                    .isVisible(presentBackground)
                    .blurred()
            }

            switch viewModel.state {
            case .content, .initial, .refreshing:
                contentView
                    .toolbar {
                        if let title = viewModel.parent?.displayTitle, title.isNotEmpty,
                           let filterViewModel = viewModel.filterViewModel, !enabledDrawerFilters.isEmpty
                        {
                            LibraryHeader(
                                title: title,
                                viewModel: viewModel,
                                filterViewModel: filterViewModel
                            )
                            .animation(.easeInOut(duration: 0.01), value: toolbarVisible)
                        }
                    }
                    .toolbarVisibility(toolbarVisible ? .visible : .hidden)
            case let .error(error):
                errorView(with: error)
            }
        }
        .animation(.linear(duration: 0.1), value: viewModel.state)
        .ignoresSafeArea()
        .onChange(of: focusedPoster) {

            setCinematicBackground()

            guard let focusedPoster else {
                toolbarVisible = true
                return
            }

            if let index = viewModel.elements.firstIndex(where: { $0.unwrappedIDHashOrZero == focusedPoster.unwrappedIDHashOrZero }) {
                updateToolbarVisibility(for: index)
            }
        }
        .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 }

            var newStoredFilters = StoredValues[.User.libraryFilters(parentID: id)]

            if Defaults[.Customization.Library.rememberSort] {
                newStoredFilters = newStoredFilters
                    .mutating(\.sortBy, with: newValue.sortBy)
                    .mutating(\.sortOrder, with: newValue.sortOrder)
            }

            if Defaults[.Customization.Library.rememberFiltering] {
                newStoredFilters = newStoredFilters
                    .mutating(\.genres, with: newValue.genres)
                    .mutating(\.letter, with: newValue.letter)
                    .mutating(\.tags, with: newValue.tags)
                    .mutating(\.traits, with: newValue.traits)
                    .mutating(\.years, with: newValue.years)
            }

            if Defaults[.Customization.Library.rememberSort] || Defaults[.Customization.Library.rememberFiltering] {
                StoredValues[.User.libraryFilters(parentID: id)] = newStoredFilters
            }
        }
        .onReceive(viewModel.events) { event in
            switch event {
            case let .gotRandomItem(item):
                switch item {
                case let item as BaseItemDto:
                    select(item: item)
                case let item as BaseItemPerson:
                    select(item: BaseItemDto(person: item))
                default:
                    assertionFailure("Used an unexpected type within a `PagingLibaryView`?")
                }
            }
        }
        .onFirstAppear {
            if viewModel.state == .initial {
                viewModel.send(.refresh)
            }
        }
    }
}

I'm sorry to do this but I think we need to break this up into 5 PRs. I am happy to help with any of the work to alleviate that from your plate but I think we need:

Filter Saving: Since saving the filters isn't a requirement to be linked in here, I think we break this out on it's own for now. This helps us with the review, makes this easier to rollback/leave in place if we find we need to do so, and finally, this let's us wrap this up much earlier.

CollectionVGrid: Expose the offset / ScrollPosition. At minimum, the focused row.

FilterView: There is some existing work on this and the FilterViewModel. The end goal we should strive for is a single reusable element between views. I have a version of this worked but that I can create a PR for but I still need to make the filters conform to SystemImageable to make this align with the expected tvOS designe we have been using:

Unified `FilterView`
//
// 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

// TODO: multiple filter types?
//       - for sort order and sort by combined
struct FilterView: PlatformView {

    @Router
    private var router

    @Binding
    private var selection: [AnyItemFilter]

    @ObservedObject
    private var viewModel: FilterViewModel

    private let type: ItemFilterType

    private var filterSource: [AnyItemFilter] {
        viewModel.allFilters[keyPath: type.collectionAnyKeyPath]
    }

    var iOSView: 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)
                )
            }
    }

    var tvOSView: some View {
        #if os(tvOS)
        SplitFormWindowView()
            .descriptionView {
                // TODO: Make filter conform to systemImageable and use that image here
                // Image(systemName: type.systemImage)
                Image(systemName: "line.3.horizontal.decrease")
                    .resizable()
                    .aspectRatio(contentMode: .fit)
                    .frame(maxWidth: 400)
            }
            .contentView {
                contentView
            }
            .navigationTitle(type.displayTitle)
        #endif
    }

    // 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
        )
    }
}

NavigationBarFilterDrawer & FilterDrawerButton: We'll need to make these usable/appropriately styled for both environments. I think we'd be able to able to share everything but we'd likely want to make this a PlatformView since tvOS has to work around focus and button styling on tvOS is severely limited for now. I am personally in favor of renaming this to FilterDrawer then calling it as is on iOS but then tvOS we'd want to place it as a .toolbar I think. We'd want to try and share the API/call if possible for the modifier/view extension.

After these four, I think we want to sit and wait for #1752 to merge since this fundementally changes how we work for PagingLibraries. So this would alleviate the need for duplicate work/rework as changes are made there. Primarily, I want to avoid any changes to the PagingLibraryView as this is being combined into a single asset for both iOS and tvOS. The shared API for the FilterDrawer above should help make this easier to share.

Once available and there aren't outstanding changes in the PagingLibraryView, this should be as simple as attaching this there and to the SearchView. That is an unofficial 5th since I believe this could be completed as part of 4 as well.


Please feel free to ask any questions! I know this is a lot of information so please also feel free to push back on any of this as well if you believe this is wrong. If @LePips has any better/different insights, definitely take his thoughts over mine as well haha.

@JPKribs JPKribs mentioned this pull request Nov 19, 2025
3 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request tvOS Impacts tvOS

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tvOS - Search, Sort, Filter

3 participants