Files
proton-stream/ios/ProtonStream/App/ProtonStreamApp.swift
T
nvrl 87ccaf26e1 feat(app,android,ios): search across episodes and people
A search now also finds, with posters and descriptions on, the directors
and studios and the genres whose names hold it, as chips that open their
page, and the episodes whose provider names hold it, which play from the
result (pstr_core::discover::find). The titles it found follow under
their own heading.

The last eight searches are kept in recent-searches.json on this device
only (pstr_core::recent), written only when a search led somewhere, and
offered under the empty search box with a way to clear them. The bridge
exposes search_extras, recent_searches, remember_search and
forget_searches.

On Android and iOS the library's titles are now the whole library
whatever is searched for; the grid narrows through the arrangement, as
it already did. A title opened from an episode result can then always be
shown, and History and Downloads no longer shrink to the search.
2026-10-30 23:07:51 +01:00

455 lines
19 KiB
Swift

import AVFoundation
import SwiftUI
import UIKit
@main
struct ProtonStreamApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var delegate
@State private var model = AppModel()
@State private var appearance = AppearanceState.shared
init() {
// `.playback` keeps the audio going with the screen locked or the app
// in the background, which `UIBackgroundModes: audio` then permits.
try? AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
// The palette read before the first frame, so the first frame is
// already painted in it.
AppearanceState.shared.seed(NativeRuntime.initialPalette())
}
var body: some Scene {
WindowGroup {
let scheme = appearance.scheme
RootView()
.environment(model)
.environment(\.scheme, scheme)
.preferredColorScheme(scheme.colorScheme)
.tint(scheme.primary)
.font(.inter(.bodyLarge))
.background(scheme.background.ignoresSafeArea())
.task { await appearance.load() }
}
}
}
final class AppDelegate: NSObject, UIApplicationDelegate {
func application(_: UIApplication, didFinishLaunchingWithOptions _: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
NewEpisodes.register()
NewEpisodes.schedule()
NewEpisodes.requestPermission()
return true
}
func application(_: UIApplication, supportedInterfaceOrientationsFor _: UIWindow?) -> UIInterfaceOrientationMask {
MainActor.assumeIsolated { OrientationLock.mask }
}
}
/// From this width the library and an open title share the window.
private let twoPaneWidth: CGFloat = 840
/// From this width the destinations are a rail down the side, not a bar.
private let railWidth: CGFloat = 600
/// How much room the floating mini transport needs at the foot of a page.
private let miniTransportInset: CGFloat = 88
enum Destination: String, CaseIterable {
case library, history, shares, downloads, settings
var label: String {
switch self {
case .library: "Library"
case .history: "History"
case .shares: "Shares"
case .downloads: "Downloads"
case .settings: "Settings"
}
}
var icon: String {
switch self {
case .library: "play.rectangle.on.rectangle.fill"
case .history: "clock.arrow.circlepath"
case .shares: "square.and.arrow.up"
case .downloads: "arrow.down.circle"
case .settings: "gearshape"
}
}
}
struct RootView: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
@Environment(\.scenePhase) private var phase
// Keys, not records: what has to survive is *which* title, and a record is
// no longer current after the library reloads. Everything else is derived,
// so a reload that renames a season updates the open screen.
@SceneStorage("destination") private var destination: Destination = .library
@SceneStorage("selectedTitle") private var selectedTitleKey: String?
@State private var incomingLink: String?
@State private var host = PlayerHost.shared
/// What a genre, studio or director chip opened, over the library pane,
/// and the title it was opened from, which is where back goes.
@State private var browsing: BrowseFacet?
@State private var browseFrom: String?
@State private var browsed: [String] = []
/// "More like this" for the open title.
@State private var similar: [String] = []
var body: some View {
GeometryReader { geometry in
let wide = geometry.size.width >= railWidth
ZStack(alignment: .bottom) {
HStack(spacing: 0) {
if wide { NavigationRail(destination: $destination, onSelect: select) }
page(width: geometry.size.width)
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 8) {
if host.isOpen && host.minimized {
MiniTransport(host: host)
}
if !wide { NavigationBar(destination: $destination, onSelect: select) }
}
}
}
VStack(spacing: 8) {
ActivityBanner()
Snackbar()
}
.padding(.bottom, (wide ? 16 : 72) + (host.isOpen && host.minimized ? miniTransportInset : 0))
.animation(.easeInOut(duration: 0.2), value: model.activity)
}
.background(scheme.background.ignoresSafeArea())
// Covered by the player, but still there to VoiceOver, which
// would otherwise walk the library behind the picture.
.accessibilityHidden(host.isOpen && !host.minimized)
}
.overlay {
// Over the shell rather than instead of it: the page behind stays
// where it was, so leaving the player finds the title page as it was.
if host.isOpen && !host.minimized {
Group {
if let player = host.player {
PlayerView(host: host, player: player)
} else {
ZStack(alignment: .topTrailing) {
Color.black.ignoresSafeArea()
Text(host.playbackError ?? "Playback is unavailable: libmpv did not start.")
.foregroundStyle(.white)
.frame(maxWidth: .infinity, maxHeight: .infinity)
IconButton("xmark", "Close", tint: .white, action: host.close)
}
}
}
.transition(.move(edge: .bottom))
}
}
.animation(.easeInOut(duration: 0.25), value: host.isOpen && !host.minimized)
.onAppear {
model.start()
host.onSaveProgress = { [model] episode, position, duration, watched in
model.saveProgress(episode, position: position, duration: duration, watched: watched)
}
host.onClosed = { [model] in model.playerClosed() }
}
.onChange(of: model.titles) { _, titles in host.update(titles: titles) }
.onChange(of: phase) { _, phase in
switch phase {
case .background:
host.enteredBackground()
model.syncInBackground()
case .active:
host.enteredForeground()
model.enteredForeground()
DownloadCoordinator.shared.resumeQueued()
default:
break
}
}
// A link opened in this app: show the Add form with it filled in. A
// playing episode keeps going in the mini transport rather than
// covering the form.
.onOpenURL { url in
guard let link = shareLink(from: url) else { return }
incomingLink = link
destination = .shares
selectedTitleKey = nil
if host.isOpen { host.minimized = true }
}
}
private func select(_ target: Destination) {
destination = target
selectedTitleKey = nil
browsing = nil
}
private func play(_ title: TitleRecord, _ index: Int) {
host.play(title, at: index)
}
@ViewBuilder private func page(width: CGFloat) -> some View {
switch destination {
case .library:
libraryPage(width: width)
case .history:
HistoryView(onPlay: play, onTitle: { title in
destination = .library
selectedTitleKey = title.key
})
case .shares:
// The first share is the library: watch it fill rather than stay
// on a form that has done its job.
SharesView(incomingLink: $incomingLink, onFirstShareAdded: { select(.library) })
case .downloads:
DownloadsView(onPlay: play, onOpen: { title in
destination = .library
selectedTitleKey = title.key
})
case .settings:
SettingsView()
}
}
/// Side by side where there is room for both: on a tablet the library
/// stays in view while a title is open, so moving between titles is one
/// tap rather than back and in again.
@ViewBuilder private func libraryPage(width: CGFloat) -> some View {
let selected = selectedTitleKey.flatMap { key in model.titles.first { $0.key == key } }
let twoPane = width >= twoPaneWidth
let open = { (title: TitleRecord) in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } }
let library = Group {
if let browsing {
BrowseView(
facet: browsing,
titles: browsed.compactMap { key in model.titles.first { $0.key == key } },
onBack: {
self.browsing = nil
if let browseFrom { selectedTitleKey = browseFrom }
},
onTitle: open
)
} else {
LibraryView(
// A search that led somewhere is one worth offering again.
onResume: { title, index in
model.rememberSearch()
play(title, index)
},
onTitle: { title in
model.rememberSearch()
open(title)
},
// The form as a pasted link opens it, only with nothing in it.
onAddShare: {
incomingLink = ""
destination = .shares
},
onOpenSettings: { select(.settings) },
onBrowse: { facet in
model.rememberSearch()
browsing = facet
browseFrom = nil
}
)
}
}
.task(id: [browsing.map { "\($0)" } ?? "", "\(model.titles.count)"]) {
if let browsing {
browsed = await model.browse(browsing)
} else {
browsed = []
}
}
if twoPane {
HStack(spacing: 0) {
library.frame(width: width * 0.45)
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
Group {
if let selected { titlePane(selected, twoPane: true) } else { EmptyState("Choose a title", "Its seasons and episodes open here.") }
}
.frame(maxWidth: .infinity)
}
} else {
// The library stays underneath the title page rather than being
// rebuilt, so going back finds the grid scrolled where it was.
ZStack {
library
.opacity(selected == nil ? 1 : 0)
.allowsHitTesting(selected == nil)
.accessibilityHidden(selected != nil)
if let selected {
titlePane(selected, twoPane: false)
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
.zIndex(1)
}
}
}
}
private func titlePane(_ selected: TitleRecord, twoPane: Bool) -> some View {
TitleView(
title: selected,
onPlay: { _, index in host.play(selected, at: index) },
onBack: { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } },
franchise: selected.franchise.compactMap { key in model.titles.first { $0.key == key } },
onOpenTitle: { selectedTitleKey = $0.key },
moreLike: similar.compactMap { key in model.titles.first { $0.key == key } },
onBrowse: { facet in
browsing = facet
browseFrom = selected.key
// On a phone the list is under the title; the page it opens
// is the library pane's.
if !twoPane { withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = nil } }
}
)
.id(selected.key)
.task(id: [selected.key, "\(model.titles.count)"]) {
similar = await model.moreLike(selected.key)
}
}
}
/// The destinations along the foot of a phone, the selected one in an accent
/// pill — the desktop's rule is that the accent marks the thing you are on.
private struct NavigationBar: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
HStack(spacing: 0) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 30)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
.lineLimit(1)
.minimumScaleFactor(0.8)
}
.frame(maxWidth: .infinity)
.padding(.top, 10)
.padding(.bottom, 6)
.contentShape(Rectangle())
}
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
}
.background(scheme.surfaceContainer.ignoresSafeArea(edges: .bottom))
}
}
/// The same destinations down the side of a wide window.
private struct NavigationRail: View {
@Binding var destination: Destination
let onSelect: (Destination) -> Void
@Environment(\.scheme) private var scheme
var body: some View {
VStack(spacing: 12) {
ForEach(Destination.allCases, id: \.self) { item in
let selected = item == destination
Button { onSelect(item) } label: {
VStack(spacing: 4) {
Image(systemName: item.icon)
.font(.system(size: 18))
.foregroundStyle(selected ? scheme.onPrimary : scheme.onSurfaceVariant)
.frame(width: 56, height: 32)
.background(selected ? scheme.primary : .clear, in: Capsule())
Text(item.label)
.textStyle(.labelMedium, semibold: selected)
.foregroundStyle(selected ? scheme.onSurface : scheme.onSurfaceVariant)
}
.frame(width: 80)
.contentShape(Rectangle())
}
.buttonStyle(.pressable)
.accessibilityLabel(item.label)
.accessibilityAddTraits(selected ? .isSelected : [])
}
Spacer()
}
.padding(.top, 24)
.background(scheme.surface.ignoresSafeArea())
}
}
/// What the app is busy with, for as long as it is: a spinner and one line.
/// Not dismissible and not timed — it goes when the work does.
private struct ActivityBanner: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let activity = model.activity {
HStack(spacing: 12) {
ProgressView().controlSize(.small).tint(scheme.primary)
Text(activity)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.onSurface)
.lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
// A crawl of a large share can take minutes, and is the
// viewer's to call off; what it has read so far stays.
if model.crawling {
Button("Stop", action: model.stopCrawl).buttonStyle(.quiet)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(scheme.surfaceContainerHighest, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.accessibilityElement(children: model.crawling ? .contain : .combine)
.accessibilityAddTraits(.updatesFrequently)
}
}
}
/// The app's one line of feedback: what just happened, with Undo where it can
/// be taken back.
private struct Snackbar: View {
@Environment(AppModel.self) private var model
@Environment(\.scheme) private var scheme
var body: some View {
if let message = model.message {
HStack(spacing: 8) {
Text(message)
.textStyle(.bodyMedium)
.foregroundStyle(scheme.inverseOnSurface)
.frame(maxWidth: .infinity, alignment: .leading)
if model.undo != nil {
Button("Undo", action: model.performUndo)
.textStyle(.labelLarge)
.foregroundStyle(scheme.inversePrimary)
.buttonStyle(.pressable)
.padding(.horizontal, 8)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(scheme.inverseSurface, in: RoundedRectangle(cornerRadius: Corner.tight))
.padding(.horizontal, 12)
.transition(.move(edge: .bottom).combined(with: .opacity))
.onTapGesture(perform: model.dismissMessage)
// Long with an Undo to reach for, short without — Material's two
// snackbar durations.
.task(id: message) {
try? await Task.sleep(for: .seconds(model.undo != nil ? 10 : 4))
if !Task.isCancelled, model.message == message { model.dismissMessage() }
}
}
}
}