tv-anarchy/Sources/TVAnarchyCore/Library/SettingsStore.swift
Natalie 4a2ceb9781 feat(offline): inline star-to-keep and trash-to-cull on cache rows
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>
2026-06-30 00:12:41 -04:00

322 lines
18 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (F7F9), 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 (5100).
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 foldertype 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)
}
}