mirror of
https://github.com/yattee/yattee.git
synced 2026-06-04 13:54:19 +00:00
Tapping bit.ly/tinyurl/t.co/etc. in a description or comment previously opened Safari even when the destination was a playable YouTube URL. Added an opt-in "Resolve Short Links" toggle under YouTube Enhancements (off by default) that follows the redirect on tap: if the target is a YouTube/PeerTube/direct-media URL, open it in-app; otherwise prompt the user before falling back to yt-dlp extraction or the browser. Also added a confirmation dialog for non-shortener links that only matched the loose .externalVideo yt-dlp fallback, so arbitrary web pages in descriptions no longer silently kick off extraction. Prompts live on NavigationCoordinator and are dual-hosted by YatteeApp and ExpandedPlayerSheet so they remain visible whether or not the expanded player is covering the main view.
799 lines
32 KiB
Swift
799 lines
32 KiB
Swift
//
|
|
// YatteeApp.swift
|
|
// Yattee
|
|
//
|
|
// Main application entry point.
|
|
//
|
|
|
|
import SwiftUI
|
|
import Combine
|
|
import CloudKit
|
|
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
|
|
|
|
// First-launch state
|
|
@State private var showingICloudAlert = false
|
|
@State private var showingICloudProgress = false
|
|
#if os(iOS)
|
|
@State private var showingSettings = false
|
|
#endif
|
|
@State private var showingOpenLinkSheet = false
|
|
|
|
// Ambiguous-link prompts are stored on NavigationCoordinator so they can
|
|
// be dual-hosted by the root app view (here) and the expanded player sheet.
|
|
|
|
init() {
|
|
// Configure Nuke image loading pipeline
|
|
ImageLoadingService.shared.configure()
|
|
|
|
// Register background tasks early — Apple requires BGTaskScheduler.register()
|
|
// to be called during the app launch sequence, before the run loop starts.
|
|
appEnvironment.backgroundRefreshManager.registerBackgroundTasks()
|
|
}
|
|
|
|
var body: some Scene {
|
|
WindowGroup {
|
|
ContentView()
|
|
.appEnvironment(appEnvironment)
|
|
#if !os(tvOS)
|
|
.preferredColorScheme(appEnvironment.settingsManager.theme.colorScheme)
|
|
.tint(appEnvironment.settingsManager.accentColor.color)
|
|
#endif
|
|
#if os(macOS)
|
|
.frame(minWidth: 800, minHeight: 500)
|
|
// 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()
|
|
#if os(tvOS)
|
|
TopShelfSnapshotWriter.startObserving(dataManager: appEnvironment.dataManager)
|
|
TopShelfSnapshotWriter.writeAll(
|
|
dataManager: appEnvironment.dataManager,
|
|
settingsManager: appEnvironment.settingsManager
|
|
)
|
|
#endif
|
|
}
|
|
.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)
|
|
}
|
|
.sheet(item: Binding<Video?>(
|
|
get: {
|
|
// Only host the sheet here when the player is not expanded.
|
|
// When expanded, ExpandedPlayerSheet hosts it so it appears above the player window.
|
|
guard !appEnvironment.navigationCoordinator.isPlayerExpanded else { return nil }
|
|
return appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo
|
|
},
|
|
set: { newValue in
|
|
if newValue == nil {
|
|
appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = nil
|
|
}
|
|
}
|
|
)) { video in
|
|
QueueActionSheet(video: video)
|
|
.appEnvironment(appEnvironment)
|
|
}
|
|
#if !os(tvOS)
|
|
.sheet(isPresented: $showingDeepLinkDownloadSheet) {
|
|
if let video = deepLinkVideo {
|
|
DownloadQualitySheet(
|
|
video: video,
|
|
streams: deepLinkStreams,
|
|
captions: deepLinkCaptions
|
|
)
|
|
.appEnvironment(appEnvironment)
|
|
}
|
|
}
|
|
#endif
|
|
#endif
|
|
// First-launch iCloud sync prompt
|
|
.alert(
|
|
String(localized: "settings.icloud.enable.confirmation.title"),
|
|
isPresented: $showingICloudAlert
|
|
) {
|
|
Button(String(localized: "settings.icloud.enable.confirmation.action")) {
|
|
enableICloudAndWait()
|
|
}
|
|
Button(String(localized: "common.cancel"), role: .cancel) {
|
|
appEnvironment.settingsManager.onboardingCompleted = true
|
|
}
|
|
} message: {
|
|
Text(String(localized: "settings.icloud.enable.confirmation.message"))
|
|
}
|
|
#if os(macOS)
|
|
.sheet(isPresented: $showingICloudProgress) {
|
|
ICloudSyncProgressView()
|
|
.appEnvironment(appEnvironment)
|
|
.interactiveDismissDisabled()
|
|
}
|
|
#else
|
|
.fullScreenCover(isPresented: $showingICloudProgress) {
|
|
ICloudSyncProgressView()
|
|
.appEnvironment(appEnvironment)
|
|
}
|
|
#endif
|
|
#if os(iOS)
|
|
.sheet(isPresented: $showingSettings) {
|
|
SettingsView()
|
|
.appEnvironment(appEnvironment)
|
|
}
|
|
#endif
|
|
#if !os(tvOS)
|
|
.sheet(isPresented: $showingOpenLinkSheet) {
|
|
OpenLinkSheet()
|
|
.appEnvironment(appEnvironment)
|
|
}
|
|
#endif
|
|
#if os(iOS)
|
|
.onReceive(NotificationCenter.default.publisher(for: .showSettings)) { _ in
|
|
showingSettings = true
|
|
}
|
|
#endif
|
|
.onReceive(NotificationCenter.default.publisher(for: .showOpenLinkSheet)) { _ in
|
|
appEnvironment.navigationCoordinator.isPlayerExpanded = false
|
|
showingOpenLinkSheet = true
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .openDescriptionLink)) { notification in
|
|
if let url = notification.object as? URL {
|
|
handleDescriptionLink(url)
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .promptResolvedShortLink)) { notification in
|
|
if let url = notification.object as? URL {
|
|
appEnvironment.navigationCoordinator.resolvedShortLinkPrompt = url
|
|
}
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .promptAmbiguousExternalLink)) { notification in
|
|
if let url = notification.object as? URL {
|
|
appEnvironment.navigationCoordinator.ambiguousExternalLinkPrompt = url
|
|
}
|
|
}
|
|
// Present confirmation dialogs at root level only when the expanded
|
|
// player isn't covering it. ExpandedPlayerSheet hosts the same
|
|
// dialogs when it *is* expanded, so they always sit on top.
|
|
.resolvedLinkPrompts(
|
|
shouldHost: !appEnvironment.navigationCoordinator.isPlayerExpanded,
|
|
appEnvironment: appEnvironment
|
|
)
|
|
}
|
|
#if os(macOS)
|
|
.windowStyle(.hiddenTitleBar)
|
|
.windowToolbarStyle(.unified)
|
|
.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
|
|
#if os(macOS)
|
|
Window(String(localized: "menu.app.settings"), id: "settings") {
|
|
SettingsView(showCloseButton: false)
|
|
.appEnvironment(appEnvironment)
|
|
.frame(minWidth: 600, idealWidth: 900, maxWidth: .infinity, minHeight: 520, idealHeight: 600, maxHeight: .infinity)
|
|
}
|
|
.windowResizability(.contentMinSize)
|
|
.defaultSize(width: 900, height: 600)
|
|
.commands {
|
|
CommandGroup(replacing: .appSettings) {
|
|
SettingsWindowMenuItem()
|
|
}
|
|
#if SPARKLE
|
|
CommandGroup(after: .appInfo) {
|
|
CheckForUpdatesMenuItem()
|
|
}
|
|
#endif
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func registerBackgroundTasksIfNeeded() {
|
|
guard !backgroundTasksRegistered else { return }
|
|
backgroundTasksRegistered = true
|
|
|
|
// 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()
|
|
|
|
// Run first-launch tasks: silently import v1 data, then offer iCloud sync.
|
|
if !appEnvironment.settingsManager.onboardingCompleted {
|
|
Task {
|
|
await appEnvironment.legacyMigrationService.autoImportIfNeeded()
|
|
await appEnvironment.cloudKitSync.refreshAccountStatus()
|
|
|
|
if appEnvironment.cloudKitSync.accountStatus == .available {
|
|
// Small delay so the main UI has time to settle before the alert.
|
|
try? await Task.sleep(nanoseconds: 500_000_000)
|
|
showingICloudAlert = true
|
|
} else {
|
|
appEnvironment.settingsManager.onboardingCompleted = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Enables iCloud sync and waits for the initial upload to complete,
|
|
/// showing a blocking progress overlay while sync runs.
|
|
private func enableICloudAndWait() {
|
|
showingICloudProgress = true
|
|
Task {
|
|
let settings = appEnvironment.settingsManager
|
|
let cloudKit = appEnvironment.cloudKitSync
|
|
|
|
settings.iCloudSyncEnabled = true
|
|
settings.enableAllSyncCategories()
|
|
|
|
await cloudKit.enable()
|
|
await cloudKit.performInitialUpload()
|
|
|
|
settings.replaceWithiCloudData()
|
|
appEnvironment.instancesManager.replaceWithiCloudData()
|
|
appEnvironment.mediaSourcesManager.replaceWithiCloudData()
|
|
|
|
settings.onboardingCompleted = true
|
|
showingICloudProgress = false
|
|
}
|
|
}
|
|
|
|
/// 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 a link tapped inside a video description.
|
|
/// Video URLs that would interrupt active playback surface the queue sheet;
|
|
/// everything else reuses the standard deep-link pipeline.
|
|
private func handleDescriptionLink(_ url: URL) {
|
|
let router = URLRouter()
|
|
guard let destination = router.route(url) else { return }
|
|
|
|
if case .video(let source, _) = destination, case .id(let videoID) = source {
|
|
let queueEnabled = appEnvironment.settingsManager.queueEnabled
|
|
let somethingPlaying = appEnvironment.playerService.state.currentVideo != nil
|
|
if queueEnabled && somethingPlaying {
|
|
Task { await presentQueueSheetFromDeepLink(videoID: videoID) }
|
|
return
|
|
}
|
|
}
|
|
|
|
handleDeepLink(url)
|
|
}
|
|
|
|
/// Fetch a video by ID and present the queue action sheet for it.
|
|
private func presentQueueSheetFromDeepLink(videoID: VideoID) async {
|
|
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "deepLink.noInstance.title", defaultValue: "Can't open video"),
|
|
subtitle: String(
|
|
localized: "deepLink.noInstance.subtitle",
|
|
defaultValue: "No source is configured for this video."
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
let toastID = appEnvironment.toastManager.show(
|
|
scopes: [.main, .player],
|
|
category: .loading,
|
|
title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"),
|
|
subtitle: instance.name,
|
|
autoDismissDelay: 30.0
|
|
)
|
|
|
|
do {
|
|
let video = try await appEnvironment.contentService.video(
|
|
id: videoID.videoID,
|
|
instance: instance
|
|
)
|
|
appEnvironment.toastManager.dismiss(id: toastID)
|
|
appEnvironment.navigationCoordinator.descriptionLinkQueueSheetVideo = video
|
|
} catch {
|
|
appEnvironment.toastManager.dismiss(id: toastID)
|
|
appEnvironment.navigationCoordinator.navigate(to: .video(.id(videoID)))
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
LoggingService.shared.info(
|
|
"Deep link play: videoID=\(videoID.videoID) source=\(videoID.source)",
|
|
category: .general
|
|
)
|
|
guard let instance = appEnvironment.instancesManager.instance(for: videoID.source) else {
|
|
LoggingService.shared.warning(
|
|
"Deep link play: no instance configured for source \(videoID.source) — aborting",
|
|
category: .general
|
|
)
|
|
appEnvironment.toastManager.showError(
|
|
String(localized: "deepLink.noInstance.title", defaultValue: "Can't open video"),
|
|
subtitle: String(
|
|
localized: "deepLink.noInstance.subtitle",
|
|
defaultValue: "No source is configured for this video."
|
|
)
|
|
)
|
|
return
|
|
}
|
|
LoggingService.shared.info(
|
|
"Deep link play: using instance \(instance.url.absoluteString)",
|
|
category: .general
|
|
)
|
|
|
|
let toastID = appEnvironment.toastManager.show(
|
|
scopes: [.main, .player],
|
|
category: .loading,
|
|
title: String(localized: "deepLink.loading.title", defaultValue: "Loading video…"),
|
|
subtitle: instance.name,
|
|
autoDismissDelay: 30.0
|
|
)
|
|
|
|
do {
|
|
let video = try await appEnvironment.contentService.video(
|
|
id: videoID.videoID,
|
|
instance: instance
|
|
)
|
|
LoggingService.shared.info("Deep link play: fetched video, opening player", category: .general)
|
|
appEnvironment.toastManager.dismiss(id: toastID)
|
|
appEnvironment.playerService.openVideo(video)
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Deep link play: video fetch failed (\(error.localizedDescription)), falling back to info view",
|
|
category: .general
|
|
)
|
|
appEnvironment.toastManager.dismiss(id: toastID)
|
|
// 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)
|
|
}
|
|
}
|