jellyflood/Shared/ViewModels/HomeViewModel.swift
Joe Kribs 0025422634
[iOS & tvOS] Upgrade SDK to 10.10 (#1463)
* Buildable!

* Update file names.

* Default sort to sort name NOT name.

* SessionInfoDto vs SessionInfo

* Targetting

* Fix many invalid `ItemSortBy` existing. Will need to revisit later to see which can still be used!

* ExtraTypes Patch.

* Move from Binding to OnChange. Tested and Working.

* Update README.md

Update README to use 10.10.6. Bumped up from 10.8.13

* Update to Main on https://github.com/jellyfin/jellyfin-sdk-swift.git

* Now using https://github.com/jellyfin/jellyfin-sdk-swift.git again!

* Paths.getUserViews() userId moved to parameters

* Fix ViewModels where -Dto suffixes were removed by https://github.com/jellyfin/Swiftfin/pull/1465 auto-merge.

* SupportedCaseIterable

* tvOS supportedCases fixes for build issue.

* cleanup

* update API to 0.5.1 and correct VideoRangeTypes.

* Remove deviceProfile.responseProfiles = videoPlayer.responseProfiles

* Second to last adjustment:
Resolved: // TODO: 10.10 - Filter to only valid SortBy's for each BaseItemKind.
Last outstanding item: // TODO: 10.10 - What should authenticationProviderID & passwordResetProviderID be?

* Trailers itemID must precede userID

* Force User Policy to exist.

---------

Co-authored-by: Ethan Pippin <ethanpippin2343@gmail.com>
2025-04-06 23:42:47 -04:00

229 lines
6.9 KiB
Swift

//
// 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 Combine
import CoreStore
import Factory
import Get
import JellyfinAPI
import OrderedCollections
final class HomeViewModel: ViewModel, Stateful {
// MARK: Action
enum Action: Equatable {
case backgroundRefresh
case error(JellyfinAPIError)
case setIsPlayed(Bool, BaseItemDto)
case refresh
}
// MARK: BackgroundState
enum BackgroundState: Hashable {
case refresh
}
// MARK: State
enum State: Hashable {
case content
case error(JellyfinAPIError)
case initial
case refreshing
}
@Published
private(set) var libraries: [LatestInLibraryViewModel] = []
@Published
var resumeItems: OrderedSet<BaseItemDto> = []
@Published
var backgroundStates: Set<BackgroundState> = []
@Published
var state: State = .initial
// TODO: replace with views checking what notifications were
// posted since last disappear
@Published
var notificationsReceived: NotificationSet = .init()
private var backgroundRefreshTask: AnyCancellable?
private var refreshTask: AnyCancellable?
var nextUpViewModel: NextUpLibraryViewModel = .init()
var recentlyAddedViewModel: RecentlyAddedLibraryViewModel = .init()
override init() {
super.init()
Notifications[.itemMetadataDidChange]
.publisher
.sink { _ in
// Necessary because when this notification is posted, even with asyncAfter,
// the view will cause layout issues since it will redraw while in landscape.
// TODO: look for better solution
DispatchQueue.main.async {
self.notificationsReceived.insert(.itemMetadataDidChange)
}
}
.store(in: &cancellables)
}
func respond(to action: Action) -> State {
switch action {
case .backgroundRefresh:
backgroundRefreshTask?.cancel()
backgroundStates.insert(.refresh)
backgroundRefreshTask = Task { [weak self] in
do {
self?.nextUpViewModel.send(.refresh)
self?.recentlyAddedViewModel.send(.refresh)
let resumeItems = try await self?.getResumeItems() ?? []
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.resumeItems.elements = resumeItems
self.backgroundStates.remove(.refresh)
}
} catch is CancellationError {
// cancelled
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.backgroundStates.remove(.refresh)
self.send(.error(.init(error.localizedDescription)))
}
}
}
.asAnyCancellable()
return state
case let .error(error):
return .error(error)
case let .setIsPlayed(isPlayed, item): ()
Task {
try await setIsPlayed(isPlayed, for: item)
self.send(.backgroundRefresh)
}
.store(in: &cancellables)
return state
case .refresh:
backgroundRefreshTask?.cancel()
refreshTask?.cancel()
refreshTask = Task { [weak self] in
do {
try await self?.refresh()
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.state = .content
}
} catch is CancellationError {
// cancelled
} catch {
guard !Task.isCancelled else { return }
await MainActor.run {
guard let self else { return }
self.send(.error(.init(error.localizedDescription)))
}
}
}
.asAnyCancellable()
return .refreshing
}
}
private func refresh() async throws {
await nextUpViewModel.send(.refresh)
await recentlyAddedViewModel.send(.refresh)
let resumeItems = try await getResumeItems()
let libraries = try await getLibraries()
for library in libraries {
await library.send(.refresh)
}
await MainActor.run {
self.resumeItems.elements = resumeItems
self.libraries = libraries
}
}
private func getResumeItems() async throws -> [BaseItemDto] {
var parameters = Paths.GetResumeItemsParameters()
parameters.userID = userSession.user.id
parameters.enableUserData = true
parameters.fields = .MinimumFields
parameters.includeItemTypes = [.movie, .episode]
parameters.limit = 20
let request = Paths.getResumeItems(parameters: parameters)
let response = try await userSession.client.send(request)
return response.value.items ?? []
}
private func getLibraries() async throws -> [LatestInLibraryViewModel] {
let parameters = Paths.GetUserViewsParameters(userID: userSession.user.id)
let userViewsPath = Paths.getUserViews(parameters: parameters)
async let userViews = userSession.client.send(userViewsPath)
async let excludedLibraryIDs = getExcludedLibraries()
return try await (userViews.value.items ?? [])
.intersection([.movies, .tvshows], using: \.collectionType)
.subtracting(excludedLibraryIDs, using: \.id)
.map { LatestInLibraryViewModel(parent: $0) }
}
// TODO: use the more updated server/user data when implemented
private func getExcludedLibraries() async throws -> [String] {
let currentUserPath = Paths.getCurrentUser
let response = try await userSession.client.send(currentUserPath)
return response.value.configuration?.latestItemsExcludes ?? []
}
private func setIsPlayed(_ isPlayed: Bool, for item: BaseItemDto) async throws {
let request: Request<UserItemDataDto>
if isPlayed {
request = Paths.markPlayedItem(
itemID: item.id!,
userID: userSession.user.id
)
} else {
request = Paths.markUnplayedItem(
itemID: item.id!,
userID: userSession.user.id
)
}
let _ = try await userSession.client.send(request)
}
}