Files
yattee/Yattee/YatteeApp.swift
2026-02-08 18:33:56 +01:00

592 lines
23 KiB
Swift

//
// YatteeApp.swift
// Yattee
//
// Main application entry point.
//
import SwiftUI
import Combine
import Nuke
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
@main
struct YatteeApp: App {
#if os(iOS)
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#elseif os(macOS)
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
#endif
@State private var appEnvironment = AppEnvironment()
@State private var backgroundTasksRegistered = false
@State private var showingClipboardAlert = false
@State private var detectedClipboardURL: URL?
@State private var lastCheckedClipboardURL: URL?
@Environment(\.scenePhase) private var scenePhase
// Deep link handling state
@State private var prefilledLinkURL: URL?
#if !os(tvOS)
@State private var deepLinkVideo: Video?
@State private var deepLinkStreams: [Stream] = []
@State private var deepLinkCaptions: [Caption] = []
@State private var showingDeepLinkDownloadSheet = false
#endif
// Onboarding state
@State private var showingOnboarding = false
@State private var showingSettings = false
@State private var showingOpenLinkSheet = false
init() {
// Configure Nuke image loading pipeline
ImageLoadingService.shared.configure()
}
var body: some Scene {
WindowGroup {
ContentView()
.appEnvironment(appEnvironment)
.preferredColorScheme(appEnvironment.settingsManager.theme.colorScheme)
.tint(appEnvironment.settingsManager.accentColor.color)
#if os(macOS)
// Required on the view to prevent new windows on URL open
.handlesExternalEvents(preferring: ["*"], allowing: ["*"])
#endif
.onOpenURL { url in
handleDeepLink(url)
}
.onContinueUserActivity(HandoffManager.activityType) { activity in
handleContinuedActivity(activity)
}
.onAppear {
registerBackgroundTasksIfNeeded()
}
.onReceive(NotificationCenter.default.publisher(for: .continueUserActivity)) { notification in
if let activity = notification.object as? NSUserActivity {
handleContinuedActivity(activity)
}
}
#if os(iOS) || os(macOS)
.alert(String(localized: "alert.openVideo.title"), isPresented: $showingClipboardAlert) {
Button(String(localized: "common.open")) {
if let url = detectedClipboardURL {
appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url))
}
}
Button(String(localized: "common.cancel"), role: .cancel) {}
} message: {
if let url = detectedClipboardURL {
Text(String(localized: "alert.openVideo.message \(url.host ?? "clipboard")"))
}
}
.sheet(item: $prefilledLinkURL) { url in
OpenLinkSheet(prefilledURL: url)
.appEnvironment(appEnvironment)
}
#if !os(tvOS)
.sheet(isPresented: $showingDeepLinkDownloadSheet) {
if let video = deepLinkVideo {
DownloadQualitySheet(
video: video,
streams: deepLinkStreams,
captions: deepLinkCaptions
)
.appEnvironment(appEnvironment)
}
}
#endif
#endif
// Onboarding sheet
#if os(tvOS)
.fullScreenCover(isPresented: $showingOnboarding) {
NavigationStack {
OnboardingSheetView()
.appEnvironment(appEnvironment)
}
}
#else
.sheet(isPresented: $showingOnboarding) {
NavigationStack {
OnboardingSheetView()
.appEnvironment(appEnvironment)
}
.presentationDetents([.large])
.interactiveDismissDisabled()
}
.sheet(isPresented: $showingSettings) {
SettingsView()
.appEnvironment(appEnvironment)
}
.sheet(isPresented: $showingOpenLinkSheet) {
OpenLinkSheet()
.appEnvironment(appEnvironment)
}
#endif
.onReceive(NotificationCenter.default.publisher(for: .showOnboarding)) { _ in
showingOnboarding = true
}
.onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in
showingSettings = true
}
.onReceive(NotificationCenter.default.publisher(for: .showOpenLinkSheet)) { _ in
appEnvironment.navigationCoordinator.isPlayerExpanded = false
showingOpenLinkSheet = true
}
}
#if os(macOS)
.windowStyle(.hiddenTitleBar)
.defaultSize(width: 1200, height: 800)
// Handle URLs in the existing window instead of opening a new one
.handlesExternalEvents(matching: Set(["*"]))
#endif
#if os(iOS) || os(macOS)
.commands {
FileCommands()
PlaybackCommands(appEnvironment: appEnvironment)
NavigationCommands(appEnvironment: appEnvironment)
}
#endif
#if os(tvOS)
.onChange(of: scenePhase) { _, newPhase in
LoggingService.shared.logCloudKit("[ScenePhase] Transition to: \(newPhase)")
// Handle background playback
appEnvironment.playerService.handleScenePhase(newPhase)
// Refresh remote control services after returning from background
appEnvironment.remoteControlCoordinator.handleScenePhase(newPhase)
// Handle pending notification navigation and warm cache when becoming active
if newPhase == .active {
// Refresh media source password status (Keychain state can change in background)
appEnvironment.mediaSourcesManager.refreshPasswordStoredStatus()
// Validate subscription account (auto-correct if invalid)
appEnvironment.subscriptionAccountValidator.validateAndCorrectIfNeeded()
SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment)
// Fetch remote CloudKit changes (catches missed push notifications)
Task {
await appEnvironment.cloudKitSync.fetchRemoteChanges()
}
// Start periodic polling as fallback for missed push notifications
appEnvironment.cloudKitSync.startForegroundPolling()
}
// Flush pending CloudKit changes when entering background
if newPhase == .background {
appEnvironment.cloudKitSync.stopForegroundPolling()
Task {
await appEnvironment.cloudKitSync.flushPendingChanges()
}
}
}
#else
.onChange(of: scenePhase) { _, newPhase in
LoggingService.shared.logCloudKit("[ScenePhase] Transition to: \(newPhase)")
// Handle background playback
appEnvironment.playerService.handleScenePhase(newPhase)
// Refresh remote control services after returning from background
appEnvironment.remoteControlCoordinator.handleScenePhase(newPhase)
#if os(iOS)
// Notify rotation manager - stops monitoring in background to prevent
// fullscreen entry while app is not visible
DeviceRotationManager.shared.handleScenePhase(newPhase)
#endif
// Handle pending notification navigation and warm cache when becoming active
if newPhase == .active {
appEnvironment.notificationManager.handlePendingNavigation(
using: appEnvironment.navigationCoordinator
)
// Refresh media source password status (Keychain state can change in background)
appEnvironment.mediaSourcesManager.refreshPasswordStoredStatus()
// Validate subscription account (auto-correct if invalid)
appEnvironment.subscriptionAccountValidator.validateAndCorrectIfNeeded()
SubscriptionFeedCache.shared.warmIfNeeded(using: appEnvironment)
// Check clipboard for external video URLs
checkClipboardForExternalURL()
// Fetch remote CloudKit changes (catches missed push notifications)
Task {
await appEnvironment.cloudKitSync.fetchRemoteChanges()
}
// Start periodic polling as fallback for missed push notifications
appEnvironment.cloudKitSync.startForegroundPolling()
}
// Flush pending CloudKit changes when entering background
if newPhase == .background {
appEnvironment.cloudKitSync.stopForegroundPolling()
Task {
await appEnvironment.cloudKitSync.flushPendingChanges()
}
#if os(iOS)
if appEnvironment.settingsManager.backgroundNotificationsEnabled {
appEnvironment.backgroundRefreshManager.scheduleIOSBackgroundRefresh()
}
#endif
}
}
#endif
}
private func registerBackgroundTasksIfNeeded() {
guard !backgroundTasksRegistered else { return }
backgroundTasksRegistered = true
// Register background tasks
appEnvironment.backgroundRefreshManager.registerBackgroundTasks()
// If notifications are enabled, schedule the first refresh
#if os(iOS)
if appEnvironment.settingsManager.backgroundNotificationsEnabled {
appEnvironment.backgroundRefreshManager.scheduleIOSBackgroundRefresh()
}
#endif
// Auto-delete old history entries based on retention setting
performHistoryCleanup()
// Show onboarding on first launch
if !appEnvironment.settingsManager.onboardingCompleted {
// Small delay to let the main UI settle
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
showingOnboarding = true
}
}
}
/// Delete old watch history entries based on the retention setting.
private func performHistoryCleanup() {
let retentionDays = appEnvironment.settingsManager.historyRetentionDays
guard retentionDays > 0 else { return }
let calendar = Calendar.current
guard let cutoffDate = calendar.date(byAdding: .day, value: -retentionDays, to: Date()) else {
return
}
appEnvironment.dataManager.clearWatchHistory(olderThan: cutoffDate)
}
/// Handle incoming deep link URLs.
private func handleDeepLink(_ url: URL) {
let router = URLRouter()
guard let destination = router.route(url) else { return }
let action = appEnvironment.settingsManager.defaultLinkAction
// Videos get special handling based on link action setting
if case .video(let source, _) = destination, case .id(let videoID) = source {
handleVideoDeepLink(videoID: videoID, originalURL: url, action: action)
return
}
// External videos also check setting
if case .externalVideo(let externalURL) = destination {
handleExternalVideoDeepLink(externalURL: externalURL, action: action)
return
}
// All other destinations use standard navigation
appEnvironment.navigationCoordinator.navigate(to: destination)
}
/// Handle video deep link based on default link action setting.
private func handleVideoDeepLink(videoID: VideoID, originalURL: URL, action: DefaultLinkAction) {
switch action {
case .open:
// Play directly (existing behavior)
Task {
await playVideoFromDeepLink(videoID: videoID)
}
case .download:
#if !os(tvOS)
// Fetch video and show download sheet
Task {
await downloadVideoFromDeepLink(videoID: videoID)
}
#else
// tvOS doesn't support downloads, fall back to open
Task {
await playVideoFromDeepLink(videoID: videoID)
}
#endif
case .ask:
// Show OpenLinkSheet with URL pre-filled
showOpenLinkSheetWithURL(originalURL)
}
}
/// Handle external video deep link based on default link action setting.
private func handleExternalVideoDeepLink(externalURL: URL, action: DefaultLinkAction) {
switch action {
case .open:
// Navigate to ExternalVideoView (existing behavior)
appEnvironment.navigationCoordinator.navigate(to: .externalVideo(externalURL))
case .download:
#if !os(tvOS)
// Extract and show download sheet
Task {
await downloadExternalVideoFromDeepLink(url: externalURL)
}
#else
// tvOS doesn't support downloads, fall back to open
appEnvironment.navigationCoordinator.navigate(to: .externalVideo(externalURL))
#endif
case .ask:
// Show OpenLinkSheet with URL pre-filled
showOpenLinkSheetWithURL(externalURL)
}
}
/// Show OpenLinkSheet with a pre-filled URL.
private func showOpenLinkSheetWithURL(_ url: URL) {
prefilledLinkURL = url
}
/// Play a video from a deep link.
private func playVideoFromDeepLink(videoID: VideoID) async {
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { return }
do {
let video = try await appEnvironment.contentService.video(
id: videoID.videoID,
instance: instance
)
appEnvironment.playerService.openVideo(video)
} catch {
// If video fetch fails, fall back to navigating to the video info view
appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
}
}
#if !os(tvOS)
/// Download a video from a deep link.
private func downloadVideoFromDeepLink(videoID: VideoID) async {
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else { return }
do {
let (video, streams, captions, _) = try await appEnvironment.contentService
.videoWithProxyStreamsAndCaptionsAndStoryboards(
id: videoID.videoID,
instance: instance
)
await MainActor.run {
deepLinkVideo = video
deepLinkStreams = streams
deepLinkCaptions = captions
showingDeepLinkDownloadSheet = true
}
} catch {
// Fall back to open on error
await playVideoFromDeepLink(videoID: videoID)
}
}
/// Download an external video from a deep link.
private func downloadExternalVideoFromDeepLink(url: URL) async {
guard let instance = appEnvironment.instancesManager.yatteeServerInstance else {
// No Yattee Server - fall back to open
appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url))
return
}
do {
let (video, streams, captions) = try await appEnvironment.contentService
.extractURL(url, instance: instance)
await MainActor.run {
deepLinkVideo = video
deepLinkStreams = streams
deepLinkCaptions = captions
showingDeepLinkDownloadSheet = true
}
} catch {
// Fall back to open on error
appEnvironment.navigationCoordinator.navigate(to: .externalVideo(url))
}
}
#endif
// MARK: - Handoff
/// Handle continued activity from Handoff.
private func handleContinuedActivity(_ activity: NSUserActivity) {
LoggingService.shared.debug("[Handoff] Received activity: \(activity.activityType) - title: \(activity.title ?? "none")", category: .general)
LoggingService.shared.debug("[Handoff] UserInfo: \(activity.userInfo ?? [:])", category: .general)
guard let (destination, playbackTime) = appEnvironment.handoffManager
.restoreDestination(from: activity) else {
LoggingService.shared.debug("[Handoff] Failed to restore destination from activity", category: .general)
return
}
LoggingService.shared.debug("[Handoff] Restored destination: \(destination), playbackTime: \(playbackTime ?? -1)", category: .general)
// For video destinations with playback time, play with resume
if case .video(let source, _) = destination, case .id(let videoID) = source {
Task {
await playVideoFromHandoff(videoID: videoID, startTime: playbackTime)
}
return
}
// For external video with playback time
if case .externalVideo = destination, playbackTime != nil {
// External videos don't support resume time in the same way
// Just navigate to the destination
appEnvironment.navigationCoordinator.navigate(to: destination)
return
}
// All other destinations use standard navigation
appEnvironment.navigationCoordinator.navigate(to: destination)
}
/// Play a video from Handoff continuation.
private func playVideoFromHandoff(videoID: VideoID, startTime: TimeInterval?) async {
LoggingService.shared.debug("[Handoff] playVideoFromHandoff called for: \(videoID.videoID)", category: .general)
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
LoggingService.shared.debug("[Handoff] No instance available, falling back to navigation", category: .general)
// Fall back to navigation if no instance available
appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
return
}
do {
let video = try await appEnvironment.contentService.video(
id: videoID.videoID,
instance: instance
)
LoggingService.shared.debug("[Handoff] Video fetched, calling openVideo", category: .general)
appEnvironment.playerService.openVideo(video, startTime: startTime)
// Always expand player after handoff (unless PiP is active)
// We add a small delay to ensure ContentView has set up its .onChange observers
// (during app launch via Handoff, the trigger can fire before the view is ready)
#if os(iOS)
let isPiPActive = appEnvironment.playerService.state.pipState == .active
#else
let isPiPActive = false
#endif
LoggingService.shared.debug("[Handoff] isPiPActive: \(isPiPActive), isPlayerExpanded: \(appEnvironment.navigationCoordinator.isPlayerExpanded)", category: .general)
if !isPiPActive {
// Small delay to ensure view hierarchy is ready to observe trigger changes
try? await Task.sleep(for: .milliseconds(100))
LoggingService.shared.debug("[Handoff] Expanding player from handoff", category: .general)
appEnvironment.navigationCoordinator.expandPlayer()
}
} catch {
LoggingService.shared.debug("[Handoff] Failed to fetch video: \(error), falling back to navigation", category: .general)
// If video fetch fails, fall back to navigating to the video info view
appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
}
}
// MARK: - Clipboard Detection
/// Check clipboard for external video URLs when app becomes active.
private func checkClipboardForExternalURL() {
#if os(iOS)
guard appEnvironment.settingsManager.clipboardURLDetectionEnabled else { return }
let clipboardURL: URL?
if UIPasteboard.general.hasURLs {
clipboardURL = UIPasteboard.general.url
} else if let string = UIPasteboard.general.string,
let url = URL(string: string),
url.scheme == "http" || url.scheme == "https" {
clipboardURL = url
} else {
clipboardURL = nil
}
guard let url = clipboardURL else { return }
// Skip if we already checked this URL
guard url != lastCheckedClipboardURL else { return }
lastCheckedClipboardURL = url
// Skip YouTube URLs - they're handled by regular deep links
if isYouTubeURL(url) { return }
// Skip known non-video sites
if isExcludedHost(url) { return }
// Only prompt if Yattee Server is configured
guard appEnvironment.instancesManager.yatteeServerInstance != nil else { return }
detectedClipboardURL = url
showingClipboardAlert = true
#elseif os(macOS)
guard appEnvironment.settingsManager.clipboardURLDetectionEnabled else { return }
guard let string = NSPasteboard.general.string(forType: .string),
let url = URL(string: string),
url.scheme == "http" || url.scheme == "https" else {
return
}
// Skip if we already checked this URL
guard url != lastCheckedClipboardURL else { return }
lastCheckedClipboardURL = url
// Skip YouTube URLs
if isYouTubeURL(url) { return }
// Skip known non-video sites
if isExcludedHost(url) { return }
// Only prompt if Yattee Server is configured
guard appEnvironment.instancesManager.yatteeServerInstance != nil else { return }
detectedClipboardURL = url
showingClipboardAlert = true
#endif
}
private func isYouTubeURL(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return false }
let youtubeHosts = ["youtube.com", "www.youtube.com", "m.youtube.com", "youtu.be", "www.youtu.be"]
return youtubeHosts.contains(host)
}
private func isExcludedHost(_ url: URL) -> Bool {
guard let host = url.host?.lowercased() else { return true }
let excludedHosts = [
"google.com", "www.google.com",
"bing.com", "www.bing.com",
"duckduckgo.com",
"apple.com", "www.apple.com",
"github.com", "www.github.com"
]
return excludedHosts.contains(host)
}
}