Files
jellyfin-plugin-floatplane/doc/research/reference-sources/wf_HomeViewModel.swift
claude 9e33a343ab
Some checks failed
release / build-release (push) Has been cancelled
Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-25 09:42:27 +01:00

125 lines
4.4 KiB
Swift

import Foundation
import CoreData
import FloatplaneAPIClient
import Vapor
class HomeViewModel: BaseViewModel, ObservableObject {
enum LoadingMode {
case append
case prepend
}
let userInfo: UserInfo
private let fpApiService: FPAPIService
private let managedObjectContext: NSManagedObjectContext
@Published var state: ViewModelState<ContentCreatorListV3Response> = .idle
private var isVisible = true
init(userInfo: UserInfo, fpApiService: FPAPIService, managedObjectContext: NSManagedObjectContext) {
self.userInfo = userInfo
self.fpApiService = fpApiService
self.managedObjectContext = managedObjectContext
}
func load(loadingMode: LoadingMode = .append) {
Task { @MainActor in
if self.state.isIdle {
state = .loading
}
var lastElements: [ContentCreatorListLastItems]? = nil
switch (loadingMode, state) {
case let (.append, .loaded(response)):
// Floatplane doesn't like when we send one of these without a blogPostId.
// so, filter those objects out before using them.
lastElements = response.lastElements.filter({ $0.blogPostId != nil })
default:
break
}
let limit = 20
let ids = self.userInfo.creators.keys.map({ $0 })
logger.info("Loading home content.", metadata: [
"loadingMode": "\(loadingMode)",
"userId": "\(self.userInfo.userSelf?.id ?? "")",
"creatorIds": "\(ids.description)",
"limit": "\(limit)",
"lastElements": "\(lastElements?.description ?? "<nil>")",
])
let response: ContentCreatorListV3Response
do {
response = try await fpApiService.getHomeContent(ids: ids, limit: limit, lastItems: lastElements)
} catch {
self.state = .failed(error)
return
}
logger.info("Loading progress for home content in background.")
Task {
do {
let progresses = try await fpApiService.getProgress(ids: response.blogPosts.map(\.id))
for progress in progresses {
let blogPostId = progress.id
if let blogPost = response.blogPosts.first(where: { $0.id == blogPostId }) {
if let videoId = blogPost.firstVideoAttachmentId {
VideoViewModel.updateLocalProgress(logger: logger, blogPostId: blogPostId, videoId: videoId, videoDuration: 100.0, progressSeconds: progress.progress, managedObjectContext: managedObjectContext)
}
}
}
self.logger.info("Done loading \(progresses.count) progresses for home content.")
} catch {
self.logger.warning("Error retrieving watch progress: \(String(reflecting: error))")
Toast.post(toast: .init(.failedToLoadProgress))
}
}
switch (loadingMode, self.state) {
case let (.append, .loaded(prevResponse)):
self.logger.notice("Received home content. Appending new items to list. Received \(response.blogPosts.count) items.")
self.state = .loaded(.init(blogPosts: prevResponse.blogPosts + response.blogPosts, lastElements: response.lastElements))
case let (.prepend, .loaded(prevResponse)):
let prevResponseIds = Set(prevResponse.blogPosts.lazy.map(\.id))
if let last = response.blogPosts.last, prevResponseIds.contains(last.id) {
let newBlogPosts = response.blogPosts.filter({ !prevResponseIds.contains($0.id) })
self.logger.notice("Received home content. Received \(response.blogPosts.count) items. Prepending only new items to list. Prepending \(newBlogPosts.count) items.")
if !newBlogPosts.isEmpty {
self.state = .loaded(.init(blogPosts: newBlogPosts + prevResponse.blogPosts, lastElements: prevResponse.lastElements))
}
} else {
self.logger.notice("Received home content. Encountered gap in new items and old items. Resetting list to only new items. Received \(response.blogPosts.count) items.")
self.state = .loaded(.init(blogPosts: response.blogPosts, lastElements: response.lastElements))
}
default:
self.logger.notice("Received initial home content. Received \(response.blogPosts.count) items.")
self.state = .loaded(response)
}
}
}
func itemDidAppear(_ item: BlogPostModelV3) {
switch state {
case let .loaded(response):
if response.lastElements.contains(where: { $0.creatorId == item.creator.id && $0.blogPostId == item.id }) {
self.logger.info("Last item appeared on screen. Loading more home content.")
self.load()
}
default:
break
}
}
func homeDidDisappear() {
isVisible = false
}
func homeDidAppearAgain() {
if !isVisible {
self.load(loadingMode: .prepend)
}
}
}