tv-anarchy/Sources/TVAnarchy/AdultView.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

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