mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Yattee 2 keys credentials by instance UUID with no notion of which Apple account they belong to, and reads them back with SynchronizableAny. Apple documents that Keychain data may stay on device when a user signs out and chooses to keep it, so whoever signs in next would be served the previous user's credential - guaranteed for migrated accounts, since local instances survive the switch and the migrated account UUID is deterministically the instance UUID. Credentials are now keyed "<identityScope>|<accountID>", where the scope is the CloudKit user record (identical across a user's devices, so iCloud Keychain sync still works). Usernames get the same scope and live beside the credential, keeping login identifiers out of synced metadata. The legacy unscoped item is never adopted automatically. A strict kSecAttrSynchronizable == true query was the candidate discriminator for "this secret belongs to the current Apple account", but measured on iPadOS 27.0 a retained item keeps the attribute and still matches that query with no Apple account signed in at all - the flag records what was once requested, not live iCloud Keychain membership. Reads therefore never fall back to the legacy key; adoption is an explicit user action that copies the item, verifies the scoped read-back, then records a marker bound to the identity and a salted fingerprint of the secret. The legacy item is retained, because deleting a synchronizable item propagates and would log out devices still on the previous release mid-rollout. Deleting it later needs all three of: a marker on this device, matching identity, and the item still holding the adopted secret. The instance-keyed path is untouched, so no shipping call site changes behavior. BasicAuthCredentialsManager stays instance-keyed - proxy auth belongs to the instance, not to a user account. While the scope is unresolved (first launch under a new Apple account, CloudKit unreachable) reads report "can't tell yet" via isScopeResolved rather than "signed out", and writes are refused rather than guessing a scope. Login state is cached per scope so one identity's cache can never answer for another. Also adds the debug-only harness the measurement was taken with, under Advanced > Developer > Experiments.
533 lines
21 KiB
Swift
533 lines
21 KiB
Swift
//
|
|
// AppEnvironment.swift
|
|
// Yattee
|
|
//
|
|
// Dependency injection container for the application.
|
|
//
|
|
|
|
import Foundation
|
|
import SwiftUI
|
|
import SwiftData
|
|
|
|
/// Main dependency injection container that holds all app services.
|
|
/// Passed through the SwiftUI environment to provide dependencies to views.
|
|
@MainActor
|
|
@Observable
|
|
final class AppEnvironment {
|
|
// MARK: - Services
|
|
|
|
let settingsManager: SettingsManager
|
|
let instancesManager: InstancesManager
|
|
let contentService: ContentService
|
|
let instanceDetector: InstanceDetector
|
|
let dataManager: DataManager
|
|
let subscriptionService: SubscriptionService
|
|
let navigationCoordinator: NavigationCoordinator
|
|
let downloadManager: DownloadManager
|
|
let downloadSettings: DownloadSettings
|
|
let playerService: PlayerService
|
|
let queueManager: QueueManager
|
|
let cloudKitSync: CloudKitSyncEngine
|
|
let deArrowBrandingProvider: DeArrowBrandingProvider
|
|
let notificationManager: NotificationManager
|
|
let backgroundRefreshManager: BackgroundRefreshManager
|
|
let mediaSourcesManager: MediaSourcesManager
|
|
let webDAVClient: WebDAVClient
|
|
let webDAVClientFactory: WebDAVClientFactory
|
|
let smbClient: SMBClient
|
|
let localFileClient: LocalFileClient
|
|
let urlSessionFactory: URLSessionFactory
|
|
let httpClientFactory: HTTPClientFactory
|
|
let localNetworkService: LocalNetworkService
|
|
let remoteControlCoordinator: RemoteControlCoordinator
|
|
let networkShareDiscoveryService: NetworkShareDiscoveryService
|
|
let connectivityMonitor: ConnectivityMonitor
|
|
let httpClient: HTTPClient
|
|
let toastManager: ToastManager
|
|
let handoffManager: HandoffManager
|
|
let invidiousCredentialsManager: InvidiousCredentialsManager
|
|
let pipedCredentialsManager: PipedCredentialsManager
|
|
let basicAuthCredentialsManager: BasicAuthCredentialsManager
|
|
/// Shared by both account credential managers so a single CloudKit lookup
|
|
/// resolves the scope for all of them.
|
|
let credentialIdentityScope: CredentialIdentityScope
|
|
let homeInstanceCache: HomeInstanceCache
|
|
let invidiousAPI: InvidiousAPI
|
|
let pipedAPI: PipedAPI
|
|
let subscriptionAccountValidator: SubscriptionAccountValidator
|
|
let playerControlsLayoutService: PlayerControlsLayoutService
|
|
let legacyMigrationService: LegacyDataMigrationService
|
|
let sourcesSettings: SourcesSettings
|
|
|
|
/// Center-section settings of the active player controls preset, cached for
|
|
/// synchronous access. Menu bar commands read seek durations from here since
|
|
/// they cannot await the layout service actor.
|
|
private(set) var activeControlsCenterSettings: CenterSectionSettings = .default
|
|
|
|
@ObservationIgnored private var controlsSettingsObservers: [NSObjectProtocol] = []
|
|
|
|
// MARK: - Shared Instance
|
|
|
|
/// The single, process-wide app environment.
|
|
///
|
|
/// SwiftUI may evaluate a `@State` property's default-value autoclosure more
|
|
/// than once (it keeps only the first result but still runs the side effects
|
|
/// of the discarded instances). Constructing `AppEnvironment` more than once
|
|
/// would create multiple `DownloadManager`s — and therefore multiple
|
|
/// background `URLSession`s registered under the same identifier — causing
|
|
/// download-completion delegate callbacks to be delivered to an instance
|
|
/// whose `activeDownloads` is empty (the finished file is then dropped).
|
|
/// Referencing this `static let` from the App's `@State` guarantees exactly
|
|
/// one instance for the lifetime of the process.
|
|
static let shared = AppEnvironment()
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
httpClient: HTTPClient? = nil,
|
|
settingsManager: SettingsManager? = nil,
|
|
instancesManager: InstancesManager? = nil,
|
|
dataManager: DataManager? = nil,
|
|
navigationCoordinator: NavigationCoordinator? = nil,
|
|
downloadManager: DownloadManager? = nil
|
|
) {
|
|
let client = httpClient ?? HTTPClient()
|
|
self.httpClient = client
|
|
|
|
let settings = settingsManager ?? SettingsManager()
|
|
self.settingsManager = settings
|
|
|
|
// Configure HTTP client with custom User-Agent
|
|
Task {
|
|
await client.setUserAgent(settings.customUserAgent)
|
|
await client.setRandomizeUserAgentPerRequest(settings.randomizeUserAgentPerRequest)
|
|
}
|
|
|
|
let instances = instancesManager ?? InstancesManager(settingsManager: settings)
|
|
instances.setSettingsManager(settings)
|
|
self.instancesManager = instances
|
|
|
|
// Initialize Basic Auth Credentials Manager early (needed for ContentService)
|
|
let basicAuthCreds = BasicAuthCredentialsManager()
|
|
basicAuthCreds.settingsManager = settings
|
|
self.basicAuthCredentialsManager = basicAuthCreds
|
|
|
|
let contentSvc = ContentService(httpClient: client, basicAuthCredentialsManager: basicAuthCreds)
|
|
self.contentService = contentSvc
|
|
self.instanceDetector = InstanceDetector(httpClient: client)
|
|
self.navigationCoordinator = navigationCoordinator ?? NavigationCoordinator()
|
|
self.downloadManager = downloadManager ?? DownloadManager()
|
|
self.downloadSettings = DownloadSettings()
|
|
|
|
// Initialize DataManager, falling back to in-memory for failures
|
|
let dm: DataManager
|
|
if let manager = dataManager {
|
|
dm = manager
|
|
} else {
|
|
do {
|
|
dm = try DataManager(iCloudSyncEnabled: settings.iCloudSyncEnabled)
|
|
} catch {
|
|
// Fall back to in-memory storage if persistent storage fails
|
|
dm = try! DataManager(inMemory: true)
|
|
}
|
|
}
|
|
self.dataManager = dm
|
|
|
|
// Resolve which Apple account owns this device's credentials before the
|
|
// managers that key items by it are built. Cached scopes restore
|
|
// synchronously here; a first-ever resolution needs the network and is
|
|
// kicked off below.
|
|
let identityScope = CredentialIdentityScope()
|
|
self.credentialIdentityScope = identityScope
|
|
|
|
// Initialize Invidious Credentials Manager (needed for SubscriptionService)
|
|
let invidiousCreds = InvidiousCredentialsManager(identityScope: identityScope)
|
|
invidiousCreds.settingsManager = settings
|
|
self.invidiousCredentialsManager = invidiousCreds
|
|
|
|
// Initialize Piped Credentials Manager
|
|
let pipedCreds = PipedCredentialsManager(identityScope: identityScope)
|
|
pipedCreds.settingsManager = settings
|
|
self.pipedCredentialsManager = pipedCreds
|
|
|
|
// Initialize Invidious API (used by SubscriptionService and SubscriptionFeedCache)
|
|
let invidiousAPI = InvidiousAPI(httpClient: client)
|
|
self.invidiousAPI = invidiousAPI
|
|
|
|
// Initialize Piped API (used by SubscriptionService and SubscriptionFeedCache)
|
|
let pipedAPI = PipedAPI(httpClient: client)
|
|
self.pipedAPI = pipedAPI
|
|
|
|
// Initialize SubscriptionService with all required dependencies
|
|
self.subscriptionService = SubscriptionService(
|
|
dataManager: dm,
|
|
settingsManager: settings,
|
|
instancesManager: instances,
|
|
invidiousCredentialsManager: invidiousCreds,
|
|
pipedCredentialsManager: pipedCreds,
|
|
invidiousAPI: invidiousAPI,
|
|
pipedAPI: pipedAPI
|
|
)
|
|
|
|
// Initialize CloudKit Sync Engine
|
|
let cloudKit = CloudKitSyncEngine(
|
|
dataManager: dm,
|
|
settingsManager: settings,
|
|
instancesManager: instances
|
|
)
|
|
self.cloudKitSync = cloudKit
|
|
dm.cloudKitSync = cloudKit
|
|
|
|
// Initialize DeArrow with low-priority networking
|
|
let lowPrioritySession = URLSessionFactory.shared.lowPrioritySession()
|
|
let deArrowHTTPClient = HTTPClient(session: lowPrioritySession)
|
|
let deArrowAPI = DeArrowAPI(httpClient: deArrowHTTPClient, urlSession: lowPrioritySession)
|
|
let deArrowProvider = DeArrowBrandingProvider(api: deArrowAPI)
|
|
deArrowProvider.setSettingsManager(settings)
|
|
self.deArrowBrandingProvider = deArrowProvider
|
|
|
|
// Initialize PlayerService
|
|
let downloads = self.downloadManager
|
|
let player = PlayerService(
|
|
httpClient: client,
|
|
contentService: contentSvc,
|
|
dataManager: dm
|
|
)
|
|
player.setInstancesManager(instances)
|
|
player.setSettingsManager(settings)
|
|
player.setDownloadManager(downloads)
|
|
player.setNavigationCoordinator(self.navigationCoordinator)
|
|
player.setDeArrowBrandingProvider(deArrowProvider)
|
|
self.playerService = player
|
|
|
|
// Initialize QueueManager
|
|
let queue = QueueManager(contentService: contentSvc)
|
|
queue.setPlayerState(player.state)
|
|
queue.setPlayerService(player)
|
|
queue.setSettingsManager(settings)
|
|
queue.setInstancesManager(instances)
|
|
queue.setDownloadManager(downloads)
|
|
player.setQueueManager(queue)
|
|
self.queueManager = queue
|
|
|
|
// Initialize Notification & Background Refresh managers
|
|
let notifManager = NotificationManager()
|
|
#if !os(tvOS)
|
|
notifManager.registerNotificationCategories()
|
|
#endif
|
|
self.notificationManager = notifManager
|
|
|
|
let bgRefreshManager = BackgroundRefreshManager(notificationManager: notifManager)
|
|
self.backgroundRefreshManager = bgRefreshManager
|
|
|
|
// Initialize URL Session and Client Factories
|
|
let sessionFactory = URLSessionFactory.shared
|
|
self.urlSessionFactory = sessionFactory
|
|
self.httpClientFactory = HTTPClientFactory(sessionFactory: sessionFactory)
|
|
self.webDAVClientFactory = WebDAVClientFactory(sessionFactory: sessionFactory)
|
|
|
|
// Initialize Media Sources components
|
|
let mediaSources = MediaSourcesManager(settingsManager: settings)
|
|
self.mediaSourcesManager = mediaSources
|
|
mediaSources.setDataManager(dm)
|
|
|
|
// Initialize media clients
|
|
self.webDAVClient = WebDAVClient()
|
|
self.smbClient = SMBClient()
|
|
self.localFileClient = LocalFileClient()
|
|
|
|
// Wire up media services to player
|
|
player.setMediaSourcesManager(mediaSources)
|
|
self.navigationCoordinator.setMediaSourcesManager(mediaSources)
|
|
player.setSMBClient(self.smbClient)
|
|
player.setWebDAVClient(self.webDAVClient)
|
|
player.setLocalFileClient(self.localFileClient)
|
|
|
|
// Wire up SMB client to check if SMB playback is active
|
|
// This prevents crashes from concurrent libsmbclient usage
|
|
let smbClientRef = self.smbClient
|
|
Task { [weak player] in
|
|
await smbClientRef.setPlaybackActiveCallback { [weak player] in
|
|
player?.state.isSMBPlaybackActive ?? false
|
|
}
|
|
}
|
|
|
|
// Initialize Remote Control components
|
|
let networkService = LocalNetworkService()
|
|
self.localNetworkService = networkService
|
|
self.networkShareDiscoveryService = NetworkShareDiscoveryService()
|
|
let remoteControl = RemoteControlCoordinator(networkService: networkService)
|
|
remoteControl.setPlayerService(player)
|
|
remoteControl.setContentService(contentSvc)
|
|
remoteControl.setInstancesManager(instances)
|
|
remoteControl.setNavigationCoordinator(self.navigationCoordinator)
|
|
remoteControl.setMediaSourcesManager(mediaSources)
|
|
remoteControl.setSettingsManager(settings)
|
|
self.remoteControlCoordinator = remoteControl
|
|
|
|
// Restore remote control enabled state (after all services are set up)
|
|
remoteControl.restoreEnabledState()
|
|
|
|
// Initialize Connectivity Monitor for network-aware quality selection
|
|
let connectivity = ConnectivityMonitor()
|
|
self.connectivityMonitor = connectivity
|
|
player.setConnectivityMonitor(connectivity)
|
|
|
|
// Initialize Toast Manager
|
|
let toast = ToastManager()
|
|
self.toastManager = toast
|
|
toast.setNavigationCoordinator(self.navigationCoordinator)
|
|
remoteControl.setToastManager(toast)
|
|
self.downloadManager.setToastManager(toast)
|
|
self.downloadManager.setDownloadSettings(self.downloadSettings)
|
|
|
|
// Initialize Handoff Manager
|
|
let handoff = HandoffManager()
|
|
handoff.setPlayerState(player.state)
|
|
handoff.setSettingsManager(settings)
|
|
self.handoffManager = handoff
|
|
self.navigationCoordinator.setHandoffManager(handoff)
|
|
player.setHandoffManager(handoff)
|
|
|
|
// Initialize Home Instance Cache
|
|
self.homeInstanceCache = .shared
|
|
|
|
// Initialize Subscription Account Validator
|
|
self.subscriptionAccountValidator = SubscriptionAccountValidator(
|
|
settingsManager: settings,
|
|
instancesManager: instances,
|
|
invidiousCredentialsManager: invidiousCreds,
|
|
pipedCredentialsManager: pipedCreds,
|
|
toastManager: toast,
|
|
feedCache: .shared
|
|
)
|
|
|
|
// Initialize Player Controls Layout Service
|
|
let layoutService = PlayerControlsLayoutService()
|
|
self.playerControlsLayoutService = layoutService
|
|
|
|
// Initialize Legacy Migration Service
|
|
self.legacyMigrationService = LegacyDataMigrationService(
|
|
instancesManager: instances,
|
|
basicAuthCredentialsManager: basicAuthCreds,
|
|
invidiousCredentialsManager: invidiousCreds,
|
|
pipedCredentialsManager: pipedCreds,
|
|
invidiousAPI: invidiousAPI,
|
|
pipedAPI: pipedAPI
|
|
)
|
|
|
|
// Initialize Sources Settings
|
|
self.sourcesSettings = SourcesSettings()
|
|
|
|
// Wire up CloudKit sync to player controls layout service (bidirectional)
|
|
cloudKit.playerControlsLayoutService = layoutService
|
|
Task {
|
|
await layoutService.setCloudKitSync(cloudKit)
|
|
}
|
|
|
|
// Wire up player controls layout service to player service (for preset-based settings)
|
|
player.setPlayerControlsLayoutService(layoutService)
|
|
|
|
// Cache active preset's center settings and keep them in sync
|
|
Task { await self.refreshActiveControlsSettings() }
|
|
for name: Notification.Name in [.playerControlsActivePresetDidChange, .playerControlsPresetsDidChange] {
|
|
controlsSettingsObservers.append(
|
|
NotificationCenter.default.addObserver(forName: name, object: nil, queue: .main) { [weak self] _ in
|
|
Task { @MainActor in
|
|
await self?.refreshActiveControlsSettings()
|
|
}
|
|
}
|
|
)
|
|
}
|
|
|
|
// Set up circular dependencies after all properties are initialized
|
|
bgRefreshManager.setAppEnvironment(self)
|
|
|
|
// Backfill credential flags for instances created before the flags
|
|
// existed, so existing setups publish them to iCloud without a re-login
|
|
backfillCredentialFlags()
|
|
|
|
// Resolve the credential identity scope if this Apple account has never
|
|
// been seen on this device. Until it lands, account credentials report
|
|
// "can't tell yet" rather than "signed out".
|
|
Task { await identityScope.resolve() }
|
|
|
|
// Log device capabilities on startup for debugging
|
|
HardwareCapabilities.shared.logCapabilities()
|
|
|
|
// Clean up any leftover subtitle temp files from previous sessions
|
|
cleanupAllTempSubtitles()
|
|
|
|
// Run orphan diagnostics on startup to help debug storage issues
|
|
#if !os(tvOS)
|
|
Task {
|
|
self.downloadManager.logOrphanDiagnostics()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Cleans up all temporary subtitle files from previous sessions.
|
|
/// Call this on app launch to ensure temp directory doesn't accumulate old files.
|
|
private func cleanupAllTempSubtitles() {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("yattee-subtitles", isDirectory: true)
|
|
|
|
do {
|
|
if FileManager.default.fileExists(atPath: tempDir.path) {
|
|
try FileManager.default.removeItem(at: tempDir)
|
|
LoggingService.shared.debug("Cleaned up all temp subtitle files on launch", category: .general)
|
|
}
|
|
} catch {
|
|
// Log but don't fail - this is just cleanup
|
|
LoggingService.shared.debug(
|
|
"Failed to clean up temp subtitles on launch: \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Configuration
|
|
|
|
/// Refreshes the cached center-section settings from the active player controls preset.
|
|
func refreshActiveControlsSettings() async {
|
|
let layout = await playerControlsLayoutService.activeLayout()
|
|
activeControlsCenterSettings = layout.centerSettings
|
|
}
|
|
|
|
/// Updates the HTTP client's User-Agent configuration from current settings.
|
|
/// Call this after changing User-Agent related settings.
|
|
func updateUserAgent() {
|
|
let userAgent = settingsManager.customUserAgent
|
|
let randomizePerRequest = settingsManager.randomizeUserAgentPerRequest
|
|
Task {
|
|
await httpClient.setUserAgent(userAgent)
|
|
await httpClient.setRandomizeUserAgentPerRequest(randomizePerRequest)
|
|
}
|
|
}
|
|
|
|
// MARK: - Notifications
|
|
|
|
/// Ensures the notification infrastructure is enabled (system permission + master toggle + background refresh).
|
|
/// Call this before enabling per-channel notifications.
|
|
/// - Returns: `true` if notifications are fully enabled, `false` if the user denied permission.
|
|
func ensureNotificationsEnabled() async -> Bool {
|
|
if settingsManager.backgroundNotificationsEnabled {
|
|
return true
|
|
}
|
|
|
|
let granted = await notificationManager.requestAuthorization()
|
|
if granted {
|
|
settingsManager.backgroundNotificationsEnabled = true
|
|
backgroundRefreshManager.handleNotificationsEnabledChanged(true)
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MARK: - Credentials Management
|
|
|
|
/// Returns the appropriate credentials manager for an instance type.
|
|
/// - Parameter instance: The instance to get a credentials manager for
|
|
/// - Returns: The credentials manager, or nil if the instance type doesn't support authentication
|
|
func credentialsManager(for instance: Instance) -> (any InstanceCredentialsManager)? {
|
|
switch instance.type {
|
|
case .invidious:
|
|
return invidiousCredentialsManager
|
|
case .piped:
|
|
return pipedCredentialsManager
|
|
case .yatteeServer:
|
|
return basicAuthCredentialsManager
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Backfills `usesBasicAuth`/`usesAccountLogin` on instances that were created
|
|
/// before these flags existed, based on credentials currently in the Keychain.
|
|
/// Only ever sets the flags — a missing Keychain entry (e.g. right after a
|
|
/// reinstall) must not clear a previously recorded flag.
|
|
private func backfillCredentialFlags() {
|
|
for instance in instancesManager.instances {
|
|
if !instance.usesBasicAuth,
|
|
instance.supportsHTTPBasicAuthProxy,
|
|
basicAuthCredentialsManager.hasCredentials(for: instance) {
|
|
instancesManager.setUsesBasicAuth(true, for: instance)
|
|
}
|
|
|
|
if !instance.usesAccountLogin,
|
|
instance.supportsAuthentication,
|
|
let manager = credentialsManager(for: instance),
|
|
manager.isLoggedIn(for: instance) {
|
|
instancesManager.setUsesAccountLogin(true, for: instance)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether an instance is known to require credentials that are missing from
|
|
/// the Keychain (e.g. after reinstalling the app and importing sources from iCloud).
|
|
/// - Yattee Server always requires basic auth; other types require it when
|
|
/// `usesBasicAuth` was recorded.
|
|
/// - Invidious/Piped account logins are checked when `usesAccountLogin` was recorded.
|
|
func needsCredentials(for instance: Instance) -> Bool {
|
|
if instance.type == .yatteeServer || instance.usesBasicAuth,
|
|
!basicAuthCredentialsManager.hasCredentials(for: instance) {
|
|
return true
|
|
}
|
|
|
|
if instance.usesAccountLogin,
|
|
let manager = credentialsManager(for: instance),
|
|
!manager.isLoggedIn(for: instance) {
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MARK: - Preview/Testing Support
|
|
|
|
@MainActor
|
|
static var preview: AppEnvironment {
|
|
let dataManager = try? DataManager.preview()
|
|
return AppEnvironment(dataManager: dataManager)
|
|
}
|
|
}
|
|
|
|
// MARK: - Environment Key
|
|
|
|
private struct AppEnvironmentKey: EnvironmentKey {
|
|
static let defaultValue: AppEnvironment? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var appEnvironment: AppEnvironment? {
|
|
get { self[AppEnvironmentKey.self] }
|
|
set { self[AppEnvironmentKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func appEnvironment(_ environment: AppEnvironment) -> some View {
|
|
self.environment(\.appEnvironment, environment)
|
|
}
|
|
}
|
|
|
|
// MARK: - Video Queue Context Environment
|
|
|
|
private struct VideoQueueContextKey: EnvironmentKey {
|
|
static let defaultValue: VideoQueueContext? = nil
|
|
}
|
|
|
|
extension EnvironmentValues {
|
|
var videoQueueContext: VideoQueueContext? {
|
|
get { self[VideoQueueContextKey.self] }
|
|
set { self[VideoQueueContextKey.self] = newValue }
|
|
}
|
|
}
|
|
|
|
extension View {
|
|
func videoQueueContext(_ context: VideoQueueContext?) -> some View {
|
|
self.environment(\.videoQueueContext, context)
|
|
}
|
|
}
|