Yattee v2 rewrite

This commit is contained in:
Arkadiusz Fal
2026-02-08 18:31:16 +01:00
parent 20d0cfc0c7
commit 05f921d605
1043 changed files with 163875 additions and 68430 deletions

View File

@@ -0,0 +1,353 @@
//
// HandoffManager.swift
// Yattee
//
// Manages Apple Handoff activities for seamless cross-device continuation.
//
import Foundation
#if canImport(UIKit)
import UIKit
#elseif canImport(AppKit)
import AppKit
#endif
/// Manages Apple Handoff activities for seamless cross-device continuation.
@MainActor
@Observable
final class HandoffManager {
// MARK: - Activity Type
static let activityType = AppIdentifiers.handoffActivityType
// MARK: - UserInfo Keys
private enum UserInfoKey {
static let destinationType = "destinationType"
static let videoID = "videoID"
static let videoUUID = "videoUUID"
static let videoSource = "videoSource"
static let channelID = "channelID"
static let channelSource = "channelSource"
static let playlistID = "playlistID"
static let playlistSource = "playlistSource"
static let localPlaylistUUID = "localPlaylistUUID"
static let searchQuery = "searchQuery"
static let playbackTime = "playbackTime"
static let externalURL = "externalURL"
static let instanceURL = "instanceURL"
static let instanceType = "instanceType"
}
// MARK: - Destination Types
private enum DestinationType: String {
case video
case channel
case playlist
case localPlaylist
case search
case subscriptions
case continueWatching
case downloads
case history
case bookmarks
case playlists
case channels // Subscribed channels list
case externalVideo
case externalChannel
case instanceBrowse
}
// MARK: - Properties
private var currentActivity: NSUserActivity?
private weak var playerState: PlayerState?
private weak var settingsManager: SettingsManager?
// MARK: - Initialization
init() {}
func setPlayerState(_ state: PlayerState) {
self.playerState = state
}
func setSettingsManager(_ settings: SettingsManager) {
self.settingsManager = settings
}
// MARK: - Activity Creation
/// Updates the current activity for a navigation destination.
func updateActivity(for destination: NavigationDestination) {
// Check if Handoff is enabled in settings
guard settingsManager?.handoffEnabled != false else {
invalidateCurrentActivity()
return
}
// Disable Handoff when incognito mode is active to preserve privacy
guard settingsManager?.incognitoModeEnabled != true else {
invalidateCurrentActivity()
return
}
invalidateCurrentActivity()
let activity = NSUserActivity(activityType: Self.activityType)
activity.isEligibleForHandoff = true
activity.isEligibleForSearch = false
#if os(iOS)
activity.isEligibleForPrediction = false
#endif
guard configureActivity(activity, for: destination) else {
LoggingService.shared.debug("[Handoff] Skipping activity for destination (not eligible)", category: .general)
return
}
currentActivity = activity
activity.becomeCurrent()
LoggingService.shared.debug("[Handoff] Activity became current: \(activity.title ?? "untitled") - type: \(activity.activityType)", category: .general)
}
/// Updates activity with current playback position (call periodically).
func updatePlaybackTime(_ time: TimeInterval) {
guard var userInfo = currentActivity?.userInfo else { return }
userInfo[UserInfoKey.playbackTime] = time
currentActivity?.userInfo = userInfo
currentActivity?.needsSave = true
}
/// Clears the current activity.
func invalidateCurrentActivity() {
if let activity = currentActivity {
LoggingService.shared.debug("[Handoff] Invalidating activity: \(activity.title ?? "untitled")", category: .general)
activity.invalidate()
}
currentActivity = nil
}
// MARK: - Activity Configuration
/// Configures activity for a destination. Returns false if destination shouldn't support handoff.
private func configureActivity(_ activity: NSUserActivity, for destination: NavigationDestination) -> Bool {
var userInfo: [AnyHashable: Any] = [:]
switch destination {
case .video(let source, _):
guard case .id(let videoID) = source else {
return false
}
userInfo[UserInfoKey.destinationType] = DestinationType.video.rawValue
userInfo[UserInfoKey.videoID] = videoID.videoID
if let uuid = videoID.uuid {
userInfo[UserInfoKey.videoUUID] = uuid
}
userInfo[UserInfoKey.videoSource] = encodeSource(videoID.source)
if let time = playerState?.currentTime, time > 0 {
userInfo[UserInfoKey.playbackTime] = time
}
activity.title = playerState?.currentVideo?.title ?? "Video"
case .channel(let channelID, let source):
userInfo[UserInfoKey.destinationType] = DestinationType.channel.rawValue
userInfo[UserInfoKey.channelID] = channelID
userInfo[UserInfoKey.channelSource] = encodeSource(source)
activity.title = "Channel"
case .playlist(let source):
switch source {
case .local(let uuid, _):
userInfo[UserInfoKey.destinationType] = DestinationType.localPlaylist.rawValue
userInfo[UserInfoKey.localPlaylistUUID] = uuid.uuidString
activity.title = "Playlist"
case .remote(let playlistID, _, _):
userInfo[UserInfoKey.destinationType] = DestinationType.playlist.rawValue
userInfo[UserInfoKey.playlistID] = playlistID.playlistID
if let contentSource = playlistID.source {
userInfo[UserInfoKey.playlistSource] = encodeSource(contentSource)
}
activity.title = "Playlist"
}
case .search(let query):
userInfo[UserInfoKey.destinationType] = DestinationType.search.rawValue
userInfo[UserInfoKey.searchQuery] = query
activity.title = "Search: \(query)"
case .externalVideo(let url):
userInfo[UserInfoKey.destinationType] = DestinationType.externalVideo.rawValue
userInfo[UserInfoKey.externalURL] = url.absoluteString
if let time = playerState?.currentTime, time > 0 {
userInfo[UserInfoKey.playbackTime] = time
}
activity.title = "External Video"
case .externalChannel(let url):
userInfo[UserInfoKey.destinationType] = DestinationType.externalChannel.rawValue
userInfo[UserInfoKey.externalURL] = url.absoluteString
activity.title = "External Channel"
case .subscriptionsFeed:
userInfo[UserInfoKey.destinationType] = DestinationType.subscriptions.rawValue
activity.title = "Subscriptions"
case .continueWatching:
userInfo[UserInfoKey.destinationType] = DestinationType.continueWatching.rawValue
activity.title = "Continue Watching"
case .downloads:
userInfo[UserInfoKey.destinationType] = DestinationType.downloads.rawValue
activity.title = "Downloads"
case .history:
userInfo[UserInfoKey.destinationType] = DestinationType.history.rawValue
activity.title = "History"
case .bookmarks:
userInfo[UserInfoKey.destinationType] = DestinationType.bookmarks.rawValue
activity.title = "Bookmarks"
case .playlists:
userInfo[UserInfoKey.destinationType] = DestinationType.playlists.rawValue
activity.title = "Playlists"
case .manageChannels:
userInfo[UserInfoKey.destinationType] = DestinationType.channels.rawValue
activity.title = "Channels"
case .instanceBrowse(let instance, _):
userInfo[UserInfoKey.destinationType] = DestinationType.instanceBrowse.rawValue
userInfo[UserInfoKey.instanceURL] = instance.url.absoluteString
userInfo[UserInfoKey.instanceType] = instance.type.rawValue
activity.title = instance.displayName
// Don't create handoff activities for these local-only destinations
case .settings, .mediaSources, .mediaSource, .mediaBrowser,
.importSubscriptions, .importPlaylists, .downloadsStorage, .directMedia:
return false
}
activity.userInfo = userInfo
return true
}
// MARK: - Source Encoding/Decoding
private func encodeSource(_ source: ContentSource) -> [String: Any] {
switch source {
case .global(let provider):
return ["type": "global", "provider": provider]
case .federated(let provider, let instance):
return ["type": "federated", "provider": provider, "instance": instance.absoluteString]
case .extracted(let extractor, let originalURL):
return ["type": "extracted", "extractor": extractor, "originalURL": originalURL.absoluteString]
}
}
private func decodeSource(_ dict: [String: Any]) -> ContentSource? {
guard let type = dict["type"] as? String else { return nil }
switch type {
case "global":
guard let provider = dict["provider"] as? String else { return nil }
return .global(provider: provider)
case "federated":
guard let provider = dict["provider"] as? String,
let instanceStr = dict["instance"] as? String,
let instance = URL(string: instanceStr) else { return nil }
return .federated(provider: provider, instance: instance)
case "extracted":
guard let extractor = dict["extractor"] as? String,
let urlStr = dict["originalURL"] as? String,
let url = URL(string: urlStr) else { return nil }
return .extracted(extractor: extractor, originalURL: url)
default:
return nil
}
}
// MARK: - Activity Restoration
/// Restores navigation from a received activity.
/// Returns the destination and optional playback time.
func restoreDestination(from activity: NSUserActivity) -> (NavigationDestination, TimeInterval?)? {
guard activity.activityType == Self.activityType,
let userInfo = activity.userInfo,
let typeString = userInfo[UserInfoKey.destinationType] as? String,
let destType = DestinationType(rawValue: typeString) else {
return nil
}
let playbackTime = userInfo[UserInfoKey.playbackTime] as? TimeInterval
switch destType {
case .video:
guard let videoID = userInfo[UserInfoKey.videoID] as? String,
let sourceDict = userInfo[UserInfoKey.videoSource] as? [String: Any],
let source = decodeSource(sourceDict) else { return nil }
let uuid = userInfo[UserInfoKey.videoUUID] as? String
return (.video(.id(VideoID(source: source, videoID: videoID, uuid: uuid))), playbackTime)
case .channel:
guard let channelID = userInfo[UserInfoKey.channelID] as? String,
let sourceDict = userInfo[UserInfoKey.channelSource] as? [String: Any],
let source = decodeSource(sourceDict) else { return nil }
return (.channel(channelID, source), nil)
case .playlist:
guard let playlistID = userInfo[UserInfoKey.playlistID] as? String else { return nil }
let source: ContentSource?
if let sourceDict = userInfo[UserInfoKey.playlistSource] as? [String: Any] {
source = decodeSource(sourceDict)
} else {
source = nil
}
return (.playlist(.remote(PlaylistID(source: source, playlistID: playlistID), instance: nil)), nil)
case .localPlaylist:
guard let uuidString = userInfo[UserInfoKey.localPlaylistUUID] as? String,
let uuid = UUID(uuidString: uuidString) else { return nil }
return (.playlist(.local(uuid)), nil)
case .search:
guard let query = userInfo[UserInfoKey.searchQuery] as? String else { return nil }
return (.search(query), nil)
case .externalVideo:
guard let urlString = userInfo[UserInfoKey.externalURL] as? String,
let url = URL(string: urlString) else { return nil }
return (.externalVideo(url), playbackTime)
case .externalChannel:
guard let urlString = userInfo[UserInfoKey.externalURL] as? String,
let url = URL(string: urlString) else { return nil }
return (.externalChannel(url), nil)
case .subscriptions:
return (.subscriptionsFeed, nil)
case .continueWatching:
return (.continueWatching, nil)
case .downloads:
return (.downloads, nil)
case .history:
return (.history, nil)
case .bookmarks:
return (.bookmarks, nil)
case .playlists:
return (.playlists, nil)
case .channels:
return (.manageChannels, nil)
case .instanceBrowse:
guard let urlString = userInfo[UserInfoKey.instanceURL] as? String,
let url = URL(string: urlString),
let typeString = userInfo[UserInfoKey.instanceType] as? String,
let instanceType = InstanceType(rawValue: typeString) else { return nil }
let instance = Instance(type: instanceType, url: url)
return (.instanceBrowse(instance), nil)
}
}
}