mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Remote playlist entries carried the display row number as their server position, so a row the app could not parse shifted every removal after it by one, and rows were paired back to entries by video id, which cannot tell two copies of the same video apart. - Piped entry positions are now the raw stream index; entries carry a separate displayRow for the row they are drawn as, filled in by both snapshot builders and used by the detail view's row map - an emptied description clears a server-side playlist instead of being read as "leave it alone" - a delete that reported failure forgets a playlist the refetch 404s on, and drops it from the account's cached list - the browse view discards account-scoped content when a status change moves which account the instance resolves to - every mutation re-reads the credential immediately before it is sent, which matters most after Piped's verify-before-remove fetch - Handoff carries the owning account id, so a Piped playlist keeps its ownership on the receiving device
379 lines
15 KiB
Swift
379 lines
15 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 playlistAccountID = "playlistAccountID"
|
|
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"
|
|
// The owning account travels with the playlist. Account ids are the
|
|
// same on every device the login is synced to, so the receiver can
|
|
// resolve one — and without it a Piped playlist arrives as nobody's:
|
|
// its id carries no hint of ownership the way an Invidious `IVPL`
|
|
// one does, so a private playlist would be loaded through the public
|
|
// endpoint and fail. A receiver that does not know the account is no
|
|
// worse off than before: the key is simply absent, which is also
|
|
// what an older payload looks like.
|
|
case .remote(let playlistID, _, _, let accountID):
|
|
userInfo[UserInfoKey.destinationType] = DestinationType.playlist.rawValue
|
|
userInfo[UserInfoKey.playlistID] = playlistID.playlistID
|
|
if let contentSource = playlistID.source {
|
|
userInfo[UserInfoKey.playlistSource] = encodeSource(contentSource)
|
|
}
|
|
if let accountID {
|
|
userInfo[UserInfoKey.playlistAccountID] = accountID.uuidString
|
|
}
|
|
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
|
|
}
|
|
// An account this device does not have resolves to nil in the
|
|
// detail view, which falls back to the ownership it can infer —
|
|
// exactly what a payload without the key does.
|
|
let accountID = (userInfo[UserInfoKey.playlistAccountID] as? String).flatMap(UUID.init(uuidString:))
|
|
return (
|
|
.playlist(
|
|
.remote(
|
|
PlaylistID(source: source, playlistID: playlistID),
|
|
instance: nil,
|
|
accountID: accountID
|
|
)
|
|
),
|
|
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)
|
|
}
|
|
}
|
|
}
|