// // 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 Factory import Foundation import Get import JellyfinAPI import OrderedCollections import UIKit // TODO: come up with a cleaner, more defined way for item update notifications class ItemViewModel: ViewModel, Stateful { // MARK: Action enum Action: Equatable { case backgroundRefresh case error(JellyfinAPIError) case refresh case replace(BaseItemDto) case toggleIsFavorite case toggleIsPlayed case selectMediaSource(MediaSourceInfo) } // 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 item: BaseItemDto { willSet { switch item.type { case .episode, .movie: guard !item.isMissing else { return } playButtonItem = newValue default: () } } } @Published var playButtonItem: BaseItemDto? { willSet { if let newValue { selectedMediaSource = newValue.mediaSources?.first } } } @Published private(set) var selectedMediaSource: MediaSourceInfo? @Published private(set) var similarItems: [BaseItemDto] = [] @Published private(set) var specialFeatures: [BaseItemDto] = [] @Published private(set) var localTrailers: [BaseItemDto] = [] @Published var backgroundStates: Set = [] @Published var state: State = .initial var presentPlayButton: Bool { true } // tasks private var toggleIsFavoriteTask: AnyCancellable? private var toggleIsPlayedTask: AnyCancellable? private var refreshTask: AnyCancellable? // MARK: init init(item: BaseItemDto) { self.item = item super.init() Notifications[.itemShouldRefreshMetadata] .publisher .sink { itemID in guard itemID == self.item.id else { return } Task { await self.send(.backgroundRefresh) } } .store(in: &cancellables) Notifications[.itemMetadataDidChange] .publisher .sink { newItem in guard let newItemID = newItem.id, newItemID == self.item.id else { return } Task { await self.send(.replace(newItem)) } } .store(in: &cancellables) } // MARK: respond func respond(to action: Action) -> State { switch action { case .backgroundRefresh: backgroundStates.insert(.refresh) Task { [weak self] in guard let self else { return } do { async let fullItem = getFullItem() async let similarItems = getSimilarItems() async let specialFeatures = getSpecialFeatures() async let localTrailers = getLocalTrailers() let results = try await ( fullItem: fullItem, similarItems: similarItems, specialFeatures: specialFeatures, localTrailers: localTrailers ) guard !Task.isCancelled else { return } try await onRefresh() guard !Task.isCancelled else { return } await MainActor.run { self.backgroundStates.remove(.refresh) // see TODO, as the item will be set in // itemMetadataDidChange notification but // is a bit redundant // self.item = results.fullItem self.similarItems = results.similarItems self.specialFeatures = results.specialFeatures self.localTrailers = results.localTrailers Notifications[.itemMetadataDidChange].post(results.fullItem) } } catch { guard !Task.isCancelled else { return } await MainActor.run { self.backgroundStates.remove(.refresh) self.send(.error(.init(error.localizedDescription))) } } } .store(in: &cancellables) return state case let .error(error): return .error(error) case .refresh: refreshTask?.cancel() refreshTask = Task { [weak self] in guard let self else { return } do { async let fullItem = getFullItem() async let similarItems = getSimilarItems() async let specialFeatures = getSpecialFeatures() async let localTrailers = getLocalTrailers() let results = try await ( fullItem: fullItem, similarItems: similarItems, specialFeatures: specialFeatures, localTrailers: localTrailers ) guard !Task.isCancelled else { return } try await onRefresh() guard !Task.isCancelled else { return } await MainActor.run { self.item = results.fullItem self.similarItems = results.similarItems self.specialFeatures = results.specialFeatures self.localTrailers = results.localTrailers self.state = .content } } catch { guard !Task.isCancelled else { return } await MainActor.run { self.send(.error(.init(error.localizedDescription))) } } } .asAnyCancellable() return .refreshing case let .replace(newItem): backgroundStates.insert(.refresh) Task { [weak self] in guard let self else { return } do { await MainActor.run { self.backgroundStates.remove(.refresh) self.item = newItem } } } .store(in: &cancellables) return state case .toggleIsFavorite: toggleIsFavoriteTask?.cancel() toggleIsFavoriteTask = Task { let beforeIsFavorite = item.userData?.isFavorite ?? false await MainActor.run { item.userData?.isFavorite?.toggle() } do { try await setIsFavorite(!beforeIsFavorite) } catch { await MainActor.run { item.userData?.isFavorite = beforeIsFavorite // emit event that toggle unsuccessful } } } .asAnyCancellable() return state case .toggleIsPlayed: toggleIsPlayedTask?.cancel() toggleIsPlayedTask = Task { let beforeIsPlayed = item.userData?.isPlayed ?? false await MainActor.run { item.userData?.isPlayed?.toggle() } do { try await setIsPlayed(!beforeIsPlayed) } catch { await MainActor.run { item.userData?.isPlayed = beforeIsPlayed // emit event that toggle unsuccessful } } } .asAnyCancellable() return state case let .selectMediaSource(newSource): selectedMediaSource = newSource return state } } @available(*, deprecated, message: "Override the `respond` method instead and return `super.respond(to:)`") func onRefresh() async throws {} private func getFullItem() async throws -> BaseItemDto { var parameters = Paths.GetItemsByUserIDParameters() parameters.enableUserData = true parameters.fields = ItemFields.allCases parameters.ids = [item.id!] let request = Paths.getItemsByUserID(userID: userSession.user.id, parameters: parameters) let response = try await userSession.client.send(request) guard let fullItem = response.value.items?.first else { throw JellyfinAPIError("Full item not in response") } return fullItem } private func getSimilarItems() async -> [BaseItemDto] { var parameters = Paths.GetSimilarItemsParameters() parameters.fields = .MinimumFields parameters.limit = 20 parameters.userID = userSession.user.id let request = Paths.getSimilarItems( itemID: item.id!, parameters: parameters ) let response = try? await userSession.client.send(request) return response?.value.items ?? [] } private func getSpecialFeatures() async -> [BaseItemDto] { let request = Paths.getSpecialFeatures( itemID: item.id!, userID: userSession.user.id ) let response = try? await userSession.client.send(request) return (response?.value ?? []) .filter { $0.extraType?.isVideo ?? false } } private func getLocalTrailers() async throws -> [BaseItemDto] { guard let itemID = item.id else { return [] } let request = Paths.getLocalTrailers(itemID: itemID, userID: userSession.user.id) let response = try? await userSession.client.send(request) return response?.value ?? [] } private func setIsPlayed(_ isPlayed: Bool) async throws { guard let itemID = item.id else { return } let request: Request 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) Notifications[.itemShouldRefreshMetadata].post(itemID) } private func setIsFavorite(_ isFavorite: Bool) async throws { let request: Request if isFavorite { request = Paths.markFavoriteItem( itemID: item.id!, userID: userSession.user.id ) } else { request = Paths.unmarkFavoriteItem( itemID: item.id!, userID: userSession.user.id ) } _ = try await userSession.client.send(request) } }