feat(adult): 🍿 collection detail view — clip checklist + offline download
Tapping a collection card on the Adult page now opens a detail view listing every clip in that collection (goon, pmv, etc) instead of silently firing the whole set at a host with nothing cached. Each row shows: - queued state as a tickable checklist (build a session clip by clip) - freshness / last-played - offline-cached state, with a per-clip download-to-offline button Plus a title filter (find e.g. a specific 'brain rot'/gooner clip), queue-all- fresh, download-all-queued-offline, and play-queued. Downloads land in the offline cache where the new star/trash row controls manage them. Quick-play the old fire path stays on the card context menu. Core: PornCollectionService.clips()/title() expose the full per-collection clip list with freshness; PlaylistController gains single-item checklist queue ops (isQueued/addToQueue/removeFromQueue) and pornClips(). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4a2ceb9781
commit
ee7efad888
4 changed files with 372 additions and 5 deletions
|
|
@ -20,6 +20,8 @@ struct AdultView: View {
|
||||||
@State private var loading = false
|
@State private var loading = false
|
||||||
/// The collection currently being fired (spinner + disable), nil when idle.
|
/// The collection currently being fired (spinner + disable), nil when idle.
|
||||||
@State private var firing: String?
|
@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)]
|
private let columns = [GridItem(.adaptive(minimum: 150), spacing: 12)]
|
||||||
|
|
||||||
|
|
@ -60,6 +62,9 @@ struct AdultView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.animation(.default, value: player.actionMessage)
|
.animation(.default, value: player.actionMessage)
|
||||||
|
.sheet(item: $detail) { col in
|
||||||
|
GoonCollectionView(collection: col, playlist: playlist, player: player, library: library)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func copyToClipboard(_ text: String) {
|
private func copyToClipboard(_ text: String) {
|
||||||
|
|
@ -145,7 +150,7 @@ struct AdultView: View {
|
||||||
}
|
}
|
||||||
|
|
||||||
private func collectionCard(_ col: PornCollection) -> some View {
|
private func collectionCard(_ col: PornCollection) -> some View {
|
||||||
Button { Task { await play(col) } } label: {
|
Button { detail = col } label: {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
HStack {
|
HStack {
|
||||||
Text(col.name.capitalized).font(.headline)
|
Text(col.name.capitalized).font(.headline)
|
||||||
|
|
@ -167,10 +172,16 @@ struct AdultView: View {
|
||||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 10))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.disabled(col.fresh == 0 || firing != nil)
|
.disabled(firing != nil)
|
||||||
.help(col.fresh == 0
|
.help("Open \(col.name.capitalized) — see all \(col.total) clips, tick what you want, download to offline or play")
|
||||||
? "Nothing fresh — everything here was played in the last \(PornCollectionService.defaultDays) days"
|
.contextMenu {
|
||||||
: "Play \(min(library.adultQueueCount, col.fresh)) fresh clip\(min(library.adultQueueCount, col.fresh) == 1 ? "" : "s") on \(selectedHostName)")
|
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 {
|
private var emptyState: some View {
|
||||||
|
|
|
||||||
280
Sources/TVAnarchy/GoonCollectionView.swift
Normal file
280
Sources/TVAnarchy/GoonCollectionView.swift
Normal file
|
|
@ -0,0 +1,280 @@
|
||||||
|
#if ENABLE_ADULT
|
||||||
|
import SwiftUI
|
||||||
|
#if canImport(AppKit)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
import TVAnarchyCore
|
||||||
|
|
||||||
|
/// Detail view for one adult collection (e.g. "goon"): lists every clip in the
|
||||||
|
/// collection, shows queued state as a checklist and offline-cached state, and
|
||||||
|
/// lets the user queue clips, download them to plum's offline cache, or play.
|
||||||
|
///
|
||||||
|
/// Opened by tapping a collection card on AdultView. This is what makes a
|
||||||
|
/// collection tap *do something* — instead of silently firing the whole set at a
|
||||||
|
/// host that may have nothing cached, you see the clips, tick the ones you want,
|
||||||
|
/// download them offline (managed by the star/trash offline cache), then play.
|
||||||
|
struct GoonCollectionView: View {
|
||||||
|
let collection: PornCollection
|
||||||
|
@Bindable var playlist: PlaylistController
|
||||||
|
@Bindable var player: PlayerController
|
||||||
|
@Bindable var library: LibraryController
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
@State private var clips: [PornClip] = []
|
||||||
|
@State private var loading = true
|
||||||
|
@State private var filter = ""
|
||||||
|
/// Paths with a download in flight (per-row spinner).
|
||||||
|
@State private var downloading: Set<String> = []
|
||||||
|
/// Paths confirmed present in the offline cache (seeded from the index, then
|
||||||
|
/// updated as downloads complete).
|
||||||
|
@State private var offlinePaths: Set<String> = []
|
||||||
|
|
||||||
|
private var shown: [PornClip] {
|
||||||
|
let q = filter.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
guard !q.isEmpty else { return clips }
|
||||||
|
return clips.filter { $0.title.lowercased().contains(q) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var queued: [PornClip] { clips.filter { playlist.isQueued(path: $0.path) } }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
header
|
||||||
|
Divider()
|
||||||
|
content
|
||||||
|
}
|
||||||
|
.frame(minWidth: 580, minHeight: 540)
|
||||||
|
.task { await load() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: header
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack(alignment: .firstTextBaseline) {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(collection.name.capitalized).font(.title2).bold()
|
||||||
|
Text(collection.desc).font(.caption).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Button { dismiss() } label: {
|
||||||
|
Image(systemName: "xmark.circle.fill").font(.title2).foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.keyboardShortcut(.cancelAction)
|
||||||
|
.help("Close")
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
stat("\(clips.count)", "clips")
|
||||||
|
Text("·").foregroundStyle(.tertiary)
|
||||||
|
stat("\(clips.filter(\.fresh).count)", "fresh")
|
||||||
|
Text("·").foregroundStyle(.tertiary)
|
||||||
|
stat("\(queued.count)", "queued", accent: !queued.isEmpty)
|
||||||
|
Text("·").foregroundStyle(.tertiary)
|
||||||
|
stat("\(offlinePaths.count)", "offline", accent: false)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
|
||||||
|
TextField("Filter clips (e.g. brain rot, gooner, hypno)…", text: $filter)
|
||||||
|
.textFieldStyle(.plain)
|
||||||
|
if !filter.isEmpty {
|
||||||
|
Button { filter = "" } label: { Image(systemName: "xmark.circle.fill") }
|
||||||
|
.buttonStyle(.plain).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(8)
|
||||||
|
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Button { queueAllFresh() } label: {
|
||||||
|
Label("Queue all fresh", systemImage: "plus.rectangle.on.rectangle")
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(clips.allSatisfy { !$0.fresh || playlist.isQueued(path: $0.path) })
|
||||||
|
|
||||||
|
Button { Task { await downloadQueued() } } label: {
|
||||||
|
Label("Download queued offline", systemImage: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.disabled(queued.allSatisfy { offlinePaths.contains($0.path) })
|
||||||
|
|
||||||
|
if !queued.isEmpty {
|
||||||
|
Button(role: .destructive) { clearQueued() } label: {
|
||||||
|
Label("Clear", systemImage: "xmark")
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
Button { playQueued() } label: {
|
||||||
|
Label("Play \(queued.count) queued", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
.controlSize(.small)
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.disabled(queued.isEmpty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stat(_ value: String, _ label: String, accent: Bool = false) -> some View {
|
||||||
|
(Text(value).font(.caption.bold()).foregroundStyle(accent ? Color.accentColor : Color.primary)
|
||||||
|
+ Text(" \(label)").font(.caption).foregroundStyle(.secondary))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: content
|
||||||
|
|
||||||
|
@ViewBuilder private var content: some View {
|
||||||
|
if loading {
|
||||||
|
ProgressView("Loading \(collection.name)…")
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else if shown.isEmpty {
|
||||||
|
ContentUnavailableView(
|
||||||
|
filter.isEmpty ? "No clips in \(collection.name)" : "No matches for “\(filter)”",
|
||||||
|
systemImage: "film.stack",
|
||||||
|
description: Text(filter.isEmpty
|
||||||
|
? "This collection has no clips in the current library index."
|
||||||
|
: "Try a different filter term.")
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
} else {
|
||||||
|
ScrollView {
|
||||||
|
LazyVStack(spacing: 0) {
|
||||||
|
ForEach(shown) { clip in
|
||||||
|
row(clip)
|
||||||
|
Divider()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func row(_ clip: PornClip) -> some View {
|
||||||
|
let isQueued = playlist.isQueued(path: clip.path)
|
||||||
|
let isOffline = offlinePaths.contains(clip.path)
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
Button { toggleQueue(clip) } label: {
|
||||||
|
Image(systemName: isQueued ? "checkmark.circle.fill" : "circle")
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(isQueued ? Color.accentColor : .secondary)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help(isQueued ? "Remove from queue" : "Add to queue")
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(clip.title).font(.callout).lineLimit(2)
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
if clip.fresh {
|
||||||
|
Label("fresh", systemImage: "sparkles")
|
||||||
|
.font(.caption2).foregroundStyle(.green)
|
||||||
|
} else if let d = clip.lastPlayed {
|
||||||
|
HStack(spacing: 3) {
|
||||||
|
Text("seen").font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
Text(d, style: .relative).font(.caption2).foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if isOffline {
|
||||||
|
Label("offline", systemImage: "internaldrive")
|
||||||
|
.font(.caption2).foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
|
||||||
|
if downloading.contains(clip.path) {
|
||||||
|
ProgressView().controlSize(.small)
|
||||||
|
} else if isOffline {
|
||||||
|
Image(systemName: "checkmark.circle")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.help("Cached in offline (manage with star/trash in Offline tab)")
|
||||||
|
} else {
|
||||||
|
Button { Task { await download(clip) } } label: {
|
||||||
|
Image(systemName: "arrow.down.circle")
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Download to plum offline cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
Button { play(clip) } label: {
|
||||||
|
Image(systemName: "play.fill").font(.caption)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.help("Play now on \(player.active?.name ?? "the selected host")")
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.contentShape(Rectangle())
|
||||||
|
.onTapGesture { toggleQueue(clip) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: actions
|
||||||
|
|
||||||
|
private func load() async {
|
||||||
|
loading = true
|
||||||
|
clips = await playlist.pornClips(collection: collection.name)
|
||||||
|
offlinePaths = Set(clips.filter { MediaPaths.localCopy(of: $0.path) != nil }.map(\.path))
|
||||||
|
loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func toggleQueue(_ clip: PornClip) {
|
||||||
|
if playlist.isQueued(path: clip.path) {
|
||||||
|
playlist.removeFromQueue(path: clip.path)
|
||||||
|
} else {
|
||||||
|
playlist.addToQueue(id: clip.path, title: clip.title, path: clip.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queueAllFresh() {
|
||||||
|
for c in clips where c.fresh && !playlist.isQueued(path: c.path) {
|
||||||
|
playlist.addToQueue(id: c.path, title: c.title, path: c.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func clearQueued() {
|
||||||
|
for c in queued { playlist.removeFromQueue(path: c.path) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func ensureHost() {
|
||||||
|
if player.active == nil { player.note("No player selected") }
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playQueued() {
|
||||||
|
guard !queued.isEmpty else { return }
|
||||||
|
ensureHost()
|
||||||
|
playlist.play(on: player)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Play one clip now without disturbing the built-up queue. Marks it played so
|
||||||
|
/// freshness advances, mirroring the collection-card fire path.
|
||||||
|
private func play(_ clip: PornClip) {
|
||||||
|
ensureHost()
|
||||||
|
PornCollectionService.markPlayed([clip.path])
|
||||||
|
player.launch(.file(path: clip.path), series: nil, adult: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func download(_ clip: PornClip) async {
|
||||||
|
guard !downloading.contains(clip.path), !offlinePaths.contains(clip.path) else { return }
|
||||||
|
downloading.insert(clip.path)
|
||||||
|
defer { downloading.remove(clip.path) }
|
||||||
|
let ok = await OfflineCacheController.fetchFile(path: clip.path, show: "porn") { msg in
|
||||||
|
player.note(msg)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
offlinePaths.insert(clip.path)
|
||||||
|
player.note("Saved offline: \(clip.title)")
|
||||||
|
} else {
|
||||||
|
player.note("Couldn’t download \(clip.title)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func downloadQueued() async {
|
||||||
|
let targets = queued.filter { !offlinePaths.contains($0.path) }
|
||||||
|
for c in targets { await download(c) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
@ -161,6 +161,26 @@ public final class PlaylistController {
|
||||||
}
|
}
|
||||||
public func clear() { queue.removeAll(); persist() }
|
public func clear() { queue.removeAll(); persist() }
|
||||||
|
|
||||||
|
// MARK: single-item checklist (collection detail view)
|
||||||
|
|
||||||
|
/// True when a file path is already in the live queue (checklist state).
|
||||||
|
public func isQueued(path: String) -> Bool { queue.contains { $0.path == path } }
|
||||||
|
|
||||||
|
/// Append one clip if not already queued (no replace). Used by the collection
|
||||||
|
/// detail checklist to build a session one clip at a time.
|
||||||
|
public func addToQueue(id: String, title: String, path: String) {
|
||||||
|
guard !isQueued(path: path) else { return }
|
||||||
|
queue.append(QueueItem(id: id, title: title, path: path))
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove every queue entry for a path (checklist un-tick).
|
||||||
|
public func removeFromQueue(path: String) {
|
||||||
|
let before = queue.count
|
||||||
|
queue.removeAll { $0.path == path }
|
||||||
|
if queue.count != before { persist() }
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: generators (replace the queue)
|
// MARK: generators (replace the queue)
|
||||||
|
|
||||||
/// Replace the queue from a one-tap generator. `limit` caps the queue length.
|
/// Replace the queue from a one-tap generator. `limit` caps the queue length.
|
||||||
|
|
@ -326,6 +346,15 @@ public final class PlaylistController {
|
||||||
await loadPornCollections()
|
await loadPornCollections()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Every clip in a collection (fresh + already-seen) for the detail checklist,
|
||||||
|
/// computed off the main actor over the same adult index pool the cards use.
|
||||||
|
public func pornClips(collection: String) async -> [PornClip] {
|
||||||
|
let pool = pornPool
|
||||||
|
return await Task.detached(priority: .utility) {
|
||||||
|
PornCollectionService.clips(pool: pool, collection: collection)
|
||||||
|
}.value
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip the "EPORNER.COM - [id]" scrape prefix + extension for a readable row.
|
/// Strip the "EPORNER.COM - [id]" scrape prefix + extension for a readable row.
|
||||||
static func prettyPornTitle(_ path: String) -> String {
|
static func prettyPornTitle(_ path: String) -> String {
|
||||||
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
|
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,20 @@ public struct PornCollection: Identifiable, Sendable, Equatable, Decodable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One clip in a collection's detail listing: its black-side path, a cleaned
|
||||||
|
/// display title, freshness (unplayed within the window), and last-played date.
|
||||||
|
/// Drives the per-collection checklist view (queue + offline download).
|
||||||
|
public struct PornClip: Identifiable, Sendable, Equatable, Hashable {
|
||||||
|
public let path: String
|
||||||
|
public let title: String
|
||||||
|
public let fresh: Bool
|
||||||
|
public let lastPlayed: Date?
|
||||||
|
public var id: String { path }
|
||||||
|
public init(path: String, title: String, fresh: Bool, lastPlayed: Date?) {
|
||||||
|
self.path = path; self.title = title; self.fresh = fresh; self.lastPlayed = lastPlayed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A named, virtual playlist over the porn pool: an *include* filter (match any
|
/// A named, virtual playlist over the porn pool: an *include* filter (match any
|
||||||
/// substring) with an optional *exclude*, layered on top of the global SKIP
|
/// substring) with an optional *exclude*, layered on top of the global SKIP
|
||||||
/// floor. A file can belong to several collections; nothing is moved on disk.
|
/// floor. A file can belong to several collections; nothing is moved on disk.
|
||||||
|
|
@ -193,6 +207,39 @@ public enum PornCollectionService {
|
||||||
for p in paths { log[(p as NSString).lastPathComponent] = stamp }
|
for p in paths { log[(p as NSString).lastPathComponent] = stamp }
|
||||||
PornPlayLog.save(log)
|
PornPlayLog.save(log)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cleaned display title for a clip path: drops the `EPORNER.COM - [id]`
|
||||||
|
/// scrape prefix and the extension. Mirrors PlaylistController.prettyPornTitle
|
||||||
|
/// so the collection detail list and the live queue read identically.
|
||||||
|
public static func title(forPath path: String) -> String {
|
||||||
|
var t = ((path as NSString).lastPathComponent as NSString).deletingPathExtension
|
||||||
|
if let r = t.range(of: #"^EPORNER\.COM - \[[0-9A-Za-z]+\]\s*"#, options: .regularExpression) {
|
||||||
|
t.removeSubrange(r)
|
||||||
|
}
|
||||||
|
return t.isEmpty ? "clip" : t
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every clip in a collection (not just the fresh ones), with per-file
|
||||||
|
/// freshness + last-played, fresh-first then alphabetical. Backs the detail
|
||||||
|
/// checklist so the user can see the whole collection and pick what to queue
|
||||||
|
/// or download offline. Pure over `pool` (the adult library index) + play-log.
|
||||||
|
public static func clips(pool: [String], collection: String, days: Int = defaultDays) -> [PornClip] {
|
||||||
|
guard let spec = specs.first(where: { $0.name == collection }) else { return [] }
|
||||||
|
let log = PornPlayLog.load()
|
||||||
|
let now = Date()
|
||||||
|
let inSpec = candidates(pool: pool)
|
||||||
|
.filter { inCollection(spec, basename: ($0 as NSString).lastPathComponent) }
|
||||||
|
let mapped = inSpec.map { p -> PornClip in
|
||||||
|
let base = (p as NSString).lastPathComponent
|
||||||
|
return PornClip(path: p, title: title(forPath: p),
|
||||||
|
fresh: isFresh(base, log: log, days: days, now: now),
|
||||||
|
lastPlayed: log[base].flatMap { ISO8601.date($0) })
|
||||||
|
}
|
||||||
|
return mapped.sorted { a, b in
|
||||||
|
if a.fresh != b.fresh { return a.fresh && !b.fresh }
|
||||||
|
return a.title.localizedCaseInsensitiveCompare(b.title) == .orderedAscending
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-file porn play-log (basename → last-played ISO). App-owned at
|
/// Per-file porn play-log (basename → last-played ISO). App-owned at
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue