Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
37e0323
Syntax Updates
hqueiroga Oct 17, 2025
9cb6b16
Syntax Updates
hqueiroga Oct 17, 2025
1ccecde
.sheet modal problem fix
hqueiroga Oct 17, 2025
3a7c126
Removed Movies and TV Shows from top menu and reordered items
hqueiroga Oct 17, 2025
1a3f776
Syntax updates
hqueiroga Oct 17, 2025
68e57c1
Library Navigation bar with Filters and Sorting + Ability of store them
hqueiroga Oct 17, 2025
d2b147d
Filter changes adjustment
hqueiroga Oct 20, 2025
347a1b0
TV Shows watching indicators
hqueiroga Oct 20, 2025
845a3ee
Library Navigation bar with Filters and Sorting + Ability of store them
hqueiroga Oct 17, 2025
27b2f8f
Filter changes adjustment
hqueiroga Oct 20, 2025
019c078
Merge branch 'main' into libray-filters-and-sorting
LePips Oct 24, 2025
bccea37
Created new component, clean-up unused code, UI improvements
hqueiroga Oct 26, 2025
99a08be
Merge remote-tracking branch 'origin/main' into libray-filters-and-so…
hqueiroga Oct 26, 2025
b650077
Revert "Merge remote-tracking branch 'origin/main' into libray-filter…
hqueiroga Oct 26, 2025
95fb3a6
Merge branch 'origin-main' into libray-filters-and-sorting
hqueiroga Oct 26, 2025
a55543f
Localization Changes
hqueiroga Oct 27, 2025
b2100b8
Merge branch 'main' into libray-filters-and-sorting
JPKribs Oct 28, 2025
039a735
Merge branch 'origin-main' into libray-filters-and-sorting
hqueiroga Nov 5, 2025
d85204b
GeometryReader use, Removed comments and updated Strings
hqueiroga Nov 9, 2025
0f34df9
Moved tvOSHeader to the new custom .header from the CollectionVGrid
hqueiroga Nov 9, 2025
113ce90
Removed Fixed sizes and Paddings
hqueiroga Nov 9, 2025
76c4b18
Add title for the Libraries from the Tab Bar
hqueiroga Nov 9, 2025
60430d2
Added multi-selection capability to the ListRowMenu component
hqueiroga Nov 10, 2025
ec00d5c
Added new component ListRowToggleCheckbox for tvOS
hqueiroga Nov 10, 2025
c8a2f05
Added comment to default single-action
hqueiroga Nov 10, 2025
1de043c
FilterView update to use new components ListRowToggleCheckbox and Lis…
hqueiroga Nov 10, 2025
0cc9464
make Played and Unplayed mutually exclusive at filtering screen
hqueiroga Nov 11, 2025
dddd67c
Fix: updates the number of items filtered at the header
hqueiroga Nov 11, 2025
6ebec04
Order the filtering criteria at the top header
hqueiroga Nov 11, 2025
779c2cc
Fix: updates the number of items filtered at the header
hqueiroga Nov 11, 2025
c163703
remove unecessary comments
hqueiroga Nov 11, 2025
a1d73f3
Fix: if no results still showing library header so another filter can…
hqueiroga Nov 11, 2025
122c4fc
Merge branch 'main' into libray-filters-and-sorting
hqueiroga Nov 11, 2025
98dded8
Merge branch 'main' into libray-filters-and-sorting
hqueiroga Nov 11, 2025
035e272
Merge branch 'main' into libray-filters-and-sorting
hqueiroga Nov 12, 2025
1301ef9
Merge branch 'main' into libray-filters-and-sorting
hqueiroga Nov 16, 2025
e9dde26
fix(tvOS): prevent top navigation overlap & make filter buttons focus…
May 21, 2026
17b13d7
Merge pull request #1 from Rushikeshh-patil/clean-fixes-for-pr-1770
hqueiroga May 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@ extension NavigationRoute {
}
#endif

#if os(tvOS)
static func filter(type: ItemFilterType, viewModel: FilterViewModel) -> NavigationRoute {
NavigationRoute(
id: "filter",
style: .sheet
) {
FilterView(viewModel: viewModel, type: type)
}
}
#endif

static func library(
viewModel: PagingLibraryViewModel<some Poster>
) -> NavigationRoute {
Expand Down
1 change: 1 addition & 0 deletions Shared/Coordinators/Tabs/TabItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ extension TabItem {
systemImage: systemName
) {
let viewModel = ItemLibraryViewModel(
parent: TitledLibraryParent(displayTitle: title),
filters: filters
)

Expand Down
1 change: 1 addition & 0 deletions Shared/Services/SwiftfinDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,7 @@ extension Defaults.Keys {

static let rememberLayout: Key<Bool> = UserKey("libraryRememberLayout", default: false)
static let rememberSort: Key<Bool> = UserKey("libraryRememberSort", default: false)
static let rememberFiltering: Key<Bool> = UserKey("libraryRememberFiltering", default: false)
}

enum Home {
Expand Down
6 changes: 6 additions & 0 deletions Shared/Strings/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,8 @@ internal enum L10n {
internal static let bugsAndFeatures = L10n.tr("Localizable", "bugsAndFeatures", fallback: "Bugs and features")
/// Buttons
internal static let buttons = L10n.tr("Localizable", "buttons", fallback: "Buttons")
/// By
internal static let by = L10n.tr("Localizable", "by", fallback: "By")
/// Cancel
internal static let cancel = L10n.tr("Localizable", "cancel", fallback: "Cancel")
/// Cancelling...
Expand Down Expand Up @@ -1190,6 +1192,10 @@ 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 filtering
internal static let rememberFiltering = L10n.tr("Localizable", "rememberFiltering", fallback: "Remember filtering")
/// Remember filtering for individual libraries.
internal static let rememberFilteringFooter = L10n.tr("Localizable", "rememberFilteringFooter", fallback: "Remember filtering for individual libraries.")
/// Remember layout
internal static let rememberLayout = L10n.tr("Localizable", "rememberLayout", fallback: "Remember layout")
/// Remember layout for individual libraries.
Expand Down
56 changes: 55 additions & 1 deletion Shared/ViewModels/FilterViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//

import Combine
import Defaults
import Foundation
import JellyfinAPI
import OrderedCollections
Expand Down Expand Up @@ -112,6 +113,40 @@ final class FilterViewModel: ViewModel, Stateful {
currentFilters = .default
}

// Clear stored filters when rememberFiltering is enabled
if let id = parent?.id, Defaults[.Customization.Library.rememberFiltering] {
var storedFilters = StoredValues[.User.libraryFilters(parentID: id)]

if let type {
// Reset specific filter type in stored filters
switch type {
case .genres:
storedFilters.genres = ItemFilterCollection.default.genres
case .letter:
storedFilters.letter = ItemFilterCollection.default.letter
case .sortBy:
storedFilters.sortBy = ItemFilterCollection.default.sortBy
case .sortOrder:
storedFilters.sortOrder = ItemFilterCollection.default.sortOrder
case .tags:
storedFilters.tags = ItemFilterCollection.default.tags
case .traits:
storedFilters.traits = ItemFilterCollection.default.traits
case .years:
storedFilters.years = ItemFilterCollection.default.years
}
} else {
// Reset all filtering filters (not sorting)
storedFilters.genres = ItemFilterCollection.default.genres
storedFilters.letter = ItemFilterCollection.default.letter
storedFilters.tags = ItemFilterCollection.default.tags
storedFilters.traits = ItemFilterCollection.default.traits
storedFilters.years = ItemFilterCollection.default.years
}

StoredValues[.User.libraryFilters(parentID: id)] = storedFilters
}

case let .update(type, filters):
updateCurrentFilters(for: type, with: filters)
}
Expand Down Expand Up @@ -157,7 +192,26 @@ final class FilterViewModel: ViewModel, Stateful {
case .tags:
currentFilters.tags = newValue.map(ItemTag.init)
case .traits:
currentFilters.traits = newValue.map(ItemTrait.init)
var traits = newValue.map(ItemTrait.init)

let isPlayedSelected = traits.contains(.isPlayed)
let isUnplayedSelected = traits.contains(.isUnplayed)

if isPlayedSelected && isUnplayedSelected {
let oldTraits = currentFilters.traits
let oldHasPlayed = oldTraits.contains(.isPlayed)
let oldHasUnplayed = oldTraits.contains(.isUnplayed)

if oldHasUnplayed {
traits.removeAll { $0 == .isUnplayed }
} else if oldHasPlayed {
traits.removeAll { $0 == .isPlayed }
} else {
traits.removeAll { $0 == .isUnplayed }
}
}

currentFilters.traits = traits
case .years:
currentFilters.years = newValue.map(ItemYear.init)
}
Expand Down
7 changes: 7 additions & 0 deletions Shared/ViewModels/LibraryViewModel/ItemLibraryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ final class ItemLibraryViewModel: PagingLibraryViewModel<BaseItemDto> {
let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters)
let response = try await userSession.client.send(request)

// Update total count on first page load
if page == 0 {
await MainActor.run {
self.totalCount = response.value.totalRecordCount ?? 0
}
}

// 1 - only care to keep collections that hold valid items
// 2 - if parent is type `folder`, then we are in a folder-view
// context so change `collectionFolder` types to `folder`
Expand Down
29 changes: 23 additions & 6 deletions Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ protocol LibraryIdentifiable: Identifiable {
var unwrappedIDHashOrZero: Int { get }
}

/// Protocol for view models that provide a total count of items
protocol HasTotalCount: AnyObject {
var totalCount: Int { get }
}

// TODO: fix how `hasNextPage` is determined
// - some subclasses might not have "paging" and only have one call. This can be solved with
// a check if elements were actually appended to the set but that requires a redundant get
Expand All @@ -60,6 +65,8 @@ protocol LibraryIdentifiable: Identifiable {
on remembering other filters.
*/

extension PagingLibraryViewModel: HasTotalCount {}

class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {

// MARK: Event
Expand Down Expand Up @@ -99,6 +106,8 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
var elements: IdentifiedArray<Int, Element>
@Published
var state: State = .initial
@Published
var totalCount: Int = 0

final let filterViewModel: FilterViewModel?
final let parent: (any LibraryParent)?
Expand Down Expand Up @@ -172,14 +181,21 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
self.parent = parent

if var filters {
if let id = parent?.id, Defaults[.Customization.Library.rememberSort] {
// TODO: see `StoredValues.User.libraryFilters` for TODO
// on remembering other filters

if let id = parent?.id {
let storedFilters = StoredValues[.User.libraryFilters(parentID: id)]

filters.sortBy = storedFilters.sortBy
filters.sortOrder = storedFilters.sortOrder
if Defaults[.Customization.Library.rememberSort] {
filters.sortBy = storedFilters.sortBy
filters.sortOrder = storedFilters.sortOrder
}

if Defaults[.Customization.Library.rememberFiltering] {
filters.genres = storedFilters.genres
filters.letter = storedFilters.letter
filters.tags = storedFilters.tags
filters.traits = storedFilters.traits
filters.years = storedFilters.years
}
}

self.filterViewModel = .init(
Expand Down Expand Up @@ -334,6 +350,7 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {

await MainActor.run {
elements.removeAll()
totalCount = 0
}

try await getNextPage()
Expand Down
77 changes: 77 additions & 0 deletions Swiftfin tvOS/Components/ListRowMenu.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ extension ListRowMenu {
// Initialize from a CaseIterable Enum
extension ListRowMenu where Subtitle == Text, Content == AnyView {

// single-selection from a CaseIterable Enum
init<ItemType>(
_ title: String,
selection: Binding<ItemType>
Expand All @@ -136,4 +137,80 @@ extension ListRowMenu where Subtitle == Text, Content == AnyView {
.eraseToAnyView()
}
}

// multi-selection from a CaseIterable Enum
init<ItemType>(
_ title: String,
selection: Binding<[ItemType]>
) where ItemType: CaseIterable & Displayable & Hashable,
ItemType.AllCases: RandomAccessCollection
{
let selectedCount = selection.wrappedValue.count
let subtitleText: String
if selectedCount == 0 {
subtitleText = L10n.none
} else if selectedCount == 1 {
subtitleText = selection.wrappedValue.first?.displayTitle ?? L10n.none
} else {
subtitleText = "\(selectedCount) selected"
}

self.title = Text(title)
self.subtitle = Text(subtitleText)
self.content = {
ForEach(Array(ItemType.allCases), id: \.self) { option in
Button(action: {
var currentSelection = selection.wrappedValue
if currentSelection.contains(option) {
currentSelection.removeAll { $0 == option }
} else {
currentSelection.append(option)
}
selection.wrappedValue = currentSelection
}) {
HStack {
Text(option.displayTitle)
Spacer()
if selection.wrappedValue.contains(option) {
Image(systemName: "checkmark")
}
}
}
}
.eraseToAnyView()
}
}

// multi-selection with dynamic items (for non-CaseIterable types)
init<Item: Hashable & Displayable>(
_ title: String,
subtitle: String,
items: [Item],
selection: Binding<[Item]>
) {
self.title = Text(title)
self.subtitle = Text(subtitle)
self.content = {
ForEach(items, id: \.hashValue) { item in
Button(action: {
var currentSelection = selection.wrappedValue
if currentSelection.contains(item) {
currentSelection.removeAll { $0.hashValue == item.hashValue }
} else {
currentSelection.append(item)
}
selection.wrappedValue = currentSelection
}) {
HStack {
Text(item.displayTitle)
Spacer()
if selection.wrappedValue.contains(item) {
Image(systemName: "checkmark")
}
}
}
}
.eraseToAnyView()
}
}
}
69 changes: 69 additions & 0 deletions Swiftfin tvOS/Components/ListRowToggleCheckbox.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//
// 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 SwiftUI

struct ListRowToggleCheckbox: View {

// MARK: - Focus State

@FocusState
private var isFocused: Bool

// MARK: - Properties

private let title: Text
private let isOn: Binding<Bool>

// MARK: - Body

var body: some View {
Button(action: {
isOn.wrappedValue.toggle()
}) {
HStack {
title
.foregroundStyle(isFocused ? .black : .white)
.padding(.leading, 4)

Spacer()

Image(systemName: isOn.wrappedValue ? "checkmark.circle.fill" : "circle")
.font(.body.weight(.regular))
.foregroundStyle(isFocused ? .black : .secondary)
.brightness(isFocused ? 0.4 : 0)
}
.padding(.horizontal)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(isFocused ? Color.white : Color.clear)
)
.scaleEffect(isFocused ? 1.04 : 1.0)
.animation(.easeInOut(duration: 0.125), value: isFocused)
}
// .buttonStyle(.plain)
.listRowInsets(.zero)
.focused($isFocused)
}
}

// MARK: - Initializers

extension ListRowToggleCheckbox {

init(_ title: String, isOn: Binding<Bool>) {
self.title = Text(title)
self.isOn = isOn
}

init(_ title: Text, isOn: Binding<Bool>) {
self.title = title
self.isOn = isOn
}
}
15 changes: 15 additions & 0 deletions Swiftfin tvOS/Extensions/View/View-tvOS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,21 @@ extension View {
func prefersStatusBarHidden(_ hidden: Bool) -> some View {
self
}

@ViewBuilder
func navigationBarCloseButton(
disabled: Bool = false,
_ action: @escaping () -> Void
) -> some View {
toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(L10n.close) {
action()
}
.disabled(disabled)
}
}
}
}

extension EnvironmentValues {
Expand Down
Loading