mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
354 lines
14 KiB
Swift
354 lines
14 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
}
|