Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- 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>
This commit is contained in:
180
doc/research/reference-sources/wf_CreatorContentViewModel.swift
Normal file
180
doc/research/reference-sources/wf_CreatorContentViewModel.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import CoreData
|
||||
import FloatplaneAPIClient
|
||||
|
||||
class CreatorContentViewModel: BaseViewModel, ObservableObject {
|
||||
|
||||
enum LoadingMode {
|
||||
case append
|
||||
case prepend
|
||||
}
|
||||
|
||||
@Published var state: ViewModelState<[BlogPostModelV3]> = .idle
|
||||
@Published var searchText: String = ""
|
||||
|
||||
private var isVisible = true
|
||||
private let fpApiService: FPAPIService
|
||||
let managedObjectContext: NSManagedObjectContext
|
||||
let creatorOrChannel: CreatorOrChannel
|
||||
let livestream: LiveStreamModel?
|
||||
var searchDebounce: AnyCancellable? = nil
|
||||
|
||||
var hasCover: Bool {
|
||||
creatorOrChannel.cover == nil
|
||||
}
|
||||
|
||||
var coverImagePath: URL? {
|
||||
if let cover = creatorOrChannel.cover {
|
||||
return URL(string: cover.path)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var creatorProfileImagePath: URL? {
|
||||
URL(string: creatorOrChannel.icon.path)
|
||||
}
|
||||
|
||||
fileprivate var aboutFirstNewlineIndex: String.Index {
|
||||
if creatorOrChannel.about.first == "#" {
|
||||
return creatorOrChannel.aboutFixed.firstIndex(of: "\n") ?? creatorOrChannel.aboutFixed.startIndex
|
||||
} else {
|
||||
return creatorOrChannel.aboutFixed.startIndex
|
||||
}
|
||||
}
|
||||
|
||||
lazy var creatorAboutHeader: AttributedString = (try? AttributedString(markdown: String(creatorOrChannel.aboutFixed[..<aboutFirstNewlineIndex]), options: .init(allowsExtendedAttributes: false, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: nil))) ?? AttributedString("")
|
||||
lazy var creatorAboutBody: AttributedString = (try? AttributedString(markdown: String(creatorOrChannel.aboutFixed[aboutFirstNewlineIndex...]), options: .init(allowsExtendedAttributes: false, interpretedSyntax: .full, failurePolicy: .returnPartiallyParsedIfPossible, languageCode: nil))) ?? AttributedString("")
|
||||
|
||||
init(fpApiService: FPAPIService, managedObjectContext: NSManagedObjectContext, creatorOrChannel: CreatorOrChannel, livestream: LiveStreamModel?) {
|
||||
self.fpApiService = fpApiService
|
||||
self.managedObjectContext = managedObjectContext
|
||||
self.creatorOrChannel = creatorOrChannel
|
||||
self.livestream = livestream
|
||||
super.init()
|
||||
|
||||
searchDebounce = $searchText
|
||||
.debounce(for: 0.8, scheduler: DispatchQueue.main)
|
||||
.removeDuplicates()
|
||||
.dropFirst()
|
||||
.sink(receiveValue: { _ in
|
||||
self.state = .loading
|
||||
self.load()
|
||||
})
|
||||
}
|
||||
|
||||
func createSubViewModel() -> CreatorContentViewModel {
|
||||
return CreatorContentViewModel(fpApiService: fpApiService,
|
||||
managedObjectContext: managedObjectContext,
|
||||
creatorOrChannel: creatorOrChannel,
|
||||
livestream: livestream)
|
||||
}
|
||||
|
||||
func load(loadingMode: LoadingMode = .append) {
|
||||
Task { @MainActor in
|
||||
if self.state.isIdle {
|
||||
state = .loading
|
||||
}
|
||||
|
||||
var fetchAfter = 0
|
||||
switch (loadingMode, state) {
|
||||
case let (.append, .loaded(posts)):
|
||||
fetchAfter = posts.count
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let creatorId = creatorOrChannel.creatorId
|
||||
let channelId = creatorOrChannel.channelId
|
||||
let limit = 20
|
||||
logger.info("Loading creator content.", metadata: [
|
||||
"creatorId": "\(creatorId)",
|
||||
"channelId": "\(channelId ?? "")",
|
||||
"limit": "\(limit)",
|
||||
"fetchAfter": "\(fetchAfter)",
|
||||
"searchText": "\(self.searchText)",
|
||||
])
|
||||
|
||||
let response: [BlogPostModelV3]
|
||||
do {
|
||||
response = try await fpApiService.getCreatorContent(id: creatorId, limit: limit, fetchAfter: fetchAfter, search: self.searchText, channelId: channelId)
|
||||
} catch {
|
||||
self.state = .failed(error)
|
||||
return
|
||||
}
|
||||
|
||||
if !response.isEmpty {
|
||||
logger.info("Loading progress for creator content in background.")
|
||||
Task {
|
||||
do {
|
||||
let progresses = try await fpApiService.getProgress(ids: response.map(\.id))
|
||||
for progress in progresses {
|
||||
let blogPostId = progress.id
|
||||
if let blogPost = response.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 creator 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(posts)):
|
||||
self.logger.notice("Received creator content. Appending new items to list. Received \(response.count) items.")
|
||||
self.state = .loaded(posts + response)
|
||||
case let (.prepend, .loaded(prevResponse)):
|
||||
let prevResponseIds = Set(prevResponse.lazy.map(\.id))
|
||||
if let last = response.last, prevResponseIds.contains(last.id) {
|
||||
let newBlogPosts = response.filter({ !prevResponseIds.contains($0.id) })
|
||||
self.logger.notice("Received creator content. Received \(response.count) items. Prepending only new items to list. Prepending \(newBlogPosts.count) items.")
|
||||
if !newBlogPosts.isEmpty {
|
||||
self.state = .loaded(newBlogPosts + prevResponse)
|
||||
}
|
||||
} else {
|
||||
self.logger.notice("Received creator content. Encountered gap in new items and old items. Resetting list to only new items. Received \(response.count) items.")
|
||||
|
||||
self.state = .loaded(response)
|
||||
}
|
||||
default:
|
||||
self.logger.notice("Received creator content. Received \(response.count) items.")
|
||||
self.state = .loaded(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func itemDidAppear(_ item: BlogPostModelV3) {
|
||||
switch state {
|
||||
case let .loaded(posts):
|
||||
if posts.lastIndex(of: item) == posts.endIndex.advanced(by: -1) {
|
||||
self.logger.info("Last item appeared on screen. Loading more creator content.")
|
||||
self.load()
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func creatorContentDidDisappear() {
|
||||
isVisible = false
|
||||
}
|
||||
|
||||
func creatorContentDidAppearAgain() {
|
||||
if !isVisible {
|
||||
self.load(loadingMode: .prepend)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class MockCreatorContentViewModel: CreatorContentViewModel {
|
||||
init(state: ViewModelState<[BlogPostModelV3]>) {
|
||||
super.init(fpApiService: MockFPAPIService(), managedObjectContext: PersistenceController.preview.container.viewContext, creatorOrChannel: MockData.creatorV3, livestream: MockData.creatorV3.liveStream)
|
||||
self.state = state
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user