tv-anarchy/Sources/TVAnarchy/HomeView.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

152 lines
6.9 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 SwiftUI
import TVAnarchyCore
/// Netflix-style landing: horizontal poster rails. Continue Watching up top, then
/// a rail per category. Tapping a tile plays/resumes on the active host; deeper
/// browsing (seasons/episodes) lives in the Library tab.
struct HomeView: View {
@Bindable var library: LibraryController
@Bindable var player: PlayerController
@Bindable var playlist: PlaylistController
/// Switch to the Library tab (after setting selectedShow/selectedCategory).
let openLibrary: () -> Void
/// Per-rail paging position (keyed by category) for the buttons.
@State private var railIndex: [String: Int] = [:]
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 28) {
if library.refreshing || library.rebuildingIndex {
ScanningBanner(progress: library.scanProgress, total: library.scanTotal,
label: library.rebuildingIndex ? "Indexing on black" : "Scanning library")
}
let continuing = library.homeContinueWatching
if !continuing.isEmpty {
rail("Continue Watching", onTitle: nil) {
ForEach(continuing) { item in
Button { play(continue: item) } label: { ContinueCard(item: item) }
.buttonStyle(.plain)
}
}
}
let recent = library.recentlyAdded()
if !recent.isEmpty {
rail("Recently Added", onTitle: nil) {
ForEach(recent) { show in
Button { open(show: show) } label: { ShowPoster(show: show, width: 132) }
.buttonStyle(.plain)
}
}
}
ForEach(library.homeCategories, id: \.self) { cat in
let shows = Array(library.homeShows(in: cat).prefix(40))
if !shows.isEmpty { posterRail(cat, shows) }
}
if library.shows.isEmpty && !library.refreshing {
Text("Library is empty — open Library and Refresh on your home network.")
.foregroundStyle(.secondary)
}
}
.padding(20)
}
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .principal) { MiniTransport(controller: player) }
ToolbarItem(placement: .primaryAction) { HostSelector(controller: player, compact: true) }
}
.overlay(alignment: .bottom) {
if let msg = player.actionMessage {
Text(msg).font(.callout).padding(.horizontal, 14).padding(.vertical, 10)
.background(.thinMaterial, in: Capsule()).padding(.bottom, 16)
.onTapGesture { copyToClipboard(msg) }.help("Click to copy")
.task(id: msg) { try? await Task.sleep(for: .seconds(4)); player.note(nil) }
}
}
.animation(.default, value: player.actionMessage)
.task { await library.refreshIfStale() }
.task {
// Keep Continue Watching live: re-read the watchlog / VLC recents every
// few seconds while Home is open, so finishing or scrubbing an episode
// in the player reorders the rail without a full library rescan.
while !Task.isCancelled {
library.refreshContinueWatching()
try? await Task.sleep(for: .seconds(8))
}
}
}
/// A rail with an optional tappable title (Continue Watching uses this; no
/// scroll buttons since its cards aren't shows).
private func rail<C: View>(_ title: String, onTitle: (() -> Void)?,
@ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(title).font(.title3).bold()
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) { content() }.padding(.bottom, 4)
}
}
}
/// A category poster rail: tappable title ( category), fixed-width tiles
/// (no overlap), and buttons that page through it.
private func posterRail(_ cat: String, _ shows: [CachedShow]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Button { open(category: cat) } label: {
HStack(spacing: 4) { Text(LibraryConfig.label(cat)).font(.title3).bold(); Image(systemName: "chevron.right").font(.caption) }
}
.buttonStyle(.plain)
ScrollViewReader { proxy in
HStack(spacing: 6) {
railButton("chevron.left") { page(cat, shows, -1, proxy) }
ScrollView(.horizontal, showsIndicators: false) {
HStack(alignment: .top, spacing: 14) {
ForEach(shows) { show in
Button { open(show: show) } label: { ShowPoster(show: show, width: 132) }
.buttonStyle(.plain).id(show.id)
}
}
.padding(.bottom, 4)
}
railButton("chevron.right") { page(cat, shows, +1, proxy) }
}
}
}
}
private func railButton(_ icon: String, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
Image(systemName: icon).font(.body).padding(8).background(.thinMaterial, in: Circle())
}
.buttonStyle(.plain)
}
/// Page a rail left/right by a few tiles, scrolling to the new lead item.
private func page(_ cat: String, _ shows: [CachedShow], _ dir: Int, _ proxy: ScrollViewProxy) {
guard !shows.isEmpty else { return }
let step = 4
let next = min(max(0, (railIndex[cat] ?? 0) + dir * step), shows.count - 1)
railIndex[cat] = next
withAnimation { proxy.scrollTo(shows[next].id, anchor: .leading) }
}
private func open(show: CachedShow) {
library.selectedCategory = library.type(of: show.category)
library.selectedShow = show
openLibrary()
}
private func open(category: String) {
library.selectedShow = nil
library.selectedCategory = category
openLibrary()
}
private func play(continue item: ContinueItem) {
// Unified queue: continuing a series queues the rest of the show from here.
if playlist.playContinue(item, shows: library.shows, on: player) { return }
guard let kind = player.activeKind,
let req = library.launchRequest(continue: item, targetKind: kind) else {
player.note("No player selected"); return
}
player.launch(req, series: item.show, resumeSeconds: item.positionSeconds)
}
}