feat(adult): Continue Watching last adult playlist + separate adult/non-adult playlist lanes

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>
This commit is contained in:
Natalie 2026-06-30 03:28:12 -04:00
parent cc5a3a5ce5
commit d793d54dfb
5 changed files with 173 additions and 3 deletions

View file

@ -28,6 +28,7 @@ struct AdultView: View {
var body: some View { var body: some View {
ScrollView { ScrollView {
VStack(alignment: .leading, spacing: 24) { VStack(alignment: .leading, spacing: 24) {
resumePlaylistCard
if library.switchToAdultOnlyHome { adultHomeRails } if library.switchToAdultOnlyHome { adultHomeRails }
collectionsSection collectionsSection
} }
@ -74,6 +75,37 @@ struct AdultView: View {
#endif #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 // MARK: adult-only Home rails
@ViewBuilder private var adultHomeRails: some View { @ViewBuilder private var adultHomeRails: some View {

View file

@ -310,6 +310,7 @@ struct GoonCollectionView: View {
private func playQueued() { private func playQueued() {
guard !queued.isEmpty else { return } guard !queued.isEmpty else { return }
ensureHost() ensureHost()
playlist.noteAdultPlaylistLabel(collection.name.capitalized)
playlist.play(on: player) playlist.play(on: player)
} }

View file

@ -61,7 +61,7 @@ struct HomeView: View {
ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) } ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) }
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let msg = player.actionMessage, !PlayerController.shouldDeferToOfflineCacheUI(msg) { if let msg = player.actionMessage {
VStack(spacing: 6) { VStack(spacing: 6) {
Text(msg).font(.callout) Text(msg).font(.callout)
if msg.contains("%") { if msg.contains("%") {
@ -82,7 +82,9 @@ struct HomeView: View {
} }
} }
.overlay(alignment: .bottom) { .overlay(alignment: .bottom) {
if let snap = playlist.recoveryPoint { // Non-adult only: an interrupted adult playlist returns on the Adult Home,
// never here (keeps adult titles off the main Home).
if let snap = playlist.recoveryPoint, !snap.isAdult {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "arrow.uturn.backward.circle.fill").foregroundStyle(.secondary) Image(systemName: "arrow.uturn.backward.circle.fill").foregroundStyle(.secondary)
Text("Interrupted “\(snap.label)").font(.callout).lineLimit(1) Text("Interrupted “\(snap.label)").font(.callout).lineLimit(1)

View file

@ -35,8 +35,59 @@ public struct QueueSnapshot: Sendable, Equatable {
if let resumePath, let item = items.first(where: { $0.path == resumePath }) { return item.title } if let resumePath, let item = items.first(where: { $0.path == resumePath }) { return item.title }
return items.first?.title ?? "previous queue" return items.first?.title ?? "previous queue"
} }
/// An all-adult snapshot used to route the recovery banner to the right
/// surface (adult interrupts belong on the Adult Home, never the main Home).
public var isAdult: Bool { !items.isEmpty && items.allSatisfy(\.isAdult) }
} }
#if ENABLE_ADULT
/// The last adult playlist that was fired, persisted so it can be resumed from the
/// Adult Home ("Continue Watching last adult playlist"). Kept in its OWN store
/// separate from the non-adult `QueueStore` (`play-queue.json`), which strips adult
/// by construction so the two lanes never bleed into one another.
public struct AdultPlaylistSnapshot: Sendable, Equatable, Codable {
/// Human label for the resume card (the collection name, capitalized).
public let label: String
/// The ordered clips of the playlist.
public let items: [QueueItem]
public init(label: String, items: [QueueItem]) {
self.label = label; self.items = items
}
public var count: Int { items.count }
}
/// Persists the last adult playlist to its own file. Unlike `QueueStore`, this lane
/// is meant to hold adult paths it never appears in the always-visible queue UI,
/// only on the gated Adult Home, so it's safe to write to disk here.
public enum AdultPlaylistStore {
private static var url: URL {
let base: URL
if let dir = ProcessInfo.processInfo.environment["TV_ANARCHY_STATE_DIR"], !dir.isEmpty {
base = URL(fileURLWithPath: dir, isDirectory: true)
} else {
base = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".local/state/tv-anarchy")
}
return base.appendingPathComponent("last-adult-playlist.json")
}
public static func load() -> AdultPlaylistSnapshot? {
guard let d = try? Data(contentsOf: url),
let snap = try? JSONDecoder().decode(AdultPlaylistSnapshot.self, from: d),
snap.items.allSatisfy(\.isAdult) else { return nil }
return snap
}
public static func save(_ snap: AdultPlaylistSnapshot?) {
guard let snap, !snap.items.isEmpty else {
try? FileManager.default.removeItem(at: url); return
}
guard let d = try? JSONEncoder().encode(snap) else { return }
try? FileManager.default.createDirectory(at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try? d.write(to: url, options: .atomic)
}
}
#endif
/// Boundary between Media Management (Library data, watch state via continueWatching) /// Boundary between Media Management (Library data, watch state via continueWatching)
/// and Playback Execution (viewer clients via enqueue/launch on PlayerController). /// and Playback Execution (viewer clients via enqueue/launch on PlayerController).
/// This class turns pure library episodes into queues for the playback piece. /// This class turns pure library episodes into queues for the playback piece.
@ -107,6 +158,15 @@ public final class PlaylistController {
/// affordance. Cleared once consumed or when a new interrupt overwrites it. /// affordance. Cleared once consumed or when a new interrupt overwrites it.
public private(set) var recoveryPoint: QueueSnapshot? public private(set) var recoveryPoint: QueueSnapshot?
public private(set) var smartPlaylists: [SmartPlaylist] = SmartPlaylistStore.load() public private(set) var smartPlaylists: [SmartPlaylist] = SmartPlaylistStore.load()
#if ENABLE_ADULT
/// The last adult playlist fired this device the "Continue Watching last adult
/// playlist" source on the Adult Home. Persisted in its own lane, distinct from
/// the (adult-stripped) non-adult queue. Nil until an adult playlist is played.
public private(set) var lastAdultPlaylist: AdultPlaylistSnapshot? = AdultPlaylistStore.load()
/// Label staged by an adult source (collection name) for the next adult fire,
/// consumed when `play(on:)` records the playlist. Reset after each record.
private var pendingAdultLabel: String?
#endif
private let library: LibraryController private let library: LibraryController
public init(library: LibraryController) { public init(library: LibraryController) {
self.library = library self.library = library
@ -274,11 +334,57 @@ public final class PlaylistController {
// MARK: firing // MARK: firing
public func play(on player: PlayerController) { public func play(on player: PlayerController) {
player.enqueuePlaylist(queue.map(\.path), adult: queueIsAdult) let adult = queueIsAdult
#if ENABLE_ADULT
if adult { recordAdultPlaylist() }
#endif
player.enqueuePlaylist(queue.map(\.path), adult: adult)
} }
private var queueIsAdult: Bool { !queue.isEmpty && queue.allSatisfy(\.isAdult) } private var queueIsAdult: Bool { !queue.isEmpty && queue.allSatisfy(\.isAdult) }
#if ENABLE_ADULT
// MARK: continue watching last adult playlist
/// Stage a label (the collection name) for the next adult playlist fire. Adult
/// sources call this just before `play(on:)` so the resume card reads well.
public func noteAdultPlaylistLabel(_ label: String) {
pendingAdultLabel = label
}
/// Remember the just-fired adult queue as the resumable "last adult playlist".
private func recordAdultPlaylist() {
let label = pendingAdultLabel?.isEmpty == false ? pendingAdultLabel! : "Adult playlist"
lastAdultPlaylist = AdultPlaylistSnapshot(label: label, items: queue)
AdultPlaylistStore.save(lastAdultPlaylist)
pendingAdultLabel = nil
}
/// Resume the last adult playlist on the active host. Skips clips already
/// finished (watch history), landing on the first unwatched clip and resuming it
/// at its saved position so it genuinely "continues" rather than restarting.
/// When the whole playlist has been watched, it replays from the top.
public func resumeAdultPlaylist(on player: PlayerController) {
guard let snap = lastAdultPlaylist, !snap.items.isEmpty else { return }
let played = library.playedPaths
var tail = snap.items
if let idx = tail.firstIndex(where: { !played.contains(MediaPaths.toRemote($0.path)) }) {
tail = Array(tail[idx...])
}
queue = tail.isEmpty ? snap.items : tail
persist() // no-op on disk (adult stripped) clears any stale non-adult queue file
guard let first = queue.first else { return }
let resume = library.resumePositions()[MediaPaths.toRemote(first.path)]
play(on: player, resumeFirst: resume)
}
/// Forget the last adult playlist (dismissing the resume card).
public func clearAdultPlaylist() {
lastAdultPlaylist = nil
AdultPlaylistStore.save(nil)
}
#endif
// MARK: unified playlist play from here, queue the rest // MARK: unified playlist play from here, queue the rest
/// Load a series into the queue starting at `startPath` (inclusive) through the /// Load a series into the queue starting at `startPath` (inclusive) through the
@ -393,6 +499,7 @@ public final class PlaylistController {
PornCollectionService.freshPaths(pool: pool, collection: name, count: count) PornCollectionService.freshPaths(pool: pool, collection: name, count: count)
}.value }.value
queue = paths.map { QueueItem(id: $0, title: Self.prettyPornTitle($0), path: $0) } queue = paths.map { QueueItem(id: $0, title: Self.prettyPornTitle($0), path: $0) }
noteAdultPlaylistLabel(name.capitalized) // label the resumable "last adult playlist"
persist() // no-op on disk (adult items are filtered out) keeps any prior non-adult queue clear persist() // no-op on disk (adult items are filtered out) keeps any prior non-adult queue clear
await loadPornCollections() await loadPornCollections()
} }

View file

@ -178,7 +178,35 @@ final class PlaylistTests: XCTestCase {
XCTAssertEqual(anime.map(\.name), ["A"]) XCTAssertEqual(anime.map(\.name), ["A"])
} }
func testQueueSnapshotIsAdultOnlyWhenAllAdult() {
let adult = QueueItem(id: "a", title: "clip", path: "/m/porn/clip.mp4")
let tv = QueueItem(id: "t", title: "ep", path: "/m/tv/ep.mkv")
XCTAssertTrue(QueueSnapshot(items: [adult], resumePath: nil, resumeSeconds: nil).isAdult)
XCTAssertFalse(QueueSnapshot(items: [adult, tv], resumePath: nil, resumeSeconds: nil).isAdult)
XCTAssertFalse(QueueSnapshot(items: [], resumePath: nil, resumeSeconds: nil).isAdult)
}
#if ENABLE_ADULT #if ENABLE_ADULT
func testAdultPlaylistStoreRoundTripsAndIsItsOwnLane() {
let items = [QueueItem(id: "1", title: "a", path: "/m/porn/a.mp4"),
QueueItem(id: "2", title: "b", path: "/m/porn/b.mp4")]
AdultPlaylistStore.save(AdultPlaylistSnapshot(label: "Goon", items: items))
let loaded = AdultPlaylistStore.load()
XCTAssertEqual(loaded?.label, "Goon")
XCTAssertEqual(loaded?.items.map(\.path), items.map(\.path)) // adult survives its own lane
// The non-adult queue file is untouched by the adult lane.
XCTAssertTrue(QueueStore.load().isEmpty)
AdultPlaylistStore.save(nil) // clearing removes the file
XCTAssertNil(AdultPlaylistStore.load())
}
func testAdultPlaylistStoreRejectsNonAdultPayload() {
// A tampered/legacy file holding a non-adult path must not load (belt-and-braces).
AdultPlaylistStore.save(AdultPlaylistSnapshot(
label: "x", items: [QueueItem(id: "t", title: "ep", path: "/m/tv/ep.mkv")]))
XCTAssertNil(AdultPlaylistStore.load())
}
func testPrettyPornTitleStripsScrapePrefix() { func testPrettyPornTitleStripsScrapePrefix() {
XCTAssertEqual( XCTAssertEqual(
PlaylistController.prettyPornTitle("/m/porn/EPORNER.COM - [9kO7IPk6qIG] Good Goon Mashup (1080).mp4"), PlaylistController.prettyPornTitle("/m/porn/EPORNER.COM - [9kO7IPk6qIG] Good Goon Mashup (1080).mp4"),