jellyflood/Shared/ViewModels/HomeViewModel.swift

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
)
}
_ = try await userSession.client.send(request)
}
}