183 lines
7.8 KiB
Swift
183 lines
7.8 KiB
Swift
#if ENABLE_ADULT
|
|
import SwiftUI
|
|
import TVAnarchyCore
|
|
|
|
/// The dedicated adult browse surface (the "Adult" tab). Always lists the native
|
|
/// porn collections (freshness-aware, sourced from the library index). When
|
|
/// `switchToAdultOnlyHome` is on it becomes a full adult-only Home: Continue
|
|
/// Watching + Recently Added rails (adult-only) above the collections grid,
|
|
/// mirroring the main Home but filtered to adult content. Gated by `ENABLE_ADULT`
|
|
/// at compile time and `pornFeature` at runtime, so it never appears unless the
|
|
/// feature is switched on (the sidebar eye icon).
|
|
struct AdultView: View {
|
|
@Bindable var playlist: PlaylistController
|
|
@Bindable var player: PlayerController
|
|
@Bindable var library: LibraryController
|
|
|
|
/// How many fresh clips a collection tap loads into the queue.
|
|
@State private var count = 25
|
|
@State private var loading = false
|
|
/// The collection currently being fired (spinner + disable), nil when idle.
|
|
@State private var firing: String?
|
|
|
|
private let columns = [GridItem(.adaptive(minimum: 150), spacing: 12)]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
if library.switchToAdultOnlyHome { adultHomeRails }
|
|
collectionsSection
|
|
}
|
|
.padding(24)
|
|
}
|
|
.navigationTitle("Adult")
|
|
.toolbar { ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) } }
|
|
.task { await reload() }
|
|
// Keep the adult-only-Home rails live while that mode is on (mirrors the
|
|
// main Home's loop, which only runs while Home is open). Re-runs when the
|
|
// flag flips so turning it on mid-view starts refreshing immediately.
|
|
.task(id: library.switchToAdultOnlyHome) {
|
|
guard library.switchToAdultOnlyHome else { return }
|
|
await library.refreshIfStale()
|
|
while !Task.isCancelled {
|
|
library.refreshContinueWatching()
|
|
try? await Task.sleep(for: .seconds(8))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: adult-only Home rails
|
|
|
|
@ViewBuilder private var adultHomeRails: some View {
|
|
let continuing = library.adultContinueWatching
|
|
if !continuing.isEmpty {
|
|
rail("Continue Watching") {
|
|
ForEach(continuing) { item in
|
|
Button { play(continue: item) } label: { ContinueCard(item: item) }
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
let recent = library.adultRecentlyAdded()
|
|
if !recent.isEmpty {
|
|
rail("Recently Added") {
|
|
ForEach(recent) { show in
|
|
Button { play(show: show) } label: { ShowPoster(show: show, width: 132) }
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func rail<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text(title).font(.title3).bold()
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(alignment: .top, spacing: 14) { content() }.padding(.bottom, 4)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: collections
|
|
|
|
@ViewBuilder private var collectionsSection: some View {
|
|
collectionsHeader
|
|
if playlist.pornCollections.allSatisfy({ $0.total == 0 }) {
|
|
emptyState
|
|
} else {
|
|
LazyVGrid(columns: columns, spacing: 12) {
|
|
ForEach(playlist.pornCollections) { col in collectionCard(col) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private var collectionsHeader: some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Collections").font(.title3).bold()
|
|
Text("Freshness-aware rotation over the adult library: each tap queues a fresh, shuffled set you haven't seen in \(PornCollectionService.defaultDays) days and plays it on \(player.active?.name ?? "the active host"). Counts and freshness are tracked per file, so nothing replays across collections.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
HStack(spacing: 14) {
|
|
Stepper("Queue \(count) clips", value: $count, in: 5...100, step: 5).fixedSize()
|
|
Button { Task { await reload() } } label: { Label("Refresh", systemImage: "arrow.clockwise") }
|
|
.controlSize(.small).disabled(loading)
|
|
if loading { ProgressView().controlSize(.small) }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func collectionCard(_ col: PornCollection) -> some View {
|
|
Button { Task { await play(col) } } label: {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack {
|
|
Text(col.name.capitalized).font(.headline)
|
|
Spacer()
|
|
if firing == col.name { ProgressView().controlSize(.small) }
|
|
}
|
|
Text(col.desc).font(.caption).foregroundStyle(.secondary)
|
|
.lineLimit(2).fixedSize(horizontal: false, vertical: true)
|
|
Spacer(minLength: 4)
|
|
HStack(spacing: 4) {
|
|
Image(systemName: "sparkles").font(.caption2)
|
|
Text("\(col.fresh) fresh").font(.caption).bold()
|
|
Text("/ \(col.total)").font(.caption).foregroundStyle(.tertiary)
|
|
}
|
|
.foregroundStyle(col.fresh == 0 ? Color.secondary : Color.accentColor)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
|
|
.padding(12)
|
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(col.fresh == 0 || firing != nil)
|
|
.help(col.fresh == 0
|
|
? "Nothing fresh — everything here was played in the last \(PornCollectionService.defaultDays) days"
|
|
: "Play \(min(count, col.fresh)) fresh clip\(min(count, col.fresh) == 1 ? "" : "s") on \(player.active?.name ?? "host")")
|
|
}
|
|
|
|
private var emptyState: some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "film.stack").font(.largeTitle).foregroundStyle(.tertiary)
|
|
Text("No adult content in the library index.")
|
|
.font(.callout).foregroundStyle(.secondary)
|
|
Text("Rebuild the index in Setup once the media mount is reachable, or add titles via Downloads.")
|
|
.font(.caption).foregroundStyle(.tertiary).multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 220)
|
|
}
|
|
|
|
// MARK: actions
|
|
|
|
private func reload() async {
|
|
loading = true
|
|
await playlist.loadPornCollections()
|
|
loading = false
|
|
}
|
|
|
|
/// Load a fresh shuffle of the collection as the queue and fire it on the
|
|
/// active host. `applyPornCollection` marks the clips played and refreshes the
|
|
/// counts, so the card's "fresh" number drops immediately after.
|
|
private func play(_ col: PornCollection) async {
|
|
firing = col.name
|
|
await playlist.applyPornCollection(col.name, count: count)
|
|
playlist.play(on: player)
|
|
firing = nil
|
|
}
|
|
|
|
private func play(continue item: ContinueItem) {
|
|
guard let kind = player.activeKind,
|
|
let req = library.launchRequest(continue: item, targetKind: kind) else {
|
|
player.note("No player selected"); return
|
|
}
|
|
player.launch(req, series: item.show, resumeSeconds: item.positionSeconds)
|
|
}
|
|
|
|
private func play(show: CachedShow) {
|
|
guard let kind = player.activeKind,
|
|
let req = library.launchRequest(show: show, episode: nil, targetKind: kind) else {
|
|
player.note("No player selected"); return
|
|
}
|
|
player.launch(req, series: show.kind == .series ? show.name : nil, category: show.category)
|
|
}
|
|
}
|
|
#endif
|