mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-03 16:41:26 +01:00
The empty library said to add a link "under Shares, then refresh" with nothing to press on the phones, and on the desktop only switched page. All three now open the add form from there, the desktop with the caret in the link field; and adding already crawls, so there is nothing to refresh. A share's name was required before the viewer had seen anything to name. It is optional now: without one, ShareStore::add_verified takes the shared folder's own name from the link it has just opened. The link comes first in each form. The desktop form submits on Enter, and the Android link field has an eye to check what was pasted.
380 lines
15 KiB
Swift
380 lines
15 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, 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
|
|
|
|
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())
|
|
}
|
|
.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
|
|
}
|
|
|
|
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:
|
|
SharesView(incomingLink: $incomingLink)
|
|
case .downloads:
|
|
DownloadsView()
|
|
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 library = LibraryView(
|
|
onResume: play,
|
|
onTitle: { title in withAnimation(.easeOut(duration: 0.25)) { selectedTitleKey = title.key } },
|
|
// The form as a pasted link opens it, only with nothing in it.
|
|
onAddShare: {
|
|
incomingLink = ""
|
|
destination = .shares
|
|
}
|
|
)
|
|
if width >= twoPaneWidth {
|
|
HStack(spacing: 0) {
|
|
library.frame(width: width * 0.45)
|
|
Rectangle().fill(scheme.outlineVariant).frame(width: 1).ignoresSafeArea()
|
|
Group {
|
|
if let selected { titlePane(selected) } 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)
|
|
.transition(.asymmetric(insertion: .move(edge: .trailing), removal: .opacity.combined(with: .scale(scale: 0.9))))
|
|
.zIndex(1)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func titlePane(_ selected: TitleRecord) -> 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 }
|
|
)
|
|
.id(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)
|
|
}
|
|
.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: .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() }
|
|
}
|
|
}
|
|
}
|
|
}
|