tv-anarchy/Sources/TVAnarchy/RootView.swift
Natalie b44b5a2d1a feat(@applications): add adult content browsing tab
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-06-09 19:51:12 -07:00

233 lines
11 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 metadata = "Metadata"
#if ENABLE_ADULT
case adult = "Adult"
#endif
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 .metadata: return "wand.and.stars"
#if ENABLE_ADULT
case .adult: return "flame.fill"
#endif
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 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 log = LogController()
@State private var selection: Section = .home
@State private var showQueue = false
@State private var versionCopied = false
init() {
let lib = LibraryController()
let dl = DownloadsController()
_library = State(initialValue: lib)
_downloads = State(initialValue: dl)
_metadata = State(initialValue: MetadataController(library: lib))
_search = State(initialValue: SearchController(library: lib, downloads: dl))
_playlist = State(initialValue: PlaylistController(library: lib))
_offline = State(initialValue: OfflineCacheController(library: lib))
}
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) {
// 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)
.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) { selection = .library }
case .player:
PlayerView(controller: controller)
case .library:
LibraryView(library: library, player: controller, playlist: playlist)
case .search:
SearchView(search: search, player: controller, library: library) { selection = .library }
case .downloads:
DownloadsView(downloads: downloads, player: controller)
case .metadata:
MetadataView(metadata: metadata)
#if ENABLE_ADULT
case .adult:
AdultView(playlist: playlist, player: controller, library: library)
#endif
case .devices:
DevicesView(controller: controller, offline: offline)
case .logs:
LogView(log: log)
case .setup:
SetupView(controller: controller, library: library)
}
}
.frame(minWidth: 780, minHeight: 520)
.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)
.task {
// A finished download is when new media lands on black index JUST
// its folder(s) incrementally (cheap), then refresh from the index.
downloads.onDownloadsCompleted = { folders in library.rebuildIndex(folders: folders) }
NotificationsService.shared.requestAuthorizationIfNeeded()
applyVisibility()
controller.start(); downloads.start()
}
.onChange(of: selection) { applyVisibility() }
#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)
}
/// 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)
downloads.detailVisible = (selection == .downloads)
}
}