Conversation
|
@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. |
|
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! |
|
Thanks @JPKribs, no problem at all :) |
…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
# Conflicts: # Translations/en.lproj/Localizable.strings
|
@JPKribs I've made changes on the UI, and fixed remaining problems after the merge. Everything looks ok to me :) 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
|
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 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! |
JPKribs
left a comment
There was a problem hiding this comment.
This was a first pass but I think some of the suggestions are more structural. Please let me know if you have any questions!
Swiftfin tvOS/Views/PagingLibraryView/Components/LibraryHeader.swift
Outdated
Show resolved
Hide resolved
|
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 |
|
@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? |
# Conflicts: # Shared/Strings/Strings.swift # Translations/en.lproj/Localizable.strings
|
@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! |
|
@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 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! |
|
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 If we have a offset/scroll position we could then move the filter into the Simulator.Screen.Recording.-.Apple.TV.4K.3rd.generation.-.2025-11-18.at.11.58.05.movCode//
// 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 FilterView: There is some existing work on this and the 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 After these four, I think we want to sit and wait for #1752 to merge since this fundementally changes how we work for Once available and there aren't outstanding changes in the 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. |



Added a Library header, with the name of the library and Filter / Sorting functionality
Library has the filtering / sorting options menu:
Sorting / Filtering options can be "remembered" by library at the settings screen: