Skip to content

[WIP] [tvOS] Letter Picker / Filters #1407

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 38 commits into
base: main
Choose a base branch
from
Draft
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
cba382f
Rebase on Main + Main work for ItemType Libraries
JPKribs Jan 23, 2025
9cd3760
Cleanup and ease of use. Enable the settings for LetterPicker
JPKribs Jan 23, 2025
6e5c4a2
Get PagingLibraryView on iOS and tvOS on the same page. Both should m…
JPKribs Jan 23, 2025
ae5a896
Merge branch 'main' into tvOSLetterPicker
JPKribs Jan 25, 2025
8914395
Merge Fixes
JPKribs Jan 25, 2025
0ec5f9c
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Jan 26, 2025
aca2eea
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Feb 6, 2025
860be12
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Feb 7, 2025
01e2dc5
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Feb 12, 2025
bb2690f
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Feb 14, 2025
a0f6cc6
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Feb 16, 2025
ca2abcb
Update Filter logic for letters based on new `FilterViewModel` Mirror…
JPKribs Feb 16, 2025
4cad34f
Still VERY much a work in progress but I think this looks nice. The f…
JPKribs Feb 27, 2025
8e4ef9b
Reversable sides! Non-Reversable Spacing. WIP.
JPKribs Mar 1, 2025
0be566a
WIP - Filters work. Everything works. The UI is just... clumsy.
JPKribs Mar 1, 2025
a752a40
Drawer -> Bar and attempts at spacing.
JPKribs Mar 2, 2025
3597c73
Width is dynamic based on text. Make reset button `desctructive`
JPKribs Mar 2, 2025
a261c4f
Focus improvements AND better spacing/placement
JPKribs Mar 2, 2025
fb2e0ad
Comment cleanup.
JPKribs Mar 2, 2025
dc56323
Background on Focus?
JPKribs Mar 2, 2025
94684eb
Backdrop
JPKribs Mar 3, 2025
361b601
Merge branch 'main' into tvOSLetterPicker
JPKribs Mar 4, 2025
115f220
Sizing Changes
JPKribs Mar 6, 2025
dbccf79
Merge remote-tracking branch 'refs/remotes/origin/tvOSLetterPicker'
JPKribs Mar 6, 2025
195e792
Search Filters
JPKribs Mar 10, 2025
99f1cbf
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Mar 14, 2025
589c800
Merge branch 'main' into tvOSLetterPicker
JPKribs Mar 17, 2025
21cefd6
ListRowMenu
JPKribs Mar 17, 2025
1760c01
Fix focus management issues.
JPKribs Mar 25, 2025
4c0505c
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Mar 30, 2025
c8be26c
Remove ViewModifer in favor of an HStack. Same thing just a little di…
JPKribs Mar 31, 2025
1bbb91b
Merge remote-tracking branch 'refs/remotes/origin/tvOSLetterPicker'
JPKribs Mar 31, 2025
0bdd32a
Reset PagingLibraryViewModel
JPKribs Mar 31, 2025
3a0b94d
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Apr 6, 2025
e3c65b2
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Apr 12, 2025
ce1568e
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Apr 13, 2025
d473a52
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Apr 13, 2025
31e7227
Merge branch 'jellyfin:main' into tvOSLetterPicker
JPKribs Apr 14, 2025
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
2 changes: 1 addition & 1 deletion Shared/Components/SelectorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ struct SelectorView<Element: Displayable & Hashable, Label: View>: View {
.backport
.fontWeight(.bold)
.aspectRatio(1, contentMode: .fit)
.frame(width: 24, height: 24)
.frame(width: 36, height: 36)
.symbolRenderingMode(.palette)
.foregroundStyle(accentColor.overlayColor, accentColor)
}
Expand Down
4 changes: 0 additions & 4 deletions Shared/Coordinators/FilterCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,6 @@ final class FilterCoordinator: NavigationCoordinatable {

@ViewBuilder
func makeStart() -> some View {
#if os(tvOS)
AssertionFailureView("Not implemented")
#else
FilterView(viewModel: parameters.viewModel, type: parameters.type)
#endif
}
}
6 changes: 6 additions & 0 deletions Shared/Coordinators/LibraryCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
var item = makeItem
@Route(.push)
var library = makeLibrary
@Route(.fullScreen)
var filter = makeFilter
#else
@Route(.push)
var item = makeItem
Expand Down Expand Up @@ -52,6 +54,10 @@ final class LibraryCoordinator<Element: Poster>: NavigationCoordinatable {
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator<BaseItemDto>(viewModel: viewModel))
}

func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#else
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
Expand Down
6 changes: 6 additions & 0 deletions Shared/Coordinators/SearchCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ final class SearchCoordinator: NavigationCoordinatable {
var item = makeItem
@Route(.modal)
var library = makeLibrary
@Route(.fullScreen)
var filter = makeFilter
#else
@Route(.push)
var item = makeItem
Expand All @@ -39,6 +41,10 @@ final class SearchCoordinator: NavigationCoordinatable {
func makeLibrary(viewModel: PagingLibraryViewModel<BaseItemDto>) -> NavigationViewCoordinator<LibraryCoordinator<BaseItemDto>> {
NavigationViewCoordinator(LibraryCoordinator(viewModel: viewModel))
}

func makeFilter(parameters: FilterCoordinator.Parameters) -> NavigationViewCoordinator<FilterCoordinator> {
NavigationViewCoordinator(FilterCoordinator(parameters: parameters))
}
#else
func makeItem(item: BaseItemDto) -> ItemCoordinator {
ItemCoordinator(item: item)
Expand Down
15 changes: 15 additions & 0 deletions Shared/Extensions/String.swift
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,21 @@ extension String {

return ceil(boundingBox.height)
}

/// Calculates the width of the string with specified font size and weight
/// - Parameters:
/// - font: UIFont.TextStyle to use (default is .body)
/// - weight: Font weight to use (default is .regular)
/// - Returns: Width of string when rendered with specified parameters
func width(font textStyle: UIFont.TextStyle = .body, weight: UIFont.Weight = .regular) -> CGFloat {
let font = UIFont.systemFont(
ofSize: UIFont.preferredFont(forTextStyle: textStyle).pointSize,
weight: weight
)
let attributes = [NSAttributedString.Key.font: font]
let size = (self as NSString).size(withAttributes: attributes)
return size.width
}
}

extension CharacterSet {
Expand Down
11 changes: 10 additions & 1 deletion Shared/Objects/ItemFilter/ItemFilterCollection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,17 @@ struct ItemFilterCollection: Codable, Defaults.Serializable, Hashable {
traits: ItemTrait.supportedCases
)

// TODO: This is bad and inefficient
var hasFilters: Bool {
self != Self.default
var selfCopy = self
let defaultCopy = Self.default

selfCopy.itemTypes = defaultCopy.itemTypes

return selfCopy != defaultCopy

// Previous version:
// self != Self.default
}

var activeFilterCount: Int {
Expand Down
21 changes: 20 additions & 1 deletion Shared/Objects/ItemFilter/ItemFilterType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ enum ItemFilterType: String, CaseIterable, Defaults.Serializable {
}
}

extension ItemFilterType: Displayable {
extension ItemFilterType: Displayable, SystemImageable {

var displayTitle: String {
switch self {
Expand All @@ -68,4 +68,23 @@ extension ItemFilterType: Displayable {
L10n.years
}
}

var systemImage: String {
switch self {
case .genres:
"theatermasks"
case .letter:
"character"
case .sortBy:
"line.3.horizontal.decrease"
case .sortOrder:
"arrow.up.arrow.down"
case .tags:
"tag"
case .traits:
"arrowtriangle.down"
case .years:
"calendar"
}
}
}
3 changes: 2 additions & 1 deletion Shared/ViewModels/FilterViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ final class FilterViewModel: ViewModel, Stateful {
if let type {
resetCurrentFilters(for: type)
} else {
currentFilters = .default
// This is exclusively for tvOS ItemType libraries
currentFilters = .init(itemTypes: currentFilters.itemTypes)
}

case let .update(type, filters):
Expand Down
132 changes: 103 additions & 29 deletions Shared/ViewModels/LibraryViewModel/PagingLibraryViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ protocol LibraryIdentifiable: Identifiable {
Note: if `rememberSort == true`, then will override given filters with stored sorts
for parent ID. This was just easy. See `PagingLibraryView` notes for lack of
`rememberSort` observation and `StoredValues.User.libraryFilters` for TODO
on remembering other filters.
on remembering other filters
*/

class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
Expand Down Expand Up @@ -92,25 +92,27 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
}

@Published
var backgroundStates: Set<BackgroundState> = []
var backgroundStates: Set<BackgroundState>
/// - Keys: the `hashValue` of the `Element.ID`
@Published
var elements: IdentifiedArray<Int, Element>
final var elements: IdentifiedArray<Int, Element>
@Published
var state: State = .initial
final var state: State = .initial
@Published
final var lastAction: Action? = nil

final let filterViewModel: FilterViewModel?
final let parent: (any LibraryParent)?

final var events: AnyPublisher<Event, Never> {
var events: AnyPublisher<Event, Never> {
eventSubject
.receive(on: RunLoop.main)
.eraseToAnyPublisher()
}

let pageSize: Int
private(set) var currentPage = 0
private(set) var hasNextPage = true
private(set) final var currentPage = 0
private(set) final var hasNextPage = true

private let eventSubject: PassthroughSubject<Event, Never> = .init()
private let isStatic: Bool
Expand All @@ -120,6 +122,12 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
private var pagingTask: AnyCancellable?
private var randomItemTask: AnyCancellable?

// Page loading queue management
private var isLoadingPage = false
private var pendingPageLoadRequests = 0
private let pageLoadQueue = DispatchQueue(label: "com.jellyfin.swiftfin.pageLoadQueue")
private var isRefreshing = false

// MARK: init

// static
Expand All @@ -134,6 +142,8 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
self.pageSize = DefaultPageSize
self.parent = parent

self.backgroundStates = []

super.init()

Notifications[.didDeleteItem]
Expand Down Expand Up @@ -189,6 +199,8 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
self.filterViewModel = nil
}

self.backgroundStates = []

super.init()

Notifications[.didDeleteItem]
Expand All @@ -206,6 +218,12 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
.sink { [weak self] _ in
guard let self else { return }

// Reset page loading queue state before refreshing
self.pageLoadQueue.sync {
self.isLoadingPage = false
self.pendingPageLoadRequests = 0
}

Task { @MainActor in
self.send(.refresh)
}
Expand Down Expand Up @@ -252,6 +270,12 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
pagingTask?.cancel()
randomItemTask?.cancel()

// Reset page loading queue state
pageLoadQueue.sync {
isLoadingPage = false
pendingPageLoadRequests = 0
}

filterViewModel?.send(.getQueryFilters)

pagingTask = Task { [weak self] in
Expand Down Expand Up @@ -280,28 +304,8 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {

guard hasNextPage else { return state }

backgroundStates.insert(.gettingNextPage)

pagingTask = Task { [weak self] in
do {
try await self?.getNextPage()

guard !Task.isCancelled else { return }

await MainActor.run {
self?.backgroundStates.remove(.gettingNextPage)
self?.state = .content
}
} catch {
guard !Task.isCancelled else { return }

await MainActor.run {
self?.backgroundStates.remove(.gettingNextPage)
self?.state = .error(.init(error.localizedDescription))
}
}
}
.asAnyCancellable()
// Queue the page loading request
queueNextPageLoad()

return .content
case .getRandomItem:
Expand All @@ -324,18 +328,88 @@ class PagingLibraryViewModel<Element: Poster>: ViewModel, Eventful, Stateful {
}
}

// MARK: Page loading queue management

private func queueNextPageLoad() {
pageLoadQueue.async { [weak self] in
guard let self = self else { return }

if self.isLoadingPage {
// A page is currently loading, increment the pending requests counter
self.pendingPageLoadRequests += 1
return
}

// No page currently loading, start loading immediately
self.isLoadingPage = true
self.loadNextPage()
}
}

private func loadNextPage() {
Task { @MainActor in
self.backgroundStates.insert(.gettingNextPage)
}

pagingTask = Task { [weak self] in
guard let self = self else { return }

do {
try await self.getNextPage()

guard !Task.isCancelled else { return }

await MainActor.run {
self.backgroundStates.remove(.gettingNextPage)
self.state = .content
}
} catch {
guard !Task.isCancelled else { return }

await MainActor.run {
self.backgroundStates.remove(.gettingNextPage)
self.state = .error(.init(error.localizedDescription))
}
}

// Check if there are pending requests in the queue
self.pageLoadQueue.async { [weak self] in
guard let self = self else { return }

if self.pendingPageLoadRequests > 0 {
self.pendingPageLoadRequests -= 1
// Process the next request in the queue
self.loadNextPage()
} else {
// No more pending requests
self.isLoadingPage = false
}
}
}
.asAnyCancellable()
}

// MARK: refresh

final func refresh() async throws {
// Set refreshing flag to track state
isRefreshing = true

currentPage = -1
hasNextPage = true

await MainActor.run {
elements.removeAll()
// Ensure visibleIndices are reset to avoid Range error
// This helps prevent the "Range requires lowerBound <= upperBound" crash
// when elements are cleared but visibleIndices still have old values
backgroundStates = []
}

try await getNextPage()

// Reset refreshing flag
isRefreshing = false
}

/// Gets the next page of items or immediately returns if
Expand Down
Loading