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>
363 lines
17 KiB
Swift
363 lines
17 KiB
Swift
import SwiftUI
|
|
import TVAnarchyCore
|
|
|
|
struct RootView: View {
|
|
enum Section: String, CaseIterable, Identifiable {
|
|
case home = "Home"
|
|
case player = "Player"
|
|
case library = "Library"
|
|
case search = "Search"
|
|
case downloads = "Downloads"
|
|
case offline = "Offline"
|
|
case metadata = "Metadata"
|
|
#if ENABLE_ADULT
|
|
case adult = "Adult"
|
|
#endif
|
|
case thisMac = "This Mac"
|
|
case devices = "Devices"
|
|
case logs = "Logs"
|
|
case setup = "Settings"
|
|
var id: String { rawValue }
|
|
var icon: String {
|
|
switch self {
|
|
case .home: return "house.fill"
|
|
case .player: return "play.rectangle.fill"
|
|
case .library: return "square.grid.2x2.fill"
|
|
case .search: return "magnifyingglass"
|
|
case .downloads: return "arrow.down.circle.fill"
|
|
case .offline: return "internaldrive"
|
|
case .metadata: return "wand.and.stars"
|
|
#if ENABLE_ADULT
|
|
case .adult: return "flame.fill"
|
|
#endif
|
|
case .thisMac: return "laptopcomputer"
|
|
case .devices: return "macbook.and.iphone"
|
|
case .logs: return "list.bullet.rectangle"
|
|
case .setup: return "gearshape.fill"
|
|
}
|
|
}
|
|
}
|
|
|
|
@State private var controller = PlayerController()
|
|
@State private var watchHistory = WatchHistoryController()
|
|
@State private var library: LibraryController
|
|
@State private var downloads: DownloadsController
|
|
@State private var metadata: MetadataController
|
|
@State private var search: SearchController
|
|
@State private var playlist: PlaylistController
|
|
@State private var offline: OfflineCacheController
|
|
@State private var localAPI = AppLocalAPI()
|
|
@State private var log = LogController()
|
|
@State private var selection: Section = .home
|
|
@State private var showQueue = false
|
|
@State private var versionCopied = false
|
|
@State private var winampSkin = WinampSkinStore()
|
|
@State private var streamability = StreamabilityMonitor()
|
|
|
|
init() {
|
|
let wh = WatchHistoryController()
|
|
let lib = LibraryController(watchHistory: wh)
|
|
let dl = DownloadsController()
|
|
_watchHistory = State(initialValue: wh)
|
|
_downloads = State(initialValue: dl)
|
|
_metadata = State(initialValue: MetadataController(library: lib))
|
|
_search = State(initialValue: SearchController(library: lib, downloads: dl))
|
|
_playlist = State(initialValue: PlaylistController(library: lib))
|
|
let off = OfflineCacheController(library: lib)
|
|
lib.attach(offline: off)
|
|
lib.attach(downloads: dl)
|
|
_library = State(initialValue: lib)
|
|
_offline = State(initialValue: off)
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationSplitView {
|
|
List(selection: $selection) {
|
|
ForEach(visibleSections) { section in
|
|
Label(section.rawValue, systemImage: section.icon).tag(section)
|
|
// Subnav: Library's category links, auto-collapsed — shown only
|
|
// while Library is the active nav (selecting another nav hides them).
|
|
if section == .library, selection == .library {
|
|
ForEach(library.homeCategories, id: \.self) { cat in
|
|
categoryLink(cat)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.navigationSplitViewColumnWidth(180)
|
|
.navigationTitle("TVAnarchy")
|
|
.safeAreaInset(edge: .bottom) {
|
|
VStack(spacing: 4) {
|
|
if DevicesConfig.loadOrSeed().localDevice?.services.offlineCache == true {
|
|
offlineSidebarStatus
|
|
}
|
|
// Global play-queue button — available from every tab.
|
|
Button { showQueue.toggle() } label: {
|
|
Label(playlist.isEmpty ? "Play Queue" : "Play Queue (\(playlist.count))",
|
|
systemImage: "list.bullet.rectangle")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.bordered).controlSize(.small)
|
|
.help("Open the play queue popover (add items from Library or Search)")
|
|
.popover(isPresented: $showQueue, arrowEdge: .leading) {
|
|
PlaylistPopover(playlist: playlist, player: controller, library: library)
|
|
}
|
|
// Build stamp, always visible — glance here to confirm you're on
|
|
// a fresh build (not a stale copy you forgot to relaunch). Click
|
|
// to copy the full version (ver · sha · build time) — handy when
|
|
// reporting "still broken on the installed build". A real Button
|
|
// (reliable hit area + pointer cursor) with in-place "Copied ✓"
|
|
// feedback, since `controller.note` only renders on Home/Library.
|
|
HStack(spacing: 6) {
|
|
Button {
|
|
copyToClipboard(AppVersion.full)
|
|
controller.note("Copied \(AppVersion.full)")
|
|
versionCopied = true
|
|
} label: {
|
|
Text(versionCopied ? "Copied ✓ — paste to share" : AppVersion.short)
|
|
.font(.caption2)
|
|
.foregroundStyle(versionCopied ? AnyShapeStyle(.green) : AnyShapeStyle(.secondary))
|
|
.lineLimit(1).truncationMode(.middle)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Click to copy: \(AppVersion.full)")
|
|
.task(id: versionCopied) {
|
|
guard versionCopied else { return }
|
|
try? await Task.sleep(for: .seconds(1.6))
|
|
versionCopied = false
|
|
}
|
|
#if ENABLE_ADULT
|
|
// The speakeasy switch: a low-key eye that reveals/hides the
|
|
// Adult tab. Compile-gated ONLY (never `pornFeature`-gated),
|
|
// so switching off can never hide the way back on.
|
|
Button { library.pornFeature.toggle() } label: {
|
|
Image(systemName: library.pornFeature ? "eye" : "eye.slash")
|
|
.font(.caption2)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.foregroundStyle(.tertiary)
|
|
.help(library.pornFeature ? "Hide adult content" : "Show adult content")
|
|
#endif
|
|
}
|
|
}
|
|
.padding(.horizontal, 12).padding(.vertical, 6)
|
|
}
|
|
} detail: {
|
|
switch selection {
|
|
case .home:
|
|
HomeView(library: library, player: controller, playlist: playlist, offline: offline, downloads: downloads) { selection = .library }
|
|
case .player:
|
|
PlayerView(controller: controller, library: library, playlist: playlist,
|
|
offline: offline, streamability: streamability,
|
|
onOfflineDetails: { selection = .offline })
|
|
case .library:
|
|
LibraryView(library: library, player: controller, playlist: playlist, offline: offline, downloads: downloads)
|
|
case .search:
|
|
SearchView(search: search, player: controller, library: library) { selection = .library }
|
|
case .downloads:
|
|
DownloadsView(downloads: downloads, player: controller)
|
|
case .offline:
|
|
OfflineCacheView(offline: offline, library: library, controller: controller)
|
|
case .metadata:
|
|
MetadataView(metadata: metadata)
|
|
#if ENABLE_ADULT
|
|
case .adult:
|
|
AdultView(playlist: playlist, player: controller, library: library)
|
|
#endif
|
|
case .thisMac:
|
|
DeviceView(controller: controller, library: library, offline: offline,
|
|
streamability: streamability)
|
|
case .devices:
|
|
DevicesView(controller: controller, offline: offline, onManageLocal: { selection = .thisMac })
|
|
case .logs:
|
|
LogView(log: log)
|
|
case .setup:
|
|
SetupView(controller: controller, library: library)
|
|
}
|
|
}
|
|
.frame(minWidth: 780, minHeight: 520)
|
|
.environment(\.winampSkin, winampSkin)
|
|
.themed(library.appTheme, skin: winampSkin.package)
|
|
.onAppear { reloadWinampSkin() }
|
|
.onChange(of: library.winampSkinId) { reloadWinampSkin() }
|
|
.onChange(of: library.winampSkinName) { reloadWinampSkin() }
|
|
.overlay(alignment: .top) {
|
|
if let banner = NotificationsService.shared.lastBanner {
|
|
Label(banner, systemImage: "bell.fill")
|
|
.font(.callout)
|
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
.background(.thinMaterial, in: Capsule())
|
|
.padding(.top, 12)
|
|
.transition(.move(edge: .top).combined(with: .opacity))
|
|
.onTapGesture { NotificationsService.shared.clearBanner() }
|
|
.task(id: banner) {
|
|
try? await Task.sleep(for: .seconds(5))
|
|
NotificationsService.shared.clearBanner()
|
|
}
|
|
}
|
|
}
|
|
.animation(.default, value: NotificationsService.shared.lastBanner)
|
|
.overlay(alignment: .bottom) {
|
|
if offline.isDownloading, selection != .setup, selection != .thisMac, selection != .player {
|
|
offlineDownloadBanner.padding(.bottom, 12)
|
|
}
|
|
}
|
|
.task {
|
|
// Finished download → scan its folder(s) directly into the library.
|
|
// Black's index.tsv is updated in the background; no wait, no re-fetch.
|
|
downloads.onDownloadsCompleted = { folders in library.ingestFolders(folders) }
|
|
// Every play this app starts (launch, queue fire, auto-advance) lands
|
|
// in the watchlog, so Continue Watching reflects in-app playback too.
|
|
// Live positions during playback also flow through so resume values and
|
|
// episode progress bars stay current (unified source).
|
|
controller.onItemStarted = { path, resume in
|
|
library.recordPlay(path: path, resumeSeconds: resume, finished: false)
|
|
}
|
|
controller.onEpisodeFinished = { path, dur in
|
|
library.recordPlay(path: path, finished: true)
|
|
if let d = dur { library.recordPosition(path: path, resumeSeconds: d, durationSeconds: d) }
|
|
Task { await offline.reconcileAfterEpisodeFinished() }
|
|
}
|
|
controller.onProgressUpdate = { path, pos, dur in
|
|
library.recordPosition(path: path, resumeSeconds: pos, durationSeconds: dur)
|
|
}
|
|
controller.attach(library: library)
|
|
controller.attach(playlist: playlist)
|
|
streamability.attach(player: controller)
|
|
streamability.refreshActivation()
|
|
controller.startDisplayMonitoring()
|
|
NotificationsService.shared.requestAuthorizationIfNeeded()
|
|
applyVisibility()
|
|
// Start the watch poller (catches governor/black externals) before
|
|
// player so first ticks see fresh state.
|
|
watchHistory.start()
|
|
controller.start(); downloads.start()
|
|
localAPI.attach(offline: offline, player: controller, library: library, playlist: playlist)
|
|
localAPI.start()
|
|
await offline.warmupIfEnabled()
|
|
offline.startPeriodicReconcile()
|
|
// v1 search robustness (D2): background non-blocking warmup so cold ETIMEDOUTs are rare.
|
|
Task.detached { await downloads.warmupSearch() } // fire and forget; tolerant
|
|
|
|
|
|
}
|
|
.onChange(of: selection) { applyVisibility() }
|
|
.onChange(of: library.playbackMode) { streamability.refreshActivation() }
|
|
.onChange(of: controller.activeSnapshot.status.duration) {
|
|
streamability.episodeDuration = controller.activeSnapshot.status.duration
|
|
streamability.syncFromPlayer()
|
|
}
|
|
.onChange(of: watchHistory.lastRefresh) { _ in library.refreshContinueWatching() }
|
|
#if ENABLE_ADULT
|
|
.onChange(of: library.pornFeature) { reconcileSelection() }
|
|
#endif
|
|
}
|
|
|
|
/// The sidebar entries. The Adult tab is hidden until adult content is
|
|
/// enabled (the discreet eye by the build stamp), keeping the default
|
|
/// build's privacy model: nothing adult is visible unless explicitly turned
|
|
/// on. The `#if ENABLE_ADULT` filter compiles out even the test for
|
|
/// flag-off builds.
|
|
private var visibleSections: [Section] {
|
|
Section.allCases.filter { section in
|
|
#if ENABLE_ADULT
|
|
if section == .adult { return library.pornFeature }
|
|
#endif
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// If the Adult tab vanishes (feature switched off) while it's selected, fall
|
|
/// back to Home so the detail pane never dangles on a hidden section.
|
|
private func reconcileSelection() {
|
|
#if ENABLE_ADULT
|
|
if selection == .adult, !library.pornFeature { selection = .home }
|
|
#endif
|
|
}
|
|
|
|
/// An indented Library category link in the sidebar subnav — jumps to the
|
|
/// Library tab filtered to that category, and marks the active one.
|
|
@ViewBuilder private func categoryLink(_ cat: String) -> some View {
|
|
Button {
|
|
library.selectedShow = nil
|
|
library.selectedCategory = cat
|
|
selection = .library
|
|
} label: {
|
|
Label(LibraryConfig.label(cat), systemImage: "chevron.forward")
|
|
.font(.callout)
|
|
.foregroundStyle(library.selectedCategory == cat ? AnyShapeStyle(.tint) : AnyShapeStyle(.secondary))
|
|
.padding(.leading, 16)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Filter Library to \(LibraryConfig.label(cat))")
|
|
}
|
|
|
|
/// Gate the expensive pollers to the tab that needs them: fast player
|
|
/// transport + stats only on Player; fast transfer polling only on Downloads.
|
|
/// Both still poll slowly off-tab (host dots, sleep timer, last-known transfers).
|
|
private func applyVisibility() {
|
|
controller.detailed = (selection == .player)
|
|
controller.devicesVisible = (selection == .devices || selection == .thisMac)
|
|
downloads.detailVisible = (selection == .downloads)
|
|
}
|
|
|
|
@ViewBuilder private var offlineSidebarStatus: some View {
|
|
Button { selection = .offline } label: {
|
|
HStack(spacing: 6) {
|
|
if offline.caching || offline.isDownloading {
|
|
ProgressView().controlSize(.small)
|
|
} else {
|
|
Image(systemName: "internaldrive")
|
|
.font(.caption2)
|
|
.foregroundStyle(offline.diskFileCount > 0 ? AnyShapeStyle(.tint) : AnyShapeStyle(.tertiary))
|
|
}
|
|
Text(offlineSidebarLine)
|
|
.font(.caption2)
|
|
.foregroundStyle(offline.caching || offline.isDownloading
|
|
? AnyShapeStyle(.primary) : AnyShapeStyle(.secondary))
|
|
.lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
if offline.caching || offline.isDownloading {
|
|
Image(systemName: "chevron.right")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help(offline.caching || offline.isDownloading
|
|
? "Offline cache in progress — open Offline"
|
|
: "Open Offline: playable list of cached items (auto fill/cull)")
|
|
}
|
|
|
|
/// Sidebar strip: full detail off Player (dedicated strip there); compact percent on Player.
|
|
private var offlineSidebarLine: String {
|
|
if selection == .player, offline.isDownloading {
|
|
if let pct = offline.queueProgress {
|
|
return "Offline cache · \(Int(pct * 100))%"
|
|
}
|
|
return "Offline cache…"
|
|
}
|
|
return offline.sidebarLine
|
|
}
|
|
|
|
private func reloadWinampSkin() {
|
|
winampSkin.reload(id: library.winampSkinId, name: library.winampSkinName)
|
|
}
|
|
|
|
@ViewBuilder private var offlineDownloadBanner: some View {
|
|
Button { selection = .offline } label: {
|
|
OfflineDownloadPanel(offline: offline, compact: true)
|
|
.padding(.horizontal, 14).padding(.vertical, 10)
|
|
.frame(maxWidth: 440)
|
|
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 12))
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.help("Open Offline tab: full queue + cached items list")
|
|
}
|
|
}
|