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( onResume: play, onTitle: open, // The form as a pasted link opens it, only with nothing in it. onAddShare: { incomingLink = "" destination = .shares }, onOpenSettings: { select(.settings) } ) } } .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() } } } } }