Surface the existing pin (keep-from-cull) and per-file delete actions as visible inline buttons on each offline cache row instead of context-menu-only: a star toggles protection from auto-cull (and restore-if-missing), a trash culls that file early. Aligns wording/icons to the star metaphor. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
322 lines
18 KiB
Swift
322 lines
18 KiB
Swift
import Foundation
|
||
|
||
/// Where playback goes: stream from a remote host or play local offline copies.
|
||
/// Persisted so relaunch restores the last choice; the toolbar picker and manual
|
||
/// host chips stay in sync.
|
||
public enum PlaybackMode: String, Codable, Sendable, CaseIterable, Identifiable {
|
||
case stream
|
||
case offline
|
||
|
||
public var id: String { rawValue }
|
||
public var label: String {
|
||
switch self {
|
||
case .stream: "Stream"
|
||
case .offline: "Offline"
|
||
}
|
||
}
|
||
public var help: String {
|
||
switch self {
|
||
case .stream: "On a local player, fetch from the server on demand; remote hosts always stream"
|
||
case .offline: "On a local player, only play files already in offline cache"
|
||
}
|
||
}
|
||
}
|
||
|
||
/// User-facing app preferences that aren't host/franchise specific. Persisted to
|
||
/// `~/.local/state/tv-anarchy/settings.json`. Kept deliberately small — one file,
|
||
/// tolerant decode, sensible defaults — so adding a preference is one field here
|
||
/// plus its wiring, no migration.
|
||
public struct AppSettings: Codable, Sendable, Equatable {
|
||
/// Master runtime switch for the whole adult feature (the runtime peer of the
|
||
/// `ENABLE_ADULT` compile flag). False by default — the app ships with adult
|
||
/// content concealed. Flipped by the discreet "hidden" eye icon in the
|
||
/// sidebar; when true, the Adult tab appears and the porn collections become
|
||
/// available in the queue popover. The two settings below only take effect
|
||
/// while this is on.
|
||
public var pornFeature: Bool
|
||
/// When false (the default), the Home screen hides everything from the `porn`
|
||
/// media dir — its category rails, plus any adult item in Continue Watching or
|
||
/// Recently Added. Independent of the Library tab's own porn browse toggle, so
|
||
/// you can deliberately browse adult content in Library without it leaking onto
|
||
/// the landing screen.
|
||
public var surfaceAdultOnHome: Bool
|
||
/// When true, the Adult tab is a full adult-only Home — Continue Watching and
|
||
/// Recently Added rails (adult-only) above the collections grid — rather than
|
||
/// just the collections browser. Independent of `surfaceAdultOnHome`, which
|
||
/// governs the *main* Home.
|
||
public var switchToAdultOnlyHome: Bool
|
||
/// When true, hovering a poster plays a short muted preview clip (built on
|
||
/// black, seeked past the intro, cached). Off by default — it triggers ffmpeg
|
||
/// work on black, so it's opt-in.
|
||
public var hoverPreviews: Bool
|
||
/// When true (the default), play/pause/next/previous/seek from the Mac's system
|
||
/// transport — hardware media keys (F7–F9), Control Center, lock screen, AirPods
|
||
/// — drives whatever TVAnarchy is playing, and Now Playing reflects it. Turn off
|
||
/// to let those keys fall through to other apps. Independent of `forwardVolumeKeys`.
|
||
public var forwardMediaKeys: Bool
|
||
/// When true (the default), volume up/down media keys adjust the active player
|
||
/// while something is playing instead of the Mac's system volume. Turn off to
|
||
/// always use system volume. Independent of `forwardMediaKeys`.
|
||
public var forwardVolumeKeys: Bool
|
||
/// When true (default), dynamically combine split/duplicate library entries of
|
||
/// one show into a single show via the cheap clusterer + the local-LLM reasoner
|
||
/// (the Dandadan case). Decisions are cached on disk, so it runs once per cluster.
|
||
public var combineSplitShows: Bool
|
||
/// When true, use the local MLX LLM (a separate download) to reason over
|
||
/// ambiguous show clusters. Off by default — the shipped `DeterministicGrouper`
|
||
/// (year + episode/season structure, zero MB) handles the real cases; the LLM is
|
||
/// an optional enhancement for the rare same-year ambiguous tail.
|
||
public var useLLMGrouper: Bool
|
||
/// Notify (Notification Center + in-app banner) when a download is ready to watch
|
||
/// or gets stuck. On by default.
|
||
public var notifyDownloads: Bool
|
||
/// Show the Continue Watching rail on Home (and the adult-only Home when enabled).
|
||
public var showContinueWatchingOnHome: Bool
|
||
/// Show the Recently Added rail on Home (and the adult-only Home when enabled).
|
||
public var showRecentlyAddedOnHome: Bool
|
||
/// Seek past the opening when you tap Skip Intro (seconds from start). 0 = off.
|
||
public var skipIntroSeconds: Int
|
||
/// How many fresh clips an Adult collection tap queues (5–100).
|
||
public var adultQueueCount: Int
|
||
/// Stream (remote host) vs offline (local downloaded copies). Drives the
|
||
/// toolbar mode picker and which host `PlayerController` selects on launch.
|
||
public var playbackMode: PlaybackMode
|
||
/// Playback mode for adult content only — independent of `playbackMode`, so you
|
||
/// can stream collections on a remote host while regular shows stay on offline cache.
|
||
public var adultPlaybackMode: PlaybackMode
|
||
/// CGDirectDisplayID for local playback output. nil = auto (external TV when
|
||
/// connected, else built-in). Mirrors VLC's `macosx-vdev` when using VLC.
|
||
public var playbackDisplayId: UInt32?
|
||
/// Last device the user picked in the Host selector. Restored on launch so MCP
|
||
/// / a relaunch doesn't silently revert to a different player.
|
||
public var activePlayerId: String?
|
||
/// Library folder → type id (`tv`/`anime`/`movies`/`porn`/…). Empty = identity
|
||
/// (a folder's type is its own name, the historical convention). Overriding
|
||
/// lets a differently-named folder be classified, and — because `porn` is the
|
||
/// adult type — lets any folder be marked adult without renaming it on disk.
|
||
public var folderTypes: [String: String]
|
||
/// The configurable type catalog — the editable, expandable default the folder
|
||
/// picker draws from. Seeded with `LibraryTypes.defaults`; the user can rename,
|
||
/// add, remove, or flag types adult.
|
||
public var libraryTypes: [LibraryType]
|
||
/// Visual theme — standard dark UI or Winamp-style skins.
|
||
public var appTheme: AppTheme
|
||
/// SHA256 of an installed `.wsz` skin cache — nil = built-in palette only.
|
||
public var winampSkinId: String?
|
||
/// Display name for `winampSkinId` (shown in Settings).
|
||
public var winampSkinName: String?
|
||
|
||
// v1 Download: BandwidthPolicy options (user > friends > public tiers). See BandwidthPolicy.swift.
|
||
public var serveFriendsWhenIdle: Bool
|
||
public var seedPublicWhenIdle: Bool
|
||
public var totalUploadKBps: Int? // nil = unmetered
|
||
|
||
public init(pornFeature: Bool = false, surfaceAdultOnHome: Bool = false,
|
||
switchToAdultOnlyHome: Bool = false, hoverPreviews: Bool = false,
|
||
forwardMediaKeys: Bool = true, forwardVolumeKeys: Bool = true,
|
||
combineSplitShows: Bool = true, useLLMGrouper: Bool = false,
|
||
notifyDownloads: Bool = true,
|
||
showContinueWatchingOnHome: Bool = true, showRecentlyAddedOnHome: Bool = true,
|
||
skipIntroSeconds: Int = 90, adultQueueCount: Int = 25,
|
||
playbackMode: PlaybackMode = .stream,
|
||
adultPlaybackMode: PlaybackMode = .stream,
|
||
playbackDisplayId: UInt32? = nil,
|
||
activePlayerId: String? = nil,
|
||
folderTypes: [String: String] = [:],
|
||
libraryTypes: [LibraryType] = LibraryTypes.defaults,
|
||
appTheme: AppTheme = .standard,
|
||
winampSkinId: String? = nil,
|
||
winampSkinName: String? = nil,
|
||
serveFriendsWhenIdle: Bool = true,
|
||
seedPublicWhenIdle: Bool = true,
|
||
totalUploadKBps: Int? = nil) {
|
||
self.pornFeature = pornFeature
|
||
self.surfaceAdultOnHome = surfaceAdultOnHome
|
||
self.switchToAdultOnlyHome = switchToAdultOnlyHome
|
||
self.hoverPreviews = hoverPreviews
|
||
self.forwardMediaKeys = forwardMediaKeys
|
||
self.forwardVolumeKeys = forwardVolumeKeys
|
||
self.combineSplitShows = combineSplitShows
|
||
self.useLLMGrouper = useLLMGrouper
|
||
self.notifyDownloads = notifyDownloads
|
||
self.showContinueWatchingOnHome = showContinueWatchingOnHome
|
||
self.showRecentlyAddedOnHome = showRecentlyAddedOnHome
|
||
self.skipIntroSeconds = skipIntroSeconds
|
||
self.adultQueueCount = adultQueueCount
|
||
self.playbackMode = playbackMode
|
||
self.adultPlaybackMode = adultPlaybackMode
|
||
self.playbackDisplayId = playbackDisplayId
|
||
self.activePlayerId = activePlayerId
|
||
self.folderTypes = folderTypes
|
||
self.libraryTypes = libraryTypes
|
||
self.appTheme = appTheme
|
||
self.winampSkinId = winampSkinId
|
||
self.winampSkinName = winampSkinName
|
||
self.serveFriendsWhenIdle = serveFriendsWhenIdle
|
||
self.seedPublicWhenIdle = seedPublicWhenIdle
|
||
self.totalUploadKBps = totalUploadKBps
|
||
}
|
||
|
||
public init(from decoder: Decoder) throws {
|
||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||
pornFeature = try c.decodeIfPresent(Bool.self, forKey: .pornFeature) ?? false
|
||
surfaceAdultOnHome = try c.decodeIfPresent(Bool.self, forKey: .surfaceAdultOnHome) ?? false
|
||
switchToAdultOnlyHome = try c.decodeIfPresent(Bool.self, forKey: .switchToAdultOnlyHome) ?? false
|
||
hoverPreviews = try c.decodeIfPresent(Bool.self, forKey: .hoverPreviews) ?? false
|
||
forwardMediaKeys = try c.decodeIfPresent(Bool.self, forKey: .forwardMediaKeys) ?? true
|
||
forwardVolumeKeys = try c.decodeIfPresent(Bool.self, forKey: .forwardVolumeKeys) ?? true
|
||
combineSplitShows = try c.decodeIfPresent(Bool.self, forKey: .combineSplitShows) ?? true
|
||
useLLMGrouper = try c.decodeIfPresent(Bool.self, forKey: .useLLMGrouper) ?? false
|
||
notifyDownloads = try c.decodeIfPresent(Bool.self, forKey: .notifyDownloads) ?? true
|
||
showContinueWatchingOnHome = try c.decodeIfPresent(Bool.self, forKey: .showContinueWatchingOnHome) ?? true
|
||
showRecentlyAddedOnHome = try c.decodeIfPresent(Bool.self, forKey: .showRecentlyAddedOnHome) ?? true
|
||
skipIntroSeconds = try c.decodeIfPresent(Int.self, forKey: .skipIntroSeconds) ?? 90
|
||
adultQueueCount = try c.decodeIfPresent(Int.self, forKey: .adultQueueCount) ?? 25
|
||
playbackMode = try c.decodeIfPresent(PlaybackMode.self, forKey: .playbackMode) ?? .stream
|
||
adultPlaybackMode = try c.decodeIfPresent(PlaybackMode.self, forKey: .adultPlaybackMode) ?? .stream
|
||
playbackDisplayId = try c.decodeIfPresent(UInt32.self, forKey: .playbackDisplayId)
|
||
activePlayerId = try c.decodeIfPresent(String.self, forKey: .activePlayerId)
|
||
folderTypes = try c.decodeIfPresent([String: String].self, forKey: .folderTypes) ?? [:]
|
||
// Absent/empty → the default catalog, so an old settings file (or one that
|
||
// never edited types) gets the full set rather than an empty picker.
|
||
let decoded = try c.decodeIfPresent([LibraryType].self, forKey: .libraryTypes) ?? []
|
||
libraryTypes = decoded.isEmpty ? LibraryTypes.defaults : decoded
|
||
appTheme = try c.decodeIfPresent(AppTheme.self, forKey: .appTheme) ?? .standard
|
||
winampSkinId = try c.decodeIfPresent(String.self, forKey: .winampSkinId)
|
||
winampSkinName = try c.decodeIfPresent(String.self, forKey: .winampSkinName)
|
||
serveFriendsWhenIdle = try c.decodeIfPresent(Bool.self, forKey: .serveFriendsWhenIdle) ?? true
|
||
seedPublicWhenIdle = try c.decodeIfPresent(Bool.self, forKey: .seedPublicWhenIdle) ?? true
|
||
totalUploadKBps = try c.decodeIfPresent(Int.self, forKey: .totalUploadKBps)
|
||
}
|
||
}
|
||
|
||
/// Partial update for app settings — used by AppLocalAPI and MCP patches.
|
||
public struct AppSettingsPatch: Codable, Sendable, Equatable {
|
||
public var pornFeature: Bool?
|
||
public var surfaceAdultOnHome: Bool?
|
||
public var switchToAdultOnlyHome: Bool?
|
||
public var hoverPreviews: Bool?
|
||
public var forwardMediaKeys: Bool?
|
||
public var forwardVolumeKeys: Bool?
|
||
public var combineSplitShows: Bool?
|
||
public var useLLMGrouper: Bool?
|
||
public var notifyDownloads: Bool?
|
||
public var showContinueWatchingOnHome: Bool?
|
||
public var showRecentlyAddedOnHome: Bool?
|
||
public var skipIntroSeconds: Int?
|
||
public var adultQueueCount: Int?
|
||
public var playbackMode: PlaybackMode?
|
||
public var adultPlaybackMode: PlaybackMode?
|
||
public var playbackDisplayId: UInt32?
|
||
public var activePlayerId: String?
|
||
public var folderTypes: [String: String]?
|
||
public var libraryTypes: [LibraryType]?
|
||
public var appTheme: AppTheme?
|
||
public var winampSkinId: String?
|
||
public var winampSkinName: String?
|
||
public var serveFriendsWhenIdle: Bool?
|
||
public var seedPublicWhenIdle: Bool?
|
||
public var totalUploadKBps: Int?
|
||
|
||
public init(pornFeature: Bool? = nil, surfaceAdultOnHome: Bool? = nil,
|
||
switchToAdultOnlyHome: Bool? = nil, hoverPreviews: Bool? = nil,
|
||
forwardMediaKeys: Bool? = nil, forwardVolumeKeys: Bool? = nil,
|
||
combineSplitShows: Bool? = nil,
|
||
useLLMGrouper: Bool? = nil, notifyDownloads: Bool? = nil,
|
||
showContinueWatchingOnHome: Bool? = nil, showRecentlyAddedOnHome: Bool? = nil,
|
||
skipIntroSeconds: Int? = nil, adultQueueCount: Int? = nil,
|
||
playbackMode: PlaybackMode? = nil,
|
||
adultPlaybackMode: PlaybackMode? = nil,
|
||
playbackDisplayId: UInt32? = nil,
|
||
activePlayerId: String? = nil,
|
||
folderTypes: [String: String]? = nil, libraryTypes: [LibraryType]? = nil,
|
||
appTheme: AppTheme? = nil,
|
||
winampSkinId: String? = nil, winampSkinName: String? = nil) {
|
||
self.pornFeature = pornFeature
|
||
self.surfaceAdultOnHome = surfaceAdultOnHome
|
||
self.switchToAdultOnlyHome = switchToAdultOnlyHome
|
||
self.hoverPreviews = hoverPreviews
|
||
self.forwardMediaKeys = forwardMediaKeys
|
||
self.forwardVolumeKeys = forwardVolumeKeys
|
||
self.combineSplitShows = combineSplitShows
|
||
self.useLLMGrouper = useLLMGrouper
|
||
self.notifyDownloads = notifyDownloads
|
||
self.showContinueWatchingOnHome = showContinueWatchingOnHome
|
||
self.showRecentlyAddedOnHome = showRecentlyAddedOnHome
|
||
self.skipIntroSeconds = skipIntroSeconds
|
||
self.adultQueueCount = adultQueueCount
|
||
self.playbackMode = playbackMode
|
||
self.adultPlaybackMode = adultPlaybackMode
|
||
self.playbackDisplayId = playbackDisplayId
|
||
self.activePlayerId = activePlayerId
|
||
self.folderTypes = folderTypes
|
||
self.libraryTypes = libraryTypes
|
||
self.appTheme = appTheme
|
||
self.winampSkinId = winampSkinId
|
||
self.winampSkinName = winampSkinName
|
||
}
|
||
|
||
public mutating func apply(to settings: inout AppSettings) {
|
||
if let v = pornFeature { settings.pornFeature = v }
|
||
if let v = surfaceAdultOnHome { settings.surfaceAdultOnHome = v }
|
||
if let v = switchToAdultOnlyHome { settings.switchToAdultOnlyHome = v }
|
||
if let v = hoverPreviews { settings.hoverPreviews = v }
|
||
if let v = forwardMediaKeys { settings.forwardMediaKeys = v }
|
||
if let v = forwardVolumeKeys { settings.forwardVolumeKeys = v }
|
||
if let v = combineSplitShows { settings.combineSplitShows = v }
|
||
if let v = useLLMGrouper { settings.useLLMGrouper = v }
|
||
if let v = notifyDownloads { settings.notifyDownloads = v }
|
||
if let v = showContinueWatchingOnHome { settings.showContinueWatchingOnHome = v }
|
||
if let v = showRecentlyAddedOnHome { settings.showRecentlyAddedOnHome = v }
|
||
if let v = skipIntroSeconds { settings.skipIntroSeconds = v }
|
||
if let v = adultQueueCount { settings.adultQueueCount = min(100, max(5, v)) }
|
||
if let v = playbackMode { settings.playbackMode = v }
|
||
if let v = adultPlaybackMode { settings.adultPlaybackMode = v }
|
||
if let v = playbackDisplayId { settings.playbackDisplayId = v }
|
||
if let v = activePlayerId { settings.activePlayerId = v.isEmpty ? nil : v }
|
||
if let v = folderTypes { settings.folderTypes = v }
|
||
if let v = libraryTypes, !v.isEmpty { settings.libraryTypes = v }
|
||
if let v = appTheme { settings.appTheme = v }
|
||
if let v = winampSkinId { settings.winampSkinId = v.isEmpty ? nil : v }
|
||
if let v = winampSkinName { settings.winampSkinName = v.isEmpty ? nil : v }
|
||
if let v = serveFriendsWhenIdle { settings.serveFriendsWhenIdle = v }
|
||
if let v = seedPublicWhenIdle { settings.seedPublicWhenIdle = v }
|
||
if let v = totalUploadKBps { settings.totalUploadKBps = v }
|
||
}
|
||
}
|
||
|
||
public enum SettingsStore {
|
||
/// State directory, overridable via `TV_ANARCHY_STATE_DIR` (mirrors
|
||
/// PlaylistController's stores) so tests redirect to a temp dir and never touch
|
||
/// the user's real settings.
|
||
public static func settingsURL() -> 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("settings.json")
|
||
}
|
||
|
||
public static func load() -> AppSettings {
|
||
guard let d = try? Data(contentsOf: settingsURL()),
|
||
let s = try? JSONDecoder().decode(AppSettings.self, from: d) else {
|
||
let s = AppSettings(); LibraryConfig.update(folderTypes: s.folderTypes, types: s.libraryTypes); return s
|
||
}
|
||
LibraryConfig.update(folderTypes: s.folderTypes, types: s.libraryTypes) // keep the in-memory config in sync
|
||
return s
|
||
}
|
||
|
||
public static func save(_ s: AppSettings) {
|
||
// Update the cache from the SAME struct being persisted — the single
|
||
// chokepoint, so the folder→type config can never desync from disk.
|
||
LibraryConfig.update(folderTypes: s.folderTypes, types: s.libraryTypes)
|
||
guard let d = try? JSONEncoder().encode(s) else { return }
|
||
let path = settingsURL()
|
||
try? FileManager.default.createDirectory(at: path.deletingLastPathComponent(),
|
||
withIntermediateDirectories: true)
|
||
try? d.write(to: path, options: .atomic)
|
||
}
|
||
}
|