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:
parent
cc5a3a5ce5
commit
d793d54dfb
5 changed files with 173 additions and 3 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue