The Adult Home now mirrors the main Home's resume affordance: the last adult collection playlist that was fired is persisted to its own lane and surfaced as a "Continue Watching" card that re-queues it on the active host, skipping clips already finished and resuming the first unwatched one at its saved position. Separation: adult playlists get a dedicated AdultPlaylistStore (last-adult-playlist.json), distinct from the adult-stripped non-adult QueueStore (play-queue.json), so the two lanes never bleed together. The main Home's interrupt-recovery banner is filtered to non-adult snapshots, keeping adult titles off the regular Home. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
269 lines
12 KiB
Swift
269 lines
12 KiB
Swift
#if ENABLE_ADULT
|
|
import SwiftUI
|
|
#if canImport(AppKit)
|
|
import AppKit
|
|
#endif
|
|
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
|
|
|
|
@State private var loading = false
|
|
/// The collection currently being fired (spinner + disable), nil when idle.
|
|
@State private var firing: String?
|
|
/// Collection whose detail checklist sheet is open (tap a card to set it).
|
|
@State private var detail: PornCollection?
|
|
|
|
private let columns = [GridItem(.adaptive(minimum: 150), spacing: 12)]
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 24) {
|
|
resumePlaylistCard
|
|
if library.switchToAdultOnlyHome { adultHomeRails }
|
|
collectionsSection
|
|
}
|
|
.padding(24)
|
|
}
|
|
.navigationTitle("Adult")
|
|
.toolbar { ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true, showShowsMode: false) } }
|
|
.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))
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if let msg = player.actionMessage {
|
|
Text(msg).font(.callout)
|
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
|
.padding(.bottom, 16)
|
|
.onTapGesture { copyToClipboard(msg) }.help("Click to copy")
|
|
.task(id: msg) {
|
|
let secs = msg.contains("not downloaded") ? 12.0 : 4.0
|
|
try? await Task.sleep(for: .seconds(secs))
|
|
player.note(nil)
|
|
}
|
|
}
|
|
}
|
|
.animation(.default, value: player.actionMessage)
|
|
.sheet(item: $detail) { col in
|
|
GoonCollectionView(collection: col, playlist: playlist, player: player, library: library)
|
|
}
|
|
}
|
|
|
|
private func copyToClipboard(_ text: String) {
|
|
#if canImport(AppKit)
|
|
NSPasteboard.general.clearContents()
|
|
NSPasteboard.general.setString(text, forType: .string)
|
|
#endif
|
|
}
|
|
|
|
// MARK: continue watching — last adult playlist
|
|
|
|
/// "Continue Watching" the last adult playlist: resumes the same shuffled
|
|
/// collection queue on the active host, picking up at the first clip you hadn't
|
|
/// finished. The adult counterpart to the main Home's recovery banner — its own
|
|
/// persisted lane, so it survives relaunch and never surfaces on the main Home.
|
|
@ViewBuilder private var resumePlaylistCard: some View {
|
|
if let snap = playlist.lastAdultPlaylist {
|
|
Button { playlist.resumeAdultPlaylist(on: player) } label: {
|
|
HStack(spacing: 14) {
|
|
Image(systemName: "play.circle.fill")
|
|
.font(.system(size: 34)).foregroundStyle(.tint)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Continue Watching").font(.caption).foregroundStyle(.secondary)
|
|
Text(snap.label).font(.headline)
|
|
Text("Resume your last playlist · \(snap.count) clips")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
Button { playlist.clearAdultPlaylist() } label: { Image(systemName: "xmark") }
|
|
.buttonStyle(.plain).foregroundStyle(.secondary).help("Forget this playlist")
|
|
}
|
|
.padding(14)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 12))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Resume “\(snap.label)” on \(selectedHostName)")
|
|
}
|
|
}
|
|
|
|
// MARK: adult-only Home rails
|
|
|
|
@ViewBuilder private var adultHomeRails: some View {
|
|
if library.showContinueWatchingOnHome {
|
|
let continuing = library.adultContinueWatching
|
|
if !continuing.isEmpty {
|
|
rail("Continue Watching") {
|
|
ForEach(continuing) { item in
|
|
Button { play(continue: item) } label: { ContinueCard(item: item) }
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if library.showRecentlyAddedOnHome {
|
|
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 on the selected host (\(selectedHostName)). Stream/Offline below controls whether a local player fetches on demand or requires cached files. Counts and freshness are tracked per file, so nothing replays across collections.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
HStack(spacing: 14) {
|
|
Picker("Adult sourcing", selection: $library.adultPlaybackMode) {
|
|
ForEach(PlaybackMode.allCases) { mode in
|
|
Text(mode.label).tag(mode)
|
|
}
|
|
}
|
|
.pickerStyle(.segmented)
|
|
.frame(maxWidth: 148)
|
|
.help("On a local player: Stream fetches from the server; Offline requires cached files. Remote hosts always stream.")
|
|
Stepper("Queue \(library.adultQueueCount) clips",
|
|
value: $library.adultQueueCount, in: 5...100, step: 5)
|
|
.fixedSize()
|
|
.help("How many fresh clips each collection tap queues — saved in settings.json")
|
|
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 { detail = 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(firing != nil)
|
|
.help("Open \(col.name.capitalized) — see all \(col.total) clips, tick what you want, download to offline or play")
|
|
.contextMenu {
|
|
Button("Open clip list", systemImage: "list.bullet") { detail = col }
|
|
if col.fresh > 0 {
|
|
Button("Quick-play \(min(library.adultQueueCount, col.fresh)) fresh", systemImage: "play.fill") {
|
|
Task { await play(col) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
private var selectedHostName: String {
|
|
player.active?.name ?? "selected host"
|
|
}
|
|
|
|
// 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: library.adultQueueCount)
|
|
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, adult: true)
|
|
}
|
|
|
|
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,
|
|
adult: true)
|
|
}
|
|
}
|
|
#endif
|