From d793d54dfb194fc84f2a00f6e15eaa1a5a880099 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 30 Jun 2026 03:28:12 -0400 Subject: [PATCH] =?UTF-8?q?feat(adult):=20=E2=9C=A8=20Continue=20Watching?= =?UTF-8?q?=20last=20adult=20playlist=20+=20separate=20adult/non-adult=20p?= =?UTF-8?q?laylist=20lanes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- Sources/TVAnarchy/AdultView.swift | 32 +++++ Sources/TVAnarchy/GoonCollectionView.swift | 1 + Sources/TVAnarchy/HomeView.swift | 6 +- .../TVAnarchyCore/PlaylistController.swift | 109 +++++++++++++++++- Tests/TVAnarchyCoreTests/PlaylistTests.swift | 28 +++++ 5 files changed, 173 insertions(+), 3 deletions(-) diff --git a/Sources/TVAnarchy/AdultView.swift b/Sources/TVAnarchy/AdultView.swift index 5aa835d..05f03ea 100644 --- a/Sources/TVAnarchy/AdultView.swift +++ b/Sources/TVAnarchy/AdultView.swift @@ -28,6 +28,7 @@ struct AdultView: View { var body: some View { ScrollView { VStack(alignment: .leading, spacing: 24) { + resumePlaylistCard if library.switchToAdultOnlyHome { adultHomeRails } collectionsSection } @@ -74,6 +75,37 @@ struct AdultView: View { #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 { diff --git a/Sources/TVAnarchy/GoonCollectionView.swift b/Sources/TVAnarchy/GoonCollectionView.swift index f538a71..c1017c1 100644 --- a/Sources/TVAnarchy/GoonCollectionView.swift +++ b/Sources/TVAnarchy/GoonCollectionView.swift @@ -310,6 +310,7 @@ struct GoonCollectionView: View { private func playQueued() { guard !queued.isEmpty else { return } ensureHost() + playlist.noteAdultPlaylistLabel(collection.name.capitalized) playlist.play(on: player) } diff --git a/Sources/TVAnarchy/HomeView.swift b/Sources/TVAnarchy/HomeView.swift index 688497b..57411ac 100644 --- a/Sources/TVAnarchy/HomeView.swift +++ b/Sources/TVAnarchy/HomeView.swift @@ -61,7 +61,7 @@ struct HomeView: View { ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) } } .overlay(alignment: .bottom) { - if let msg = player.actionMessage, !PlayerController.shouldDeferToOfflineCacheUI(msg) { + if let msg = player.actionMessage { VStack(spacing: 6) { Text(msg).font(.callout) if msg.contains("%") { @@ -82,7 +82,9 @@ struct HomeView: View { } } .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) { Image(systemName: "arrow.uturn.backward.circle.fill").foregroundStyle(.secondary) Text("Interrupted “\(snap.label)”").font(.callout).lineLimit(1) diff --git a/Sources/TVAnarchyCore/PlaylistController.swift b/Sources/TVAnarchyCore/PlaylistController.swift index b7e70ca..0d97c7c 100644 --- a/Sources/TVAnarchyCore/PlaylistController.swift +++ b/Sources/TVAnarchyCore/PlaylistController.swift @@ -35,8 +35,59 @@ public struct QueueSnapshot: Sendable, Equatable { if let resumePath, let item = items.first(where: { $0.path == resumePath }) { return item.title } 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) /// and Playback Execution (viewer clients via enqueue/launch on PlayerController). /// 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. public private(set) var recoveryPoint: QueueSnapshot? 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 public init(library: LibraryController) { self.library = library @@ -274,11 +334,57 @@ public final class PlaylistController { // MARK: firing 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) } + #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 /// 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) }.value 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 await loadPornCollections() } diff --git a/Tests/TVAnarchyCoreTests/PlaylistTests.swift b/Tests/TVAnarchyCoreTests/PlaylistTests.swift index 5e51744..0a62258 100644 --- a/Tests/TVAnarchyCoreTests/PlaylistTests.swift +++ b/Tests/TVAnarchyCoreTests/PlaylistTests.swift @@ -178,7 +178,35 @@ final class PlaylistTests: XCTestCase { 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 + 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() { XCTAssertEqual( PlaylistController.prettyPornTitle("/m/porn/EPORNER.COM - [9kO7IPk6qIG] Good Goon Mashup (1080).mp4"),