diff --git a/Yattee/Core/AccountsManager.swift b/Yattee/Core/AccountsManager.swift index f8fea004..a6950097 100644 --- a/Yattee/Core/AccountsManager.swift +++ b/Yattee/Core/AccountsManager.swift @@ -161,11 +161,6 @@ final class AccountsManager { } } - func setInstancesManager(_ manager: InstancesManager) { - instancesManager = manager - reconcileLegacyAccounts() - } - // MARK: - Lookup func account(id: UUID) -> Account? { diff --git a/Yattee/Core/AppEnvironment.swift b/Yattee/Core/AppEnvironment.swift index 83c12130..22f95eae 100644 --- a/Yattee/Core/AppEnvironment.swift +++ b/Yattee/Core/AppEnvironment.swift @@ -36,7 +36,6 @@ final class AppEnvironment { let webDAVClientFactory: WebDAVClientFactory let smbClient: SMBClient let localFileClient: LocalFileClient - let urlSessionFactory: URLSessionFactory let httpClientFactory: HTTPClientFactory let localNetworkService: LocalNetworkService let remoteControlCoordinator: RemoteControlCoordinator @@ -57,7 +56,6 @@ final class AppEnvironment { let localNamespaceImporter: LocalNamespaceImporter let homeInstanceCache: HomeInstanceCache let invidiousAPI: InvidiousAPI - let pipedAPI: PipedAPI let credentialProbeService: CredentialProbeService let accountService: AccountService let remotePlaylistsCache: RemotePlaylistsCache @@ -262,7 +260,6 @@ final class AppEnvironment { // Initialize Piped API (used by SubscriptionService and SubscriptionFeedCache) let pipedAPI = PipedAPI(httpClient: client) - self.pipedAPI = pipedAPI // A 401 or 403 from an ordinary request is never allowed to reject a // login on its own — it is re-asked here against an endpoint that @@ -399,7 +396,6 @@ final class AppEnvironment { // Initialize URL Session and Client Factories let sessionFactory = URLSessionFactory.shared - self.urlSessionFactory = sessionFactory self.httpClientFactory = HTTPClientFactory(sessionFactory: sessionFactory) self.webDAVClientFactory = WebDAVClientFactory(sessionFactory: sessionFactory) diff --git a/Yattee/Core/HomeInstanceDiskCache.swift b/Yattee/Core/HomeInstanceDiskCache.swift index 5313c73a..9d8f750e 100644 --- a/Yattee/Core/HomeInstanceDiskCache.swift +++ b/Yattee/Core/HomeInstanceDiskCache.swift @@ -99,15 +99,6 @@ actor HomeInstanceDiskCache { } } } - - /// Clears the cache from both memory and disk. - func clear() async { - cachedData = nil - try? fileManager.removeItem(at: cacheFileURL) - await MainActor.run { - LoggingService.shared.debug("HomeInstanceDiskCache.clear: Cache cleared", category: .general) - } - } } // MARK: - Cache Data Model diff --git a/Yattee/Core/InstancesManager.swift b/Yattee/Core/InstancesManager.swift index 66fef417..d41a55c5 100644 --- a/Yattee/Core/InstancesManager.swift +++ b/Yattee/Core/InstancesManager.swift @@ -12,8 +12,6 @@ import SwiftUI enum InstanceStatus: Equatable { /// Instance is online and working. case online - /// Instance is offline or unreachable. - case offline /// Instance requires authentication but credentials are not provided. case authRequired /// Instance authentication failed (wrong credentials). @@ -172,11 +170,6 @@ final class InstancesManager { } } - /// Alias for add method to maintain consistency. - func addInstance(_ instance: Instance) { - add(instance) - } - /// Toggles the enabled state of an instance. func toggleEnabled(_ instance: Instance) { if let index = instances.firstIndex(where: { $0.id == instance.id }) { @@ -211,39 +204,12 @@ final class InstancesManager { saveInstances() } - /// Sets the given instance as the primary (first) instance. - func setPrimary(_ instance: Instance) { - LoggingService.shared.debug("[InstancesManager] setPrimary called for: \(instance.displayName)", category: .general) - LoggingService.shared.debug("[InstancesManager] Current instances: \(instances.map { $0.displayName })", category: .general) - - guard let index = instances.firstIndex(where: { $0.id == instance.id }) else { - LoggingService.shared.debug("[InstancesManager] Instance not found in list", category: .general) - return - } - - if index == 0 { - LoggingService.shared.debug("[InstancesManager] Instance already at index 0, skipping", category: .general) - return - } - - LoggingService.shared.debug("[InstancesManager] Moving instance from index \(index) to 0", category: .general) - // Move to front - let removed = instances.remove(at: index) - instances.insert(removed, at: 0) - saveInstances() - LoggingService.shared.debug("[InstancesManager] After move: \(instances.map { $0.displayName })", category: .general) - } - // MARK: - Computed Properties var enabledInstances: [Instance] { instances.filter(\.isEnabled) } - var youtubeInstances: [Instance] { - instances.filter(\.isYouTubeInstance) - } - var peertubeInstances: [Instance] { instances.filter(\.isPeerTubeInstance) } @@ -360,42 +326,6 @@ final class InstancesManager { instanceStatuses[instance.id] ?? .online } - /// Updates the status of an instance. - func updateStatus(_ status: InstanceStatus, for instance: Instance) { - instanceStatuses[instance.id] = status - } - - /// Updates status based on an API error. - func updateStatusFromError(_ error: Error, for instance: Instance) { - if let apiError = error as? APIError { - switch apiError { - case .unauthorized: - updateStatus(.authFailed, for: instance) - case .noConnection, .timeout, .connectionFailed: - updateStatus(.offline, for: instance) - default: - // Don't change status for other errors - break - } - } else { - // Generic network errors - let nsError = error as NSError - if nsError.domain == NSURLErrorDomain { - updateStatus(.offline, for: instance) - } - } - } - - /// Clears the status of an instance (resets to online). - func clearStatus(for instance: Instance) { - instanceStatuses.removeValue(forKey: instance.id) - } - - /// Instances that have auth issues (need attention). - var instancesWithAuthIssues: [Instance] { - instances.filter { instanceStatuses[$0.id] == .authFailed || instanceStatuses[$0.id] == .authRequired } - } - /// The currently active instance for browsing content. /// Falls back to the first enabled instance if no active instance is set. var activeInstance: Instance? { @@ -406,21 +336,6 @@ final class InstancesManager { return enabledInstances.first } - /// Sets the given instance as the active instance for browsing. - func setActive(_ instance: Instance) { - guard enabledInstances.contains(where: { $0.id == instance.id }) else { return } - activeInstanceID = instance.id - saveActiveInstance() - NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil) - } - - /// Clears the active instance, falling back to the first enabled instance. - func clearActiveInstance() { - activeInstanceID = nil - localDefaults.removeObject(forKey: activeInstanceKey) - NotificationCenter.default.post(name: .activeInstanceDidChange, object: nil) - } - // MARK: - Private Methods private func loadInstances() { @@ -453,19 +368,10 @@ final class InstancesManager { activeInstanceID = uuid } } - - private func saveActiveInstance() { - if let id = activeInstanceID { - localDefaults.set(id.uuidString, forKey: activeInstanceKey) - } else { - localDefaults.removeObject(forKey: activeInstanceKey) - } - } } // MARK: - Notifications extension Notification.Name { static let instancesDidChange = Notification.Name("stream.yattee.instancesDidChange") - static let activeInstanceDidChange = Notification.Name("stream.yattee.activeInstanceDidChange") } diff --git a/Yattee/Core/MediaSourcesManager.swift b/Yattee/Core/MediaSourcesManager.swift index 8d29653a..0aae5bb9 100644 --- a/Yattee/Core/MediaSourcesManager.swift +++ b/Yattee/Core/MediaSourcesManager.swift @@ -89,11 +89,6 @@ final class MediaSourcesManager { } } - /// Sets the settings manager reference (for dependency injection after init). - func configure(settingsManager: SettingsManager) { - self.settingsManager = settingsManager - } - /// Sets the data manager reference (for cleanup when sources are deleted). func setDataManager(_ manager: DataManager) { self.dataManager = manager @@ -344,51 +339,6 @@ final class MediaSourcesManager { passwordStoredSourceIDs.remove(source.id) } - // MARK: - Bookmarks (Local Folders) - - /// Updates the bookmark data for a local folder source. - func updateBookmark(_ bookmarkData: Data, for source: MediaSource) { - if let index = sources.firstIndex(where: { $0.id == source.id }) { - var updated = sources[index] - updated.bookmarkData = bookmarkData - sources[index] = updated - saveSources() - } - } - - /// Resolves and accesses a local folder source. - /// - Parameter source: The local folder source. - /// - Returns: The resolved URL, or nil if bookmark resolution failed. - func resolveLocalFolderURL(for source: MediaSource) -> URL? { - guard source.type == .localFolder, - let bookmarkData = source.bookmarkData else { - return source.url - } - - var isStale = false - - #if os(macOS) - let options: URL.BookmarkResolutionOptions = [.withSecurityScope] - #else - let options: URL.BookmarkResolutionOptions = [] - #endif - - do { - let url = try URL( - resolvingBookmarkData: bookmarkData, - options: options, - relativeTo: nil, - bookmarkDataIsStale: &isStale - ) - - // If bookmark is stale, we should re-create it - // but we can't do that without user interaction - return url - } catch { - return nil - } - } - // MARK: - iCloud Sync /// Whether iCloud sync is enabled and media sources sync is enabled. @@ -501,17 +451,3 @@ final class MediaSourcesManager { ubiquitousStore.synchronize() } } - -// MARK: - Preview Support - -extension MediaSourcesManager { - /// Preview manager with sample data. - static var preview: MediaSourcesManager { - let manager = MediaSourcesManager() - manager.sources = [ - .webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!, username: "user"), - .localFolder(name: "Downloads", url: URL(fileURLWithPath: "/Users/user/Downloads")) - ] - return manager - } -} diff --git a/Yattee/Core/Settings/SettingsManager+General.swift b/Yattee/Core/Settings/SettingsManager+General.swift index 8580ea85..234ae339 100644 --- a/Yattee/Core/Settings/SettingsManager+General.swift +++ b/Yattee/Core/Settings/SettingsManager+General.swift @@ -264,11 +264,6 @@ extension SettingsManager { // MARK: - Custom User-Agent - /// Generates a new random User-Agent string. - static func generateRandomUserAgent() -> String { - UserAgentGenerator.generateRandom() - } - /// Returns the current effective User-Agent string. /// If randomize per request is enabled, generates a new random UA. /// Otherwise, returns the stored custom user agent. diff --git a/Yattee/Core/Settings/SettingsManager+Home.swift b/Yattee/Core/Settings/SettingsManager+Home.swift index 51fd628c..d59bd206 100644 --- a/Yattee/Core/Settings/SettingsManager+Home.swift +++ b/Yattee/Core/Settings/SettingsManager+Home.swift @@ -337,25 +337,6 @@ extension SettingsManager { homeSectionVisibility = sectionVis } - /// Checks if an instance content item is in Home (either as card or section). - func isInHome(instanceID: UUID, contentType: InstanceContentType) -> (inCards: Bool, inSections: Bool) { - let inCards = homeShortcutOrder.contains { item in - if case .instanceContent(let id, let type) = item { - return id == instanceID && type == contentType - } - return false - } - - let inSections = homeSectionOrder.contains { item in - if case .instanceContent(let id, let type) = item { - return id == instanceID && type == contentType - } - return false - } - - return (inCards, inSections) - } - /// Removes all Home items for instances that no longer exist. func cleanupOrphanedHomeInstanceItems(validInstanceIDs: Set) { // Collect orphaned instance IDs for cache cleanup @@ -560,25 +541,6 @@ extension SettingsManager { homeSectionVisibility = sectionVis } - /// Checks if a media source is in Home (either as card or section). - func isInHome(sourceID: UUID) -> (inCards: Bool, inSections: Bool) { - let inCards = homeShortcutOrder.contains { item in - if case .mediaSource(let id) = item { - return id == sourceID - } - return false - } - - let inSections = homeSectionOrder.contains { item in - if case .mediaSource(let id) = item { - return id == sourceID - } - return false - } - - return (inCards, inSections) - } - /// Returns all available card items for a media source that are NOT already added. func availableShortcuts(for source: MediaSource) -> [HomeShortcutItem] { let card = HomeShortcutItem.mediaSource(sourceID: source.id) diff --git a/Yattee/Core/Settings/SettingsManager+Player.swift b/Yattee/Core/Settings/SettingsManager+Player.swift index 30023df9..45184960 100644 --- a/Yattee/Core/Settings/SettingsManager+Player.swift +++ b/Yattee/Core/Settings/SettingsManager+Player.swift @@ -13,17 +13,6 @@ import UIKit extension SettingsManager { // MARK: - Player Settings - var keepPlayerPinnedEnabled: Bool { - get { - if let cached = _keepPlayerPinnedEnabled { return cached } - return bool(for: .keepPlayerPinned, default: false) - } - set { - _keepPlayerPinnedEnabled = newValue - set(newValue, for: .keepPlayerPinned) - } - } - #if os(iOS) /// Whether in-app orientation lock is enabled. /// When enabled, ignores accelerometer rotation detection and stays in current orientation. diff --git a/Yattee/Core/Settings/SettingsTypes.swift b/Yattee/Core/Settings/SettingsTypes.swift index d405417a..c2916700 100644 --- a/Yattee/Core/Settings/SettingsTypes.swift +++ b/Yattee/Core/Settings/SettingsTypes.swift @@ -603,11 +603,6 @@ enum VideoSwipeAction: String, CaseIterable, Codable, Identifiable { } } - /// Default order with only download and share enabled. - static var defaultOrder: [VideoSwipeAction] { - [.download, .share] - } - /// Default visibility: only download and share are enabled by default. static var defaultVisibility: [VideoSwipeAction: Bool] { var visibility = [VideoSwipeAction: Bool]() diff --git a/Yattee/Data/Bookmark.swift b/Yattee/Data/Bookmark.swift index ee6d78ba..2d5aee16 100644 --- a/Yattee/Data/Bookmark.swift +++ b/Yattee/Data/Bookmark.swift @@ -172,26 +172,6 @@ final class Bookmark { } } - /// Formatted view count string. - var formattedViewCount: String? { - guard let viewCount else { return nil } - return CountFormatter.compact(viewCount) - } - - /// Formatted published date, preferring parsed Date over API-provided text. - /// - /// A date that would render as "in 0 seconds" is shown as nothing at all — - /// see ``Video/formattedPublishedDate``. - var formattedPublishedDate: String? { - if let publishedAt { - guard !RelativeDateFormatter.isEffectivelyNow(publishedAt) else { return nil } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .full - return formatter.localizedString(for: publishedAt, relativeTo: Date()) - } - return publishedText - } - /// Converts this bookmark to a Video model for playback. func toVideo() -> Video { let videoIDObj: VideoID diff --git a/Yattee/Data/DataManager+ChannelNotificationSettings.swift b/Yattee/Data/DataManager+ChannelNotificationSettings.swift index 36fb546f..803e76de 100644 --- a/Yattee/Data/DataManager+ChannelNotificationSettings.swift +++ b/Yattee/Data/DataManager+ChannelNotificationSettings.swift @@ -131,41 +131,6 @@ extension DataManager { save() } - /// Deletes notification settings for a channel. - /// - Parameter channelID: The channel ID to delete settings for. - func deleteNotificationSettings(for channelID: String) { - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.channelID == channelID } - ) - - do { - let results = try modelContext.fetch(descriptor) - guard !results.isEmpty else { return } - - // Capture scopes before deleting - let scopes = results.map { - SourceScope.from( - sourceRawValue: $0.sourceRawValue, - globalProvider: $0.globalProvider, - instanceURLString: $0.instanceURLString, - externalExtractor: nil - ) - } - - for settings in results { - modelContext.delete(settings) - } - save() - - // Queue scoped CloudKit deletions - for scope in scopes { - cloudKitSync?.queueChannelNotificationSettingsDelete(channelID: channelID, scope: scope) - } - } catch { - LoggingService.shared.logCloudKitError("Failed to delete notification settings", error: error) - } - } - /// Inserts or updates notification settings from CloudKit sync. /// - Parameter settings: The notification settings to upsert. func upsertChannelNotificationSettings(_ settings: ChannelNotificationSettings) { diff --git a/Yattee/Data/DataManager+Subscriptions.swift b/Yattee/Data/DataManager+Subscriptions.swift index e66a1236..587afda0 100644 --- a/Yattee/Data/DataManager+Subscriptions.swift +++ b/Yattee/Data/DataManager+Subscriptions.swift @@ -43,61 +43,6 @@ extension DataManager { } } - /// Subscribes to a channel from an Author. - /// - Parameters: - /// - author: The author/channel to subscribe to. - /// - source: The content source (YouTube or PeerTube). - func subscribe(to author: Author, source: ContentSource) { - let channelID = author.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.channelID == channelID } - ) - - do { - let existing = try modelContext.fetch(descriptor) - guard existing.isEmpty else { - return - } - - let sourceRaw: String - var instanceURL: String? - - switch source { - case .global: - sourceRaw = "global" - case .federated(_, let instance): - sourceRaw = "federated" - instanceURL = instance.absoluteString - case .extracted: - // Extracted sources don't support subscriptions - return - } - - let subscription = Subscription( - channelID: channelID, - sourceRawValue: sourceRaw, - instanceURLString: instanceURL, - name: author.name, - subscriberCount: author.subscriberCount, - avatarURLString: author.thumbnailURL?.absoluteString - ) - modelContext.insert(subscription) - save() - - // Queue for CloudKit sync - cloudKitSync?.queueSubscriptionSave(subscription) - - let change = SubscriptionChange(addedSubscriptions: [subscription], removedChannelIDs: []) - NotificationCenter.default.post( - name: .subscriptionsDidChange, - object: nil, - userInfo: [SubscriptionChange.userInfoKey: change] - ) - } catch { - LoggingService.shared.logCloudKitError("Failed to subscribe", error: error) - } - } - /// Unsubscribes from a channel. func unsubscribe(from channelID: String) { let descriptor = FetchDescriptor( @@ -137,42 +82,6 @@ extension DataManager { } } - /// Bulk adds subscriptions from channel data (for testing). - func bulkAddSubscriptions(_ channels: [(id: String, name: String)]) { - var addedCount = 0 - - for channel in channels { - let channelID = channel.id - let descriptor = FetchDescriptor( - predicate: #Predicate { $0.channelID == channelID } - ) - - do { - let existing = try modelContext.fetch(descriptor) - guard existing.isEmpty else { - continue - } - - let subscription = Subscription( - channelID: channel.id, - sourceRawValue: "youtube", - instanceURLString: nil, - name: channel.name - ) - modelContext.insert(subscription) - addedCount += 1 - } catch { - continue - } - } - - if addedCount > 0 { - save() - SubscriptionFeedCache.shared.invalidate() - LoggingService.shared.info("Bulk added \(addedCount) subscriptions", category: .general) - } - } - /// Checks if subscribed to a channel. func isSubscribed(to channelID: String) -> Bool { let descriptor = FetchDescriptor( @@ -257,40 +166,6 @@ extension DataManager { // Note: caller is responsible for calling save() after batch operations } - /// Removes subscriptions matching the given channel IDs. - func removeSubscriptions(matching channelIDs: Set) { - let allSubscriptions = subscriptions() - var removedCount = 0 - var deleteInfo: [(channelID: String, scope: SourceScope)] = [] - - for subscription in allSubscriptions { - if channelIDs.contains(subscription.channelID) { - // Capture scope before deleting - let scope = SourceScope.from( - sourceRawValue: subscription.sourceRawValue, - globalProvider: subscription.providerName, - instanceURLString: subscription.instanceURLString, - externalExtractor: nil - ) - deleteInfo.append((subscription.channelID, scope)) - modelContext.delete(subscription) - removedCount += 1 - } - } - - if removedCount > 0 { - save() - - // Queue CloudKit deletions - for info in deleteInfo { - cloudKitSync?.queueSubscriptionDelete(channelID: info.channelID, scope: info.scope) - } - - SubscriptionFeedCache.shared.invalidate() - LoggingService.shared.info("Removed \(removedCount) test subscriptions", category: .general) - } - } - /// Deletes all locally stored subscriptions, including their iCloud copies. /// Server-account subscriptions (Invidious/Piped) are unaffected. func deleteAllSubscriptions() { diff --git a/Yattee/Data/DataManagerNotifications.swift b/Yattee/Data/DataManagerNotifications.swift index dbf74a44..ea8be07a 100644 --- a/Yattee/Data/DataManagerNotifications.swift +++ b/Yattee/Data/DataManagerNotifications.swift @@ -40,8 +40,4 @@ struct SubscriptionChange { let removedChannelIDs: [String] static let userInfoKey = "subscriptionChange" - - var isEmpty: Bool { - addedSubscriptions.isEmpty && removedChannelIDs.isEmpty - } } diff --git a/Yattee/Data/WatchEntry.swift b/Yattee/Data/WatchEntry.swift index f34eceeb..173b707e 100644 --- a/Yattee/Data/WatchEntry.swift +++ b/Yattee/Data/WatchEntry.swift @@ -149,20 +149,6 @@ final class WatchEntry { return min(watchedSeconds / duration, 1.0) } - /// Formatted total duration. - var formattedDuration: String { - guard duration > 0 else { return "" } - let hours = Int(duration) / 3600 - let minutes = (Int(duration) % 3600) / 60 - let seconds = Int(duration) % 60 - - if hours > 0 { - return String(format: "%d:%02d:%02d", hours, minutes, seconds) - } else { - return String(format: "%d:%02d", minutes, seconds) - } - } - /// Formatted remaining time. var remainingTime: String { let remaining = max(0, duration - watchedSeconds) diff --git a/Yattee/Extensions/View+Conditional.swift b/Yattee/Extensions/View+Conditional.swift index a1d2ed0a..7ae30663 100644 --- a/Yattee/Extensions/View+Conditional.swift +++ b/Yattee/Extensions/View+Conditional.swift @@ -34,17 +34,4 @@ extension View { falseTransform(self) } } - - /// Conditionally applies a transformation when an optional value is present. - @ViewBuilder - func ifLet( - _ value: T?, - transform: (Self, T) -> Transform - ) -> some View { - if let value { - transform(self, value) - } else { - self - } - } } diff --git a/Yattee/Extensions/View+LiquidGlassSheet.swift b/Yattee/Extensions/View+LiquidGlassSheet.swift index 27b86667..62305236 100644 --- a/Yattee/Extensions/View+LiquidGlassSheet.swift +++ b/Yattee/Extensions/View+LiquidGlassSheet.swift @@ -106,45 +106,3 @@ extension View { modifier(LiquidGlassSheetContentModifier(sourceID: sourceID, namespace: namespace)) } } - -// MARK: - ToolbarContent Extension - -extension ToolbarContent { - /// Marks this toolbar content as the source for a Liquid Glass morphing sheet transition. - /// - /// Apply this modifier to a `ToolbarItem` that presents a sheet. On iOS 26+, - /// the sheet will morph from this toolbar item when presented. - /// - /// - Parameters: - /// - id: Unique identifier for the transition (must match the sheet content's sourceID). - /// - namespace: A namespace for the matched geometry effect. - /// - /// Example: - /// ```swift - /// @Namespace private var sheetTransition - /// - /// .toolbar { - /// ToolbarItem(placement: .primaryAction) { - /// Button { showSheet = true } label: { - /// Image(systemName: "gear") - /// } - /// } - /// .liquidGlassTransitionSource(id: "settings", in: sheetTransition) - /// } - /// ``` - @ToolbarContentBuilder - func liquidGlassTransitionSource( - id: String, - in namespace: Namespace.ID - ) -> some ToolbarContent { - #if os(iOS) - if #available(iOS 26, *) { - self.matchedTransitionSource(id: id, in: namespace) - } else { - self - } - #else - self - #endif - } -} diff --git a/Yattee/Extensions/View+ZoomTransition.swift b/Yattee/Extensions/View+ZoomTransition.swift index 5a65e91a..05fdf448 100644 --- a/Yattee/Extensions/View+ZoomTransition.swift +++ b/Yattee/Extensions/View+ZoomTransition.swift @@ -159,21 +159,6 @@ extension View { environment(\.zoomTransitionNamespace, namespace) } - /// Injects the zoom transition namespace into the environment (optional overload). - /// - /// If the namespace is nil, the view is returned unchanged. - /// - /// - Parameter namespace: The optional namespace to use for matched transitions. - /// - Returns: A view with the namespace injected into the environment, or unchanged if nil. - @ViewBuilder - func zoomTransitionNamespace(_ namespace: Namespace.ID?) -> some View { - if let namespace { - environment(\.zoomTransitionNamespace, namespace) - } else { - self - } - } - /// Sets whether zoom transitions are enabled. /// /// Apply this at a high level in the view hierarchy (e.g., ContentView) to control diff --git a/Yattee/Models/Comment.swift b/Yattee/Models/Comment.swift index d17b7f8e..5dce7d12 100644 --- a/Yattee/Models/Comment.swift +++ b/Yattee/Models/Comment.swift @@ -94,6 +94,4 @@ struct CommentsPage: Sendable { /// Continuation token for loading more comments. Nil if no more pages. let continuation: String? - /// Whether there are more comments to load. - var hasMore: Bool { continuation != nil } } diff --git a/Yattee/Models/MediaSources/MediaFile.swift b/Yattee/Models/MediaSources/MediaFile.swift index 12f87efa..b4ce66cf 100644 --- a/Yattee/Models/MediaSources/MediaFile.swift +++ b/Yattee/Models/MediaSources/MediaFile.swift @@ -36,9 +36,6 @@ struct MediaFile: Identifiable, Hashable, Sendable { /// Creation date. let createdDate: Date? - /// MIME type if known. - let mimeType: String? - // MARK: - Initialization init( @@ -60,7 +57,6 @@ struct MediaFile: Identifiable, Hashable, Sendable { self.size = size self.modifiedDate = modifiedDate self.createdDate = createdDate - self.mimeType = mimeType } // MARK: - Computed Properties diff --git a/Yattee/Models/Navigation/SidebarItem.swift b/Yattee/Models/Navigation/SidebarItem.swift index 11c21df1..7d9c4ed5 100644 --- a/Yattee/Models/Navigation/SidebarItem.swift +++ b/Yattee/Models/Navigation/SidebarItem.swift @@ -215,22 +215,6 @@ enum SidebarItem: Hashable, Identifiable { // MARK: - Item Categories - /// Whether this is a fixed navigation item (always visible). - var isFixedNavigation: Bool { - switch self { - case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl: - return true - default: - return false - } - } - - /// Whether this is a dynamic channel item. - var isChannel: Bool { - if case .channel = self { return true } - return false - } - /// Whether this is a dynamic playlist item. var isPlaylist: Bool { switch self { diff --git a/Yattee/Models/Player/VideoQueueContext.swift b/Yattee/Models/Player/VideoQueueContext.swift index f52c2648..b6d1b7fc 100644 --- a/Yattee/Models/Player/VideoQueueContext.swift +++ b/Yattee/Models/Player/VideoQueueContext.swift @@ -57,12 +57,6 @@ struct VideoQueueContext { videoList != nil && videoIndex != nil } - /// Number of videos that will be queued after the current one - var remainingVideosCount: Int { - guard let list = videoList, let index = videoIndex else { return 0 } - return max(0, list.count - index - 1) - } - /// Whether more videos can be loaded via continuation var canLoadMore: Bool { let hasCallback = loadMoreVideos != nil diff --git a/Yattee/Models/PlayerControls/ControlButtonConfiguration.swift b/Yattee/Models/PlayerControls/ControlButtonConfiguration.swift index b8d71002..a69ae354 100644 --- a/Yattee/Models/PlayerControls/ControlButtonConfiguration.swift +++ b/Yattee/Models/PlayerControls/ControlButtonConfiguration.swift @@ -59,16 +59,6 @@ struct ControlButtonConfiguration: Identifiable, Codable, Hashable, Sendable { ) } - /// Creates a configuration for a fixed-width spacer. - /// - Parameter width: The fixed width in points. - /// - Returns: A spacer configuration with fixed width. - static func fixedSpacer(width: Int) -> ControlButtonConfiguration { - ControlButtonConfiguration( - buttonType: .spacer, - settings: .spacer(SpacerSettings(isFlexible: false, fixedWidth: width)) - ) - } - // MARK: - Convenience Accessors /// Returns the spacer settings if this is a spacer button. diff --git a/Yattee/Models/PlayerControls/ControlButtonType.swift b/Yattee/Models/PlayerControls/ControlButtonType.swift index 72cbf948..cf123e8a 100644 --- a/Yattee/Models/PlayerControls/ControlButtonType.swift +++ b/Yattee/Models/PlayerControls/ControlButtonType.swift @@ -294,11 +294,6 @@ enum ControlButtonType: String, Codable, Hashable, Sendable, CaseIterable { #endif } - /// Button types for center section (play/pause, seek). - static var availableForCenterSection: [ControlButtonType] { - [.playPause, .seekBackward, .seekForward] - } - /// Button types available for the player pill (curated subset). static var availableForPill: [ControlButtonType] { [ diff --git a/Yattee/Models/PlayerControls/GlobalLayoutSettings.swift b/Yattee/Models/PlayerControls/GlobalLayoutSettings.swift index bdb2017d..d8a980ae 100644 --- a/Yattee/Models/PlayerControls/GlobalLayoutSettings.swift +++ b/Yattee/Models/PlayerControls/GlobalLayoutSettings.swift @@ -294,18 +294,6 @@ extension SponsorBlockSegmentSettings { func settings(for category: SponsorBlockCategory) -> SponsorBlockCategorySettings { settings(forKey: category.rawValue) } - - /// Returns a copy with updated settings for a specific category. - /// - Parameters: - /// - category: The category to update. - /// - settings: The new settings. - /// - Returns: Updated SponsorBlockSegmentSettings. - func withUpdatedSettings( - for category: SponsorBlockCategory, - _ settings: SponsorBlockCategorySettings - ) -> SponsorBlockSegmentSettings { - withUpdatedSettings(forKey: category.rawValue, settings) - } } // MARK: - Font Style diff --git a/Yattee/Models/PlayerControls/LayoutSection.swift b/Yattee/Models/PlayerControls/LayoutSection.swift index a43c4002..11de198c 100644 --- a/Yattee/Models/PlayerControls/LayoutSection.swift +++ b/Yattee/Models/PlayerControls/LayoutSection.swift @@ -34,37 +34,12 @@ struct LayoutSection: Codable, Hashable, Sendable { buttons.append(button) } - /// Adds a button with the given type to the end of the section. - /// - Parameter type: The button type to add with default configuration. - mutating func add(buttonType type: ControlButtonType) { - buttons.append(.defaultConfiguration(for: type)) - } - - /// Removes a button at the specified index. - /// - Parameter index: The index of the button to remove. - mutating func remove(at index: Int) { - guard buttons.indices.contains(index) else { return } - buttons.remove(at: index) - } - /// Removes a button with the specified ID. /// - Parameter id: The ID of the button to remove. mutating func remove(id: UUID) { buttons.removeAll { $0.id == id } } - /// Moves a button from one position to another. - /// - Parameters: - /// - source: The current index of the button. - /// - destination: The target index for the button. - mutating func move(from source: Int, to destination: Int) { - guard buttons.indices.contains(source) else { return } - let button = buttons.remove(at: source) - let targetIndex = destination > source ? destination - 1 : destination - let clampedIndex = max(0, min(buttons.count, targetIndex)) - buttons.insert(button, at: clampedIndex) - } - /// Moves buttons from source indices to a destination index. /// Compatible with SwiftUI's `onMove` modifier. /// - Parameters: @@ -97,13 +72,6 @@ struct LayoutSection: Codable, Hashable, Sendable { buttons.map(\.buttonType) } - /// Checks if a button type is already in this section. - /// - Parameter type: The button type to check. - /// - Returns: True if the type is already present. - func contains(buttonType type: ControlButtonType) -> Bool { - buttons.contains { $0.buttonType == type } - } - /// Returns buttons filtered by visibility for the given layout state. /// - Parameter isWideLayout: Whether the current layout is wide/landscape. /// - Returns: Buttons that should be visible. diff --git a/Yattee/Models/Storyboard.swift b/Yattee/Models/Storyboard.swift index 43d44e11..0ee31750 100644 --- a/Yattee/Models/Storyboard.swift +++ b/Yattee/Models/Storyboard.swift @@ -170,15 +170,6 @@ extension Storyboard { // MARK: - Storyboard Selection extension Array where Element == Storyboard { - /// Selects the preferred storyboard based on desired width. - /// Prefers the largest storyboard that doesn't exceed maxWidth. - /// - Parameter maxWidth: Maximum preferred width (default 160) - /// - Returns: Best matching storyboard, or nil if array is empty - func preferred(maxWidth: Int = 160) -> Storyboard? { - let suitable = filter { $0.width <= maxWidth } - return suitable.max(by: { $0.width < $1.width }) ?? first - } - /// Returns the highest quality storyboard (largest width). func highest() -> Storyboard? { self.max(by: { $0.width < $1.width }) diff --git a/Yattee/Models/VideoListLayout.swift b/Yattee/Models/VideoListLayout.swift index a00eb619..2bdcc2e5 100644 --- a/Yattee/Models/VideoListLayout.swift +++ b/Yattee/Models/VideoListLayout.swift @@ -113,7 +113,6 @@ func makeGridColumns(count: Int) -> [GridItem] { /// // Then use: /// // gridConfig.effectiveColumns - actual column count /// // gridConfig.isCompactCards - whether to use compact card styling -/// // gridConfig.columns - GridItem array for LazyVGrid /// // gridConfig.maxColumns - for ViewOptionsSheet /// ``` struct GridLayoutConfiguration { @@ -134,9 +133,4 @@ struct GridLayoutConfiguration { var isCompactCards: Bool { effectiveColumns >= GridConstants.compactThreshold } - - /// GridItem array for LazyVGrid columns parameter. - var columns: [GridItem] { - makeGridColumns(count: effectiveColumns) - } } diff --git a/Yattee/Services/API/ContentService.swift b/Yattee/Services/API/ContentService.swift index fbbb1f4e..571ad450 100644 --- a/Yattee/Services/API/ContentService.swift +++ b/Yattee/Services/API/ContentService.swift @@ -8,25 +8,7 @@ import Foundation /// Protocol defining the common content fetching interface. -protocol ContentServiceProtocol: Sendable { - func trending(for instance: Instance) async throws -> [Video] - func popular(for instance: Instance) async throws -> [Video] - func feed(for instance: Instance, credential: String) async throws -> [Video] - func subscriptions(for instance: Instance, credential: String) async throws -> [Channel] - func search(query: String, instance: Instance, page: Int, filters: SearchFilters) async throws -> SearchResult - func searchSuggestions(query: String, instance: Instance) async throws -> [String] - func video(id: String, instance: Instance) async throws -> Video - func channel(id: String, instance: Instance) async throws -> Channel - func channelVideos(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage - func channelPlaylists(id: String, instance: Instance, continuation: String?) async throws -> ChannelPlaylistsPage - func channelShorts(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage - func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage - func playlist(id: String, instance: Instance) async throws -> Playlist - func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage - func streams(videoID: String, instance: Instance) async throws -> [Stream] - func captions(videoID: String, instance: Instance) async throws -> [Caption] - func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage -} +protocol ContentServiceProtocol: Sendable {} /// Unified content service that routes requests to the appropriate API based on instance type. actor ContentService: ContentServiceProtocol { @@ -55,17 +37,6 @@ actor ContentService: ContentServiceProtocol { self.basicAuthCredentialsManager = basicAuthCredentialsManager } - init(httpClientFactory: HTTPClientFactory, basicAuthCredentialsManager: BasicAuthCredentialsManager? = nil) { - self.httpClientFactory = httpClientFactory - // Create default client for instances that don't need insecure SSL - self.defaultHTTPClient = httpClientFactory.createClient(allowInvalidCertificates: false) - self.defaultInvidiousAPI = InvidiousAPI(httpClient: defaultHTTPClient) - self.defaultPipedAPI = PipedAPI(httpClient: defaultHTTPClient) - self.defaultPeerTubeAPI = PeerTubeAPI(httpClient: defaultHTTPClient) - self.defaultYatteeServerAPI = YatteeServerAPI(httpClient: defaultHTTPClient) - self.basicAuthCredentialsManager = basicAuthCredentialsManager - } - // MARK: - Routing /// Builds a per-instance HTTPClient with the basic-auth `Authorization` header baked in, @@ -256,10 +227,6 @@ actor ContentService: ContentServiceProtocol { try await api(for: instance).comments(videoID: videoID, instance: instance, continuation: continuation) } - func streams(videoID: String, instance: Instance) async throws -> [Stream] { - try await api(for: instance).streams(videoID: videoID, instance: instance) - } - func captions(videoID: String, instance: Instance) async throws -> [Caption] { try await api(for: instance).captions(videoID: videoID, instance: instance) } @@ -590,17 +557,6 @@ actor ContentService: ContentServiceProtocol { } } - /// Fetches streams with proxy URLs for downloads (Yattee Server: hits /proxy/fast/ - /// so the server caches the file on disk; other backends apply Invidious URL rewriting). - func proxyStreams(videoID: String, instance: Instance) async throws -> [Stream] { - if instance.type == .yatteeServer { - return try await yatteeServerAPI(for: instance) - .proxyStreams(videoID: videoID, instance: instance, mode: .download) - } - let fetchedStreams = try await streams(videoID: videoID, instance: instance) - return await InvidiousAPI.proxyStreamsIfNeeded(fetchedStreams, instance: instance) - } - /// Fetches video details + streams for downloads. For Yattee Server this routes /// through `/proxy/fast/`, which caches the file on disk on the server. func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { diff --git a/Yattee/Services/API/GitHubAPI.swift b/Yattee/Services/API/GitHubAPI.swift index 0683b596..fe99ff70 100644 --- a/Yattee/Services/API/GitHubAPI.swift +++ b/Yattee/Services/API/GitHubAPI.swift @@ -77,9 +77,4 @@ actor GitHubAPI { throw APIError.decodingError(error) } } - - /// Clears the cache. - func clearCache() { - contributorsCache = nil - } } diff --git a/Yattee/Services/API/InstanceAPI.swift b/Yattee/Services/API/InstanceAPI.swift index bc5a694e..f546395d 100644 --- a/Yattee/Services/API/InstanceAPI.swift +++ b/Yattee/Services/API/InstanceAPI.swift @@ -21,7 +21,6 @@ protocol InstanceAPI: Sendable { func channelStreams(id: String, instance: Instance, continuation: String?) async throws -> ChannelVideosPage func playlist(id: String, instance: Instance) async throws -> Playlist func comments(videoID: String, instance: Instance, continuation: String?) async throws -> CommentsPage - func streams(videoID: String, instance: Instance) async throws -> [Stream] func captions(videoID: String, instance: Instance) async throws -> [Caption] func channelSearch(id: String, query: String, instance: Instance, page: Int) async throws -> ChannelSearchPage } diff --git a/Yattee/Services/API/InstanceDetector.swift b/Yattee/Services/API/InstanceDetector.swift index fde71832..0ca79dda 100644 --- a/Yattee/Services/API/InstanceDetector.swift +++ b/Yattee/Services/API/InstanceDetector.swift @@ -50,12 +50,9 @@ enum DetectionError: Error, Sendable { /// Result of instance detection including type and authentication requirements. struct InstanceDetectionResult: Sendable { let type: InstanceType - /// Whether this instance requires authentication (Basic Auth for Yattee Server). - let requiresAuth: Bool init(type: InstanceType, requiresAuth: Bool = false) { self.type = type - self.requiresAuth = requiresAuth } } @@ -67,27 +64,6 @@ actor InstanceDetector { self.httpClient = httpClient } - /// Detects the instance type for a given URL. - /// - Parameter url: The base URL of the instance. - /// - Returns: The detected instance type, or nil if detection failed. - func detect(url: URL) async -> InstanceType? { - let result = await detectWithAuth(url: url) - return result?.type - } - - /// Detects the instance type and authentication requirements for a given URL. - /// - Parameter url: The base URL of the instance. - /// - Returns: The detection result including type and auth requirements, or nil if detection failed. - func detectWithAuth(url: URL) async -> InstanceDetectionResult? { - let result = await detectWithResult(url: url) - switch result { - case .success(let detectionResult): - return detectionResult - case .failure: - return nil - } - } - /// Detects the instance type with detailed error reporting. /// - Parameters: /// - url: The base URL of the instance. diff --git a/Yattee/Services/API/InvidiousAPI.swift b/Yattee/Services/API/InvidiousAPI.swift index 13dc28c8..29eb04b4 100644 --- a/Yattee/Services/API/InvidiousAPI.swift +++ b/Yattee/Services/API/InvidiousAPI.swift @@ -234,22 +234,11 @@ actor InvidiousAPI: InstanceAPI { ).toPlaylist(baseURL: instance.url) } - /// Fetches a user's playlist using authenticated endpoint. - /// Required for private playlists (IVPL* IDs). - /// - Parameters: - /// - id: The playlist ID - /// - instance: The Invidious instance - /// - sid: The session ID from login - /// - Returns: The playlist with videos - func userPlaylist(id: String, instance: Instance, sid: String) async throws -> Playlist { - try await userPlaylistSnapshot(id: id, instance: instance, sid: sid).playlist - } - /// Fetches a user's playlist along with the per-entry identities needed to /// mutate it. /// - /// Same request as ``userPlaylist(id:instance:sid:)`` — that one is a thin - /// wrapper over this — but it keeps the `index`/`indexId` tokens Invidious + /// Uses the authenticated endpoint (required for private IVPL* playlists) + /// and keeps the `index`/`indexId` tokens Invidious /// hands out for every row instead of dropping them on conversion. /// - Parameters: /// - id: The playlist ID @@ -455,14 +444,6 @@ actor InvidiousAPI: InstanceAPI { return ChannelSearchPage(items: items, nextPage: hasResults ? page + 1 : nil) } - /// Fetches video details, streams, and captions in a single API call. - /// This is more efficient than calling video(), streams(), and captions() separately - /// since they all fetch from the same endpoint. - func videoWithStreamsAndCaptions(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) { - let result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance) - return (video: result.video, streams: result.streams, captions: result.captions) - } - func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)") let response: InvidiousVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) @@ -1854,10 +1835,6 @@ private struct InvidiousChannel: Decodable, Sendable { } } -private struct InvidiousChannelVideos: Decodable, Sendable { - let videos: [InvidiousVideo] -} - private struct InvidiousChannelVideosWithContinuation: Decodable, Sendable { let videos: [InvidiousVideo] let continuation: String? diff --git a/Yattee/Services/API/PipedAPI.swift b/Yattee/Services/API/PipedAPI.swift index 51a1aefa..cffd3e87 100644 --- a/Yattee/Services/API/PipedAPI.swift +++ b/Yattee/Services/API/PipedAPI.swift @@ -385,21 +385,10 @@ actor PipedAPI: InstanceAPI { return response.map { $0.toPlaylist() } } - /// Fetches a user playlist with its videos. - /// - Parameters: - /// - id: The playlist ID (UUID) - /// - instance: The Piped instance - /// - authToken: The auth token from login - /// - Returns: Playlist with videos - func userPlaylist(id: String, instance: Instance, authToken: String) async throws -> Playlist { - try await userPlaylistSnapshot(id: id, instance: instance, authToken: authToken).playlist - } - /// Fetches a user playlist along with the per-entry identities needed to /// mutate it. /// - /// Same request as ``userPlaylist(id:instance:authToken:)`` — that one is a - /// thin wrapper over this. Piped has no per-entry token, so entries are + /// Piped has no per-entry token, so entries are /// addressed by their position in this fetch and are only valid until the /// playlist next changes. /// - Parameters: diff --git a/Yattee/Services/API/ProxyDetectionCache.swift b/Yattee/Services/API/ProxyDetectionCache.swift index 97ec0ca2..e04028fd 100644 --- a/Yattee/Services/API/ProxyDetectionCache.swift +++ b/Yattee/Services/API/ProxyDetectionCache.swift @@ -45,13 +45,6 @@ actor ProxyDetectionCache { return entry.decision } - /// Most-recently-seen CDN URL for this instance. Used by ``prewarm`` so we - /// don't have to wait for the current video's API response just to learn a - /// URL to probe. - func lastSampleURL(for instance: Instance) -> URL? { - entries[instance.id]?.sampleURL - } - /// Records a verdict (decision + the URL we probed against, kept as a /// future probe sample). Refreshes the TTL. func record(decision: Bool, sampleURL: URL?, for instance: Instance) { @@ -115,12 +108,4 @@ actor ProxyDetectionCache { inFlight[instance.id]?.cancel() inFlight.removeValue(forKey: instance.id) } - - /// Drop all entries — e.g. after a network reachability change or when the - /// instances collection mutates broadly. - func invalidateAll() { - entries.removeAll() - for (_, task) in inFlight { task.cancel() } - inFlight.removeAll() - } } diff --git a/Yattee/Services/API/YatteeServerAPI.swift b/Yattee/Services/API/YatteeServerAPI.swift index 7533637e..debef250 100644 --- a/Yattee/Services/API/YatteeServerAPI.swift +++ b/Yattee/Services/API/YatteeServerAPI.swift @@ -206,12 +206,6 @@ actor YatteeServerAPI: InstanceAPI { } } - func streams(videoID: String, instance: Instance) async throws -> [Stream] { - let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)") - let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) - return response.toStreams() - } - func captions(videoID: String, instance: Instance) async throws -> [Caption] { let endpoint = GenericEndpoint.get("/api/v1/videos/\(videoID)") let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) @@ -237,12 +231,6 @@ actor YatteeServerAPI: InstanceAPI { return ChannelSearchPage(items: items, nextPage: hasResults ? page + 1 : nil) } - /// Fetches video details, streams, and captions in a single API call. - func videoWithStreamsAndCaptions(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption]) { - let result = try await videoWithStreamsAndCaptionsAndStoryboards(id: id, instance: instance) - return (video: result.video, streams: result.streams, captions: result.captions) - } - func videoWithStreamsAndCaptionsAndStoryboards(id: String, instance: Instance) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { let endpoint = GenericEndpoint.get("/api/v1/videos/\(id)") let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) @@ -268,16 +256,6 @@ actor YatteeServerAPI: InstanceAPI { case download } - /// Fetches streams with URLs that proxy through the Yattee Server. - func proxyStreams(videoID: String, instance: Instance, mode: ProxyMode) async throws -> [Stream] { - let endpoint = GenericEndpoint.get( - "/api/v1/videos/\(videoID)", - query: ["proxy": "true", "proxy_mode": mode.rawValue] - ) - let response: YatteeVideoDetails = try await httpClient.fetch(endpoint, baseURL: instance.url) - return response.toStreams() - } - /// Fetches video details, proxy streams, captions, and storyboards in a single API call. func videoWithProxyStreamsAndCaptionsAndStoryboards(id: String, instance: Instance, mode: ProxyMode) async throws -> (video: Video, streams: [Stream], captions: [Caption], storyboards: [Storyboard]) { let endpoint = GenericEndpoint.get( @@ -1324,10 +1302,6 @@ private struct YatteeChannel: Decodable, Sendable { } } -private struct YatteeChannelVideos: Decodable, Sendable { - let videos: [YatteeVideo] -} - private struct YatteeChannelSearchResponse: Decodable, Sendable { let videos: [YatteeVideo] } diff --git a/Yattee/Services/BackgroundRefresh/BackgroundRefreshManager.swift b/Yattee/Services/BackgroundRefresh/BackgroundRefreshManager.swift index c071df40..a1e864c2 100644 --- a/Yattee/Services/BackgroundRefresh/BackgroundRefreshManager.swift +++ b/Yattee/Services/BackgroundRefresh/BackgroundRefreshManager.swift @@ -23,19 +23,15 @@ final class BackgroundRefreshManager { // MARK: - Dependencies - private weak var appEnvironment: AppEnvironment? private let backgroundRefresher: BackgroundFeedRefresher - private let notificationManager: NotificationManager // MARK: - Initialization init(notificationManager: NotificationManager) { - self.notificationManager = notificationManager self.backgroundRefresher = BackgroundFeedRefresher(notificationManager: notificationManager) } func setAppEnvironment(_ environment: AppEnvironment) { - self.appEnvironment = environment backgroundRefresher.setAppEnvironment(environment) } @@ -148,11 +144,6 @@ final class BackgroundRefreshManager { activityScheduler = nil LoggingService.shared.debug("Invalidated macOS background scheduler", category: .notifications) } - - func restartMacOSScheduler() { - invalidateMacOSScheduler() - registerMacOSBackgroundActivity() - } #endif // MARK: - Enable/Disable diff --git a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift index 93a6a3c6..4c5ed6c4 100644 --- a/Yattee/Services/CloudKit/CloudKitSyncEngine.swift +++ b/Yattee/Services/CloudKit/CloudKitSyncEngine.swift @@ -1225,21 +1225,6 @@ final class CloudKitSyncEngine: @unchecked Sendable { LoggingService.shared.logCloudKit("Queued channel notification settings save: \(settings.channelID)") } - /// Queue channel notification settings deletion for sync (debounced). - func queueChannelNotificationSettingsDelete(channelID: String, scope: SourceScope) { - guard canSyncSubscriptions else { return } - - let recordID = SyncableRecordType.channelNotificationSettings(channelID: channelID, scope: scope).recordID(in: zone) - addPendingChanges([.deleteRecord(recordID)]) - LoggingService.shared.logCloudKit("Queued channel notification settings delete: \(channelID)") - } - - /// Upload all existing channel notification settings to CloudKit (for initial sync). - func uploadAllLocalChannelNotificationSettings() async { - guard enqueueAllLocalChannelNotificationSettings() else { return } - await sync() - } - /// Registers all channel notification settings for upload without sending. /// - Returns: whether anything was registered. @discardableResult diff --git a/Yattee/Services/CloudKit/CloudKitZoneManager.swift b/Yattee/Services/CloudKit/CloudKitZoneManager.swift index 78a39195..a8c76ca5 100644 --- a/Yattee/Services/CloudKit/CloudKitZoneManager.swift +++ b/Yattee/Services/CloudKit/CloudKitZoneManager.swift @@ -82,9 +82,4 @@ actor CloudKitZoneManager { throw error } } - - /// Fetches all zones in the private database. - func fetchAllZones() async throws -> [CKRecordZone] { - try await database.allRecordZones() - } } diff --git a/Yattee/Services/CloudKit/RecordTypes.swift b/Yattee/Services/CloudKit/RecordTypes.swift index 0757e7da..872c1d6c 100644 --- a/Yattee/Services/CloudKit/RecordTypes.swift +++ b/Yattee/Services/CloudKit/RecordTypes.swift @@ -109,29 +109,6 @@ enum SyncableRecordType: Sendable { case dataContext case accountDeletion(accountID: UUID) - /// The CloudKit record type name. - var recordTypeName: String { - switch self { - case .subscription: RecordType.subscription - case .watchEntry: RecordType.watchEntry - case .bookmark: RecordType.bookmark - case .localPlaylist: RecordType.localPlaylist - case .localPlaylistItem: RecordType.localPlaylistItem - case .searchHistory: RecordType.searchHistory - case .recentChannel: RecordType.recentChannel - case .recentPlaylist: RecordType.recentPlaylist - case .appSettings: RecordType.appSettings - case .instance: RecordType.instance - case .mediaSource: RecordType.mediaSource - case .channelNotificationSettings: RecordType.channelNotificationSettings - case .controlsPreset: RecordType.controlsPreset - case .account: RecordType.account - case .legacyAccountMigration: RecordType.legacyAccountMigration - case .dataContext: RecordType.dataContext - case .accountDeletion: RecordType.accountDeletion - } - } - /// CloudKit rejects record names longer than this with a fatal /// `CKException` at `CKRecord.ID` construction time. static let maxRecordNameLength = 255 @@ -290,22 +267,3 @@ enum AccountRecordName { || recordName.hasPrefix(accountPrefix) } } - -/// Sync operation type. -enum SyncOperation: Sendable { - case save - case delete -} - -/// Pending sync change to be uploaded to CloudKit. -struct PendingSyncChange: Sendable { - let recordType: SyncableRecordType - let operation: SyncOperation - let timestamp: Date - - init(recordType: SyncableRecordType, operation: SyncOperation) { - self.recordType = recordType - self.operation = operation - self.timestamp = Date() - } -} diff --git a/Yattee/Services/Credentials/AccountCredentialsManager.swift b/Yattee/Services/Credentials/AccountCredentialsManager.swift index 1385088c..80c4b0dc 100644 --- a/Yattee/Services/Credentials/AccountCredentialsManager.swift +++ b/Yattee/Services/Credentials/AccountCredentialsManager.swift @@ -17,9 +17,6 @@ import Foundation /// proxy credential covers every account on that instance. @MainActor protocol AccountCredentialsManager: AnyObject, Observable { - /// Accounts with a stored credential under the **current** identity scope. - var loggedInAccountIDs: Set { get } - /// Whether the identity scope has been resolved yet. /// /// While `false`, every read below reports "no credential" because the app @@ -41,12 +38,6 @@ protocol AccountCredentialsManager: AnyObject, Observable { /// key needs only the account's ID. func deleteCredential(forAccountID accountID: UUID) - /// Rewrites stored credentials so they carry the current iCloud sync - /// setting. Whether a Keychain item syncs is decided when it is written, so - /// enabling sync later would otherwise leave existing logins local — and - /// every other device with an account whose credential never arrives. - func resyncCredentialsWithCurrentSetting() - /// The login identifier the user typed. Kept in the Keychain beside the /// credential so it never enters synced metadata. func setUsername(_ username: String, for account: Account) diff --git a/Yattee/Services/Credentials/BasicAuthCredentialsManager.swift b/Yattee/Services/Credentials/BasicAuthCredentialsManager.swift index fa955759..4c7b2f13 100644 --- a/Yattee/Services/Credentials/BasicAuthCredentialsManager.swift +++ b/Yattee/Services/Credentials/BasicAuthCredentialsManager.swift @@ -179,16 +179,6 @@ final class BasicAuthCredentialsManager: InstanceCredentialsManager { return hasCreds } - /// Refreshes the logged-in status for an instance. - /// Call this when the view appears to ensure UI is in sync. - func refreshLoginStatus(for instance: Instance) { - if credentials(for: instance) != nil { - loggedInInstanceIDs.insert(instance.id) - } else { - loggedInInstanceIDs.remove(instance.id) - } - } - // MARK: - InstanceCredentialsManager Protocol /// Stores a credential for an instance (protocol conformance). diff --git a/Yattee/Services/Credentials/InstanceCredentialsManager.swift b/Yattee/Services/Credentials/InstanceCredentialsManager.swift index 65b74f1a..57899f66 100644 --- a/Yattee/Services/Credentials/InstanceCredentialsManager.swift +++ b/Yattee/Services/Credentials/InstanceCredentialsManager.swift @@ -13,10 +13,6 @@ import Foundation /// across different instance types (Invidious, Piped, etc.). @MainActor protocol InstanceCredentialsManager: AnyObject, Observable { - /// Set of instance IDs that are currently logged in. - /// Used for reactive UI updates when login state changes. - var loggedInInstanceIDs: Set { get } - /// Stores a credential (SID for Invidious, token for Piped) for an instance. /// - Parameters: /// - credential: The credential value to store @@ -36,9 +32,4 @@ protocol InstanceCredentialsManager: AnyObject, Observable { /// - Parameter instance: The instance to check /// - Returns: true if logged in, false otherwise func isLoggedIn(for instance: Instance) -> Bool - - /// Refreshes the login status for an instance from the Keychain. - /// Call this when a view appears to ensure UI is in sync with stored state. - /// - Parameter instance: The instance to refresh status for - func refreshLoginStatus(for instance: Instance) } diff --git a/Yattee/Services/Credentials/InvidiousCredentialsManager.swift b/Yattee/Services/Credentials/InvidiousCredentialsManager.swift index b7476cb3..9ec39106 100644 --- a/Yattee/Services/Credentials/InvidiousCredentialsManager.swift +++ b/Yattee/Services/Credentials/InvidiousCredentialsManager.swift @@ -236,16 +236,6 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { return hasSession } - /// Refreshes the logged-in status for an instance. - /// Call this when the view appears to ensure UI is in sync. - func refreshLoginStatus(for instance: Instance) { - if sid(for: instance) != nil { - loggedInInstanceIDs.insert(instance.id) - } else { - loggedInInstanceIDs.remove(instance.id) - } - } - // MARK: - InstanceCredentialsManager Protocol /// Stores a credential (SID) for an Invidious instance. @@ -270,7 +260,6 @@ final class InvidiousCredentialsManager: InstanceCredentialsManager { // MARK: - AccountCredentialsManager extension InvidiousCredentialsManager: AccountCredentialsManager { - var loggedInAccountIDs: Set { accountCredentials.loggedInAccountIDs } var isScopeResolved: Bool { accountCredentials.isScopeResolved } func setCredential(_ credential: String, for account: Account) { diff --git a/Yattee/Services/Credentials/PipedCredentialsManager.swift b/Yattee/Services/Credentials/PipedCredentialsManager.swift index 8d3ec6f9..fada499c 100644 --- a/Yattee/Services/Credentials/PipedCredentialsManager.swift +++ b/Yattee/Services/Credentials/PipedCredentialsManager.swift @@ -199,7 +199,6 @@ final class PipedCredentialsManager: InstanceCredentialsManager { // MARK: - AccountCredentialsManager extension PipedCredentialsManager: AccountCredentialsManager { - var loggedInAccountIDs: Set { accountCredentials.loggedInAccountIDs } var isScopeResolved: Bool { accountCredentials.isScopeResolved } func setCredential(_ credential: String, for account: Account) { diff --git a/Yattee/Services/DeviceRotationManager.swift b/Yattee/Services/DeviceRotationManager.swift index aca6c45a..44accabe 100644 --- a/Yattee/Services/DeviceRotationManager.swift +++ b/Yattee/Services/DeviceRotationManager.swift @@ -252,11 +252,6 @@ final class DeviceRotationManager { } } - /// Check if device is currently in landscape orientation - var isLandscape: Bool { - detectedOrientation == .landscapeLeft || detectedOrientation == .landscapeRight - } - /// Check if device is currently in portrait orientation var isPortrait: Bool { detectedOrientation == .portrait || detectedOrientation == .portraitUpsideDown diff --git a/Yattee/Services/Downloads/DownloadManager.swift b/Yattee/Services/Downloads/DownloadManager.swift index d6ace4c3..b7278933 100644 --- a/Yattee/Services/Downloads/DownloadManager.swift +++ b/Yattee/Services/Downloads/DownloadManager.swift @@ -866,20 +866,6 @@ final class DownloadManager: NSObject { // MARK: - Batch Operations - /// Pause all active downloads. - func pauseAll() async { - for download in activeDownloads where download.status == .downloading { - await pause(download) - } - } - - /// Resume all paused downloads. - func resumeAll() async { - for download in activeDownloads where download.status == .paused { - await resume(download) - } - } - /// Delete all completed downloads. func deleteAllCompleted() async { for download in completedDownloads { @@ -1024,16 +1010,6 @@ final class DownloadManager: NSObject { return (video, stream, audioStream, captionURL, download.dislikeCount) } - /// Creates a Video and Stream for playing a downloaded video by video ID. - /// Returns nil if the video is not downloaded or the file doesn't exist. - /// Also returns the stored dislike count, audio stream, and caption URL if available. - func videoAndStream(for videoID: VideoID) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { - guard let download = completedDownloads.first(where: { $0.videoID == videoID }) else { - return nil - } - return videoAndStream(for: download) - } - // MARK: - Private Helpers func sortQueue() { @@ -1226,36 +1202,12 @@ final class DownloadManager { private(set) var storageUsed: Int64 = 0 var maxConcurrentDownloads: Int { 2 } - func enqueue(_ video: Video, quality: String, formatID: String, streamURL: URL, audioStreamURL: URL? = nil, captionURL: URL? = nil, audioLanguage: String? = nil, captionLanguage: String? = nil, httpHeaders: [String: String]? = nil, storyboard: Storyboard? = nil, dislikeCount: Int? = nil, priority: DownloadPriority = .normal, videoCodec: String? = nil, audioCodec: String? = nil, videoBitrate: Int? = nil, audioBitrate: Int? = nil, suppressStartToast: Bool = false) async throws { - throw DownloadError.notSupported - } - - func autoEnqueue(_ video: Video, preferredQuality: DownloadQuality, preferredAudioLanguage: String?, preferredSubtitlesLanguage: String?, includeSubtitles: Bool, contentService: ContentService, instance: Instance, suppressStartToast: Bool = false) async throws { - throw DownloadError.notSupported - } - - func autoEnqueueMediaSource(_ video: Video, mediaSourcesManager: MediaSourcesManager, webDAVClient: WebDAVClient, smbClient: SMBClient) async throws { - throw DownloadError.notSupported - } - - func pause(_ download: Download) async {} - func resume(_ download: Download) async {} - func cancel(_ download: Download) async {} - func delete(_ download: Download) async {} - func moveInQueue(_ download: Download, to position: Int) async {} - func pauseAll() async {} - func resumeAll() async {} - func deleteAllCompleted() async {} - func calculateStorageUsed() async -> Int64 { 0 } func getAvailableStorage() -> Int64 { 0 } - func deleteWatchedDownloads(watchedVideoIDs: Set) async {} func isDownloaded(_ videoID: VideoID) -> Bool { false } func isDownloading(_ videoID: VideoID) -> Bool { false } func download(for videoID: VideoID) -> Download? { nil } func localURL(for videoID: VideoID) -> URL? { nil } - func resolveLocalURL(for download: Download) -> URL? { nil } func videoAndStream(for download: Download) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { nil } - func videoAndStream(for videoID: VideoID) -> (video: Video, stream: Stream, audioStream: Stream?, captionURL: URL?, dislikeCount: Int?)? { nil } func setToastManager(_ manager: ToastManager) {} func setDownloadSettings(_ settings: DownloadSettings) {} func downloadsDirectory() -> URL { URL(fileURLWithPath: NSTemporaryDirectory()) } diff --git a/Yattee/Services/ImageLoading/ImageLoadingService.swift b/Yattee/Services/ImageLoading/ImageLoadingService.swift index 8eabac7d..474fbc09 100644 --- a/Yattee/Services/ImageLoading/ImageLoadingService.swift +++ b/Yattee/Services/ImageLoading/ImageLoadingService.swift @@ -118,20 +118,6 @@ final class ImageLoadingService: Sendable { LoggingService.shared.info("Image cache cleared", category: .imageLoading) } - - /// Returns the disk cache size in bytes. - func diskCacheSize() -> Int { - guard let dataCache = ImagePipeline.shared.configuration.dataCache as? DataCache else { - return 0 - } - return dataCache.totalSize - } - - /// Returns a formatted string of the disk cache size. - func formattedDiskCacheSize() -> String { - let size = diskCacheSize() - return ByteCountFormatter.string(fromByteCount: Int64(size), countStyle: .file) - } } /// Pipeline delegate that normalizes Nuke's cache key by stripping per-request diff --git a/Yattee/Services/Logging/LoggingService.swift b/Yattee/Services/Logging/LoggingService.swift index 4ba3061e..c9c583b1 100644 --- a/Yattee/Services/Logging/LoggingService.swift +++ b/Yattee/Services/Logging/LoggingService.swift @@ -313,11 +313,6 @@ extension LoggingService { self.error(message, category: .downloads, details: error?.localizedDescription) } - /// Log a notification event. - nonisolated func logNotification(_ message: String, details: String? = nil) { - info(message, category: .notifications, details: details) - } - /// Log a notification error. nonisolated func logNotificationError(_ message: String, error: Error? = nil) { self.error(message, category: .notifications, details: error?.localizedDescription) diff --git a/Yattee/Services/MediaSources/LocalFileClient.swift b/Yattee/Services/MediaSources/LocalFileClient.swift index 2550b463..67901532 100644 --- a/Yattee/Services/MediaSources/LocalFileClient.swift +++ b/Yattee/Services/MediaSources/LocalFileClient.swift @@ -47,14 +47,6 @@ enum LocalFolderURLResolver { lock.lock(); defer { lock.unlock() } return cache[sourceID] } - - static func clear(_ sourceID: UUID) { - lock.lock(); defer { lock.unlock() } - cache.removeValue(forKey: sourceID) - if let previous = persistentAccess.removeValue(forKey: sourceID) { - previous.stopAccessingSecurityScopedResource() - } - } } /// Actor-based client for local file system operations. @@ -279,15 +271,6 @@ actor LocalFileClient { return url } - /// Resolves bookmark and starts accessing the security-scoped resource. - /// - Parameter bookmarkData: The stored bookmark data. - /// - Returns: Tuple of (URL, didStartAccessing) - caller must call stopAccessingSecurityScopedResource when done. - func resolveAndAccessBookmark(_ bookmarkData: Data) throws -> (URL, Bool) { - let url = try resolveBookmark(bookmarkData) - let didStart = url.startAccessingSecurityScopedResource() - return (url, didStart) - } - // MARK: - Private Methods private func createMediaFile( diff --git a/Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift b/Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift index d6f8d4cd..1f24f3fd 100644 --- a/Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift +++ b/Yattee/Services/MediaSources/NetworkShareDiscoveryService.swift @@ -22,14 +22,6 @@ struct DiscoveredShare: Identifiable, Hashable, Sendable { case webdavs // _webdavs._tcp (HTTPS) case smb // _smb._tcp - var displayName: String { - switch self { - case .webdav: String(localized: "discovery.shareType.webdav") - case .webdavs: String(localized: "discovery.shareType.webdavs") - case .smb: String(localized: "discovery.shareType.smb") - } - } - var systemImage: String { switch self { case .webdav: "globe" diff --git a/Yattee/Services/MediaSources/SMBBridge/SMBBridgeWrapper.swift b/Yattee/Services/MediaSources/SMBBridge/SMBBridgeWrapper.swift index 5e5dc23a..52c9ebd5 100644 --- a/Yattee/Services/MediaSources/SMBBridge/SMBBridgeWrapper.swift +++ b/Yattee/Services/MediaSources/SMBBridge/SMBBridgeWrapper.swift @@ -39,24 +39,6 @@ enum SMBBridgeError: Error, LocalizedError, Sendable { return "Invalid parameters provided" } } - - /// User-friendly error messages based on common SMB errors - var userFriendlyMessage: String { - switch self { - case .connectionFailed(let msg) where msg.contains("errno: 13"): - return "Permission denied. Check username and password." - case .connectionFailed(let msg) where msg.contains("errno: 2"): - return "Share or path not found." - case .connectionFailed(let msg) where msg.contains("errno: 110"): - return "Connection timed out. Check server address and network." - case .connectionFailed(let msg) where msg.contains("errno: 111"): - return "Cannot reach server. Check server address." - case .listingFailed(let msg) where msg.contains("errno: 13"): - return "Access denied to this folder." - default: - return errorDescription ?? "Unknown error" - } - } } /// SMB protocol version for connection preferences (Swift wrapper). diff --git a/Yattee/Services/MediaSources/SMBClient.swift b/Yattee/Services/MediaSources/SMBClient.swift index 4f098499..31c1a76f 100644 --- a/Yattee/Services/MediaSources/SMBClient.swift +++ b/Yattee/Services/MediaSources/SMBClient.swift @@ -343,29 +343,12 @@ actor SMBClient { // MARK: - Helper Methods - /// Validates SMB URL format. - private func validateSMBURL(_ url: URL) throws { - guard url.scheme?.lowercased() == "smb" else { - throw MediaSourceError.unknown("URL must use smb:// scheme") - } - - guard url.host != nil else { - throw MediaSourceError.unknown("SMB URL missing host") - } - } - /// Clears cached SMB context for a specific source. /// Call this when source credentials change or connection fails. func clearCache(for source: MediaSource) { contextCache.removeValue(forKey: source.id) LoggingService.shared.logMediaSourcesDebug("Cleared SMB context cache for source: \(source.id)") } - - /// Clears all cached SMB contexts. - func clearAllCaches() { - contextCache.removeAll() - LoggingService.shared.logMediaSourcesDebug("Cleared all SMB context caches") - } } // MARK: - File Download diff --git a/Yattee/Services/MediaSources/WebDAVClient.swift b/Yattee/Services/MediaSources/WebDAVClient.swift index 4307aab6..b07f299a 100644 --- a/Yattee/Services/MediaSources/WebDAVClient.swift +++ b/Yattee/Services/MediaSources/WebDAVClient.swift @@ -167,7 +167,6 @@ private final class WebDAVResponseParser: NSObject, XMLParserDelegate { private var files: [MediaFile] = [] private var currentResponse: ResponseData? - private var currentElement: String = "" private var currentText: String = "" // Temporary storage for current response properties @@ -209,7 +208,6 @@ private final class WebDAVResponseParser: NSObject, XMLParserDelegate { qualifiedName: String?, attributes: [String: String] ) { - currentElement = elementName currentText = "" if elementName == "response" { diff --git a/Yattee/Services/MediaSources/WebDAVClientFactory.swift b/Yattee/Services/MediaSources/WebDAVClientFactory.swift index db08678c..09bd9eee 100644 --- a/Yattee/Services/MediaSources/WebDAVClientFactory.swift +++ b/Yattee/Services/MediaSources/WebDAVClientFactory.swift @@ -22,12 +22,4 @@ final class WebDAVClientFactory: Sendable { let session = sessionFactory.session(allowInvalidCertificates: source.allowInvalidCertificates) return WebDAVClient(session: session) } - - /// Creates a WebDAVClient with explicit SSL settings. - /// - Parameter allowInvalidCertificates: Whether to bypass SSL certificate validation. - /// - Returns: A WebDAVClient with the specified SSL settings. - func createClient(allowInvalidCertificates: Bool) -> WebDAVClient { - let session = sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates) - return WebDAVClient(session: session) - } } diff --git a/Yattee/Services/Navigation/NavigationCoordinator.swift b/Yattee/Services/Navigation/NavigationCoordinator.swift index d8d1f3e6..76fdd094 100644 --- a/Yattee/Services/Navigation/NavigationCoordinator.swift +++ b/Yattee/Services/Navigation/NavigationCoordinator.swift @@ -288,19 +288,6 @@ final class NavigationCoordinator { } } - /// Scrolls the player into view (uncovered position). - func scrollPlayerIntoView() { - isPlayerScrollAnimating = true - scrollPlayerIntoViewTrigger += 1 - } - - /// Waits until player scroll animation completes. - func waitForPlayerScrollAnimation() async { - while isPlayerScrollAnimating { - try? await Task.sleep(for: .milliseconds(50)) - } - } - // MARK: - Convenience Navigation /// Navigate to the subscriptions tab/feed. @@ -322,13 +309,6 @@ final class NavigationCoordinator { navigate(to: destination) } - /// Navigate to a video by its ID string. - /// Creates a VideoID assuming YouTube source. - func navigateToVideo(videoID: String) { - let id = VideoID(source: .global(provider: ContentSource.youtubeProvider), videoID: videoID) - navigate(to: .video(.id(id))) - } - /// Navigate to a video's channel/source. /// For media source videos (SMB, WebDAV, local), navigates to the parent directory. /// For extracted videos, navigates to the external channel URL. diff --git a/Yattee/Services/Navigation/SidebarManager.swift b/Yattee/Services/Navigation/SidebarManager.swift index 04b3ea06..c777a3fc 100644 --- a/Yattee/Services/Navigation/SidebarManager.swift +++ b/Yattee/Services/Navigation/SidebarManager.swift @@ -244,7 +244,7 @@ final class SidebarManager { private func loadPlaylists() { guard let dataManager else { return } - // Cache playlists for use in videoCount(for:) + // Cache playlists for use in thumbnailURL(for:) cachedPlaylists = dataManager.playlists() let sortOrder = settingsManager?.sidebarPlaylistSort ?? .alphabetical let limitEnabled = settingsManager?.sidebarPlaylistsLimitEnabled ?? false @@ -424,20 +424,6 @@ final class SidebarManager { // MARK: - Playlist Data Access - /// Returns video count for a playlist sidebar item. - /// Uses cached playlists to avoid repeated DB queries during layout. - func videoCount(for item: SidebarItem) -> Int { - switch item { - case .playlist(let id, _): - return cachedPlaylists.first { $0.id == id }?.videoCount ?? 0 - case .remotePlaylist(let id, _, let accountID): - return remotePlaylistsCache?.playlists(for: accountID)? - .first { $0.id == id }?.videoCount ?? 0 - default: - return 0 - } - } - /// Returns thumbnail URL for a playlist sidebar item. /// Uses cached playlists to avoid repeated DB queries during layout. func thumbnailURL(for item: SidebarItem) -> URL? { diff --git a/Yattee/Services/Networking/HTTPClientFactory.swift b/Yattee/Services/Networking/HTTPClientFactory.swift index 3d5f34b9..ea06d7dd 100644 --- a/Yattee/Services/Networking/HTTPClientFactory.swift +++ b/Yattee/Services/Networking/HTTPClientFactory.swift @@ -66,12 +66,6 @@ final class HTTPClientFactory: Sendable { makeClient(session: sessionFactory.session(allowInvalidCertificates: allowInvalidCertificates)) } - /// Creates an HTTPClient with low network priority for background/prefetch work. - /// - Returns: An HTTPClient configured with `.background` network service type. - func createLowPriorityClient() -> HTTPClient { - makeClient(session: sessionFactory.lowPrioritySession()) - } - /// Builds the client with the current User-Agent baked in. /// /// Passed through the initializer rather than set afterwards: these clients diff --git a/Yattee/Services/Player/BackendSwitcher.swift b/Yattee/Services/Player/BackendSwitcher.swift index 36918d13..73c26d7c 100644 --- a/Yattee/Services/Player/BackendSwitcher.swift +++ b/Yattee/Services/Player/BackendSwitcher.swift @@ -7,63 +7,11 @@ import Foundation -#if canImport(UIKit) -import UIKit -#elseif canImport(AppKit) -import AppKit -#endif - -// MARK: - Switch Animation - -/// Animation style for backend switching. -enum BackendSwitchAnimation: Sendable { - case instant // No animation, immediate swap - case crossfade // Crossfade between views - case slide // Slide transition - - var duration: TimeInterval { - switch self { - case .instant: return 0 - case .crossfade: return 0.3 - case .slide: return 0.4 - } - } -} - -// MARK: - Switch Result - -/// Result of a backend switch operation. -struct BackendSwitchResult: Sendable { - let success: Bool - let sourceBackend: PlayerBackendType - let targetBackend: PlayerBackendType - let timeDrift: TimeInterval // Difference between expected and actual time after switch - let switchDuration: TimeInterval // How long the switch took -} - -// MARK: - Backend Switcher Delegate - -/// Delegate for switch progress callbacks. -@MainActor -protocol BackendSwitcherDelegate: AnyObject { - func switcherWillBeginSwitch(from source: PlayerBackendType, to target: PlayerBackendType) - func switcherDidPrepareTarget(_ switcher: BackendSwitcher) - func switcherDidCompleteSwitch(_ result: BackendSwitchResult) - func switcherDidFailSwitch(_ error: Error) -} - // MARK: - Backend Switcher /// Manages seamless hot-swapping between player backends. @MainActor final class BackendSwitcher { - // MARK: - Properties - - weak var delegate: BackendSwitcherDelegate? - - /// Whether a switch is currently in progress. - private(set) var isSwitching: Bool = false - // MARK: - Dependencies /// Factory for creating backend instances. @@ -76,362 +24,6 @@ final class BackendSwitcher { self.backendFactory = backendFactory self.settingsManager = settingsManager } - - // MARK: - Public Methods - - /// Switch from one backend to another during active playback. - /// - /// This method: - /// 1. Captures the current playback state from the source backend - /// 2. Selects a compatible stream for the target backend - /// 3. Initializes the target backend and loads the stream - /// 4. Seeks to the captured position - /// 5. Waits for the target to be ready - /// 6. Performs a smooth visual transition - /// 7. Resumes playback on the target backend - /// 8. Cleans up the source backend - /// - /// - Parameters: - /// - source: The currently active backend - /// - targetType: The type of backend to switch to - /// - streams: Available streams for the current video - /// - animation: Animation style for the transition - /// - Returns: The new active backend - /// - Throws: BackendError if the switch fails - func switchBackend( - from source: any PlayerBackend, - to targetType: PlayerBackendType, - streams: [Stream], - animation: BackendSwitchAnimation = .crossfade - ) async throws -> any PlayerBackend { - guard !isSwitching else { - throw BackendError.switchFailed("Switch already in progress") - } - - let startTime = Date() - isSwitching = true - - defer { isSwitching = false } - - LoggingService.shared.logPlayer("Backend switch starting", details: "From \(source.backendType.rawValue) to \(targetType.rawValue)") - delegate?.switcherWillBeginSwitch(from: source.backendType, to: targetType) - - // Step 1: Capture current state - let capturedState = source.captureState() - - // Step 2: Find compatible stream for target backend - guard let selection = selectStream(for: targetType, from: streams) else { - throw BackendError.switchFailed("No compatible stream found for \(targetType.displayName)") - } - let targetStream = selection.video - let targetAudioStream = selection.audio - - // Step 3: Prepare source for handoff (pause but keep state) - source.prepareForHandoff() - - // Step 4: Create and initialize target backend - let target = try backendFactory.createBackend(type: targetType) - - // Step 5: Load stream on target (without autoplay) - do { - let useEDL = settingsManager?.mpvUseEDLStreams ?? true - try await target.load(stream: targetStream, audioStream: targetAudioStream, autoplay: false, useEDL: useEDL) - } catch { - // Rollback: resume source backend - if capturedState.isPlaying { - source.play() - } - throw BackendError.switchFailed("Failed to load stream on target: \(error.localizedDescription)") - } - - delegate?.switcherDidPrepareTarget(self) - - // Step 6: Seek to captured position - if capturedState.currentTime > 0 { - await target.seek(to: capturedState.currentTime, showLoading: false) - } - - // Step 7: Restore other state (volume, rate, mute) - target.volume = capturedState.volume - target.isMuted = capturedState.isMuted - target.rate = capturedState.rate - - // Step 8: Perform visual transition - await performTransition( - from: source, - to: target, - animation: animation - ) - - // Step 9: Resume playback if was playing - if capturedState.isPlaying { - target.play() - } - - // Step 10: Stop source backend - source.stop() - - // Calculate result metrics - let switchDuration = Date().timeIntervalSince(startTime) - let timeDrift = abs(target.currentTime - capturedState.currentTime) - - let result = BackendSwitchResult( - success: true, - sourceBackend: source.backendType, - targetBackend: targetType, - timeDrift: timeDrift, - switchDuration: switchDuration - ) - - LoggingService.shared.logPlayer("Backend switch completed", details: "Duration: \(String(format: "%.2f", switchDuration))s, drift: \(String(format: "%.3f", timeDrift))s") - delegate?.switcherDidCompleteSwitch(result) - - return target - } - - /// Check if switching to a given backend type is possible. - func canSwitch(to targetType: PlayerBackendType, streams: [Stream]) -> Bool { - // Check if we have a compatible stream - selectStream(for: targetType, from: streams) != nil - } - - // MARK: - Private Methods - - /// Select the best compatible stream for a backend type. - private func selectStream(for backendType: PlayerBackendType, from streams: [Stream]) -> (video: Stream, audio: Stream?)? { - let supportedFormats = backendType.supportedFormats - let preferredQuality = settingsManager?.preferredQuality ?? .auto - - // Separate streams by type - let videoOnlyStreams = streams.filter { stream in - guard !stream.isAudioOnly && stream.isVideoOnly else { return false } - let format = StreamFormat.detect(from: stream) - return supportedFormats.contains(format) - } - - let muxedStreams = streams.filter { stream in - let format = StreamFormat.detect(from: stream) - guard supportedFormats.contains(format) else { return false } - return stream.isMuxed || format == .hls || format == .dash - } - - let audioStreams = streams.filter { $0.isAudioOnly } - - // Get the maximum resolution based on user's quality preference - let maxResolution = preferredQuality.maxResolution - - // For live streams, always prefer HLS/DASH (designed for live streaming) - let isLiveStream = streams.contains(where: { $0.isLive }) - if isLiveStream { - if let hlsStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .hls }) { - return (hlsStream, nil) - } - if let dashStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .dash }) { - return (dashStream, nil) - } - } - - // Note: For non-live videos, we prefer progressive formats (MP4/WebM) over HLS/DASH - // because they typically offer better quality. HLS/DASH are only used as last resort. - - // Try to find the best video-only stream + audio - if !videoOnlyStreams.isEmpty && !audioStreams.isEmpty { - let filteredVideoStreams: [Stream] - if let maxRes = maxResolution { - filteredVideoStreams = videoOnlyStreams.filter { stream in - guard let resolution = stream.resolution else { return true } - return resolution <= maxRes - } - } else { - filteredVideoStreams = videoOnlyStreams - } - - // Sort by resolution first, then by codec quality (AV1 > VP9 > H.264) - let sortedVideo = filteredVideoStreams.sorted { s1, s2 in - let res1 = s1.resolution ?? .p360 - let res2 = s2.resolution ?? .p360 - if res1 != res2 { - return res1 > res2 - } - // Same resolution - prefer better codec - return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec) - } - - if let bestVideo = sortedVideo.first { - // Select best audio stream based on preferred language, codec, and bitrate - let preferredAudioLanguage = settingsManager?.preferredAudioLanguage - let bestAudio = audioStreams - .sorted { stream1, stream2 in - // First priority: preferred language or original audio - if let preferred = preferredAudioLanguage { - // User selected a specific language - let lang1 = stream1.audioLanguage ?? "" - let lang2 = stream2.audioLanguage ?? "" - let matches1 = lang1.hasPrefix(preferred) - let matches2 = lang2.hasPrefix(preferred) - if matches1 != matches2 { return matches1 } - } else { - // No preference set - prefer original audio track - if stream1.isOriginalAudio != stream2.isOriginalAudio { - return stream1.isOriginalAudio - } - } - - // Second priority: prefer Opus > AAC for MPV (better quality/compression) - let codecPriority1 = audioCodecPriority(stream1.audioCodec) - let codecPriority2 = audioCodecPriority(stream2.audioCodec) - if codecPriority1 != codecPriority2 { - return codecPriority1 > codecPriority2 - } - - // Third priority: higher bitrate - return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0) - } - .first - - if let audio = bestAudio { - return (bestVideo, audio) - } - } - } - - // Fallback to muxed streams - prefer progressive formats over HLS/DASH for non-live content - let filteredMuxed: [Stream] - if let maxRes = maxResolution { - filteredMuxed = muxedStreams.filter { stream in - guard let resolution = stream.resolution else { return true } - return resolution <= maxRes - } - } else { - filteredMuxed = muxedStreams - } - - // Sort: prefer progressive formats, then HLS, and only fall back to DASH as a last - // resort (DASH should never be chosen when any other format is available). - let sortedMuxed = filteredMuxed.sorted { s1, s2 in - let rank1 = muxedFormatRank(StreamFormat.detect(from: s1)) - let rank2 = muxedFormatRank(StreamFormat.detect(from: s2)) - if rank1 != rank2 { - return rank1 < rank2 - } - return (s1.resolution ?? .p360) > (s2.resolution ?? .p360) - } - - if let bestMuxed = sortedMuxed.first { - return (bestMuxed, nil) - } - - // Last resort: any muxed stream (HLS/DASH will be selected here if nothing else available) - if let anyMuxed = muxedStreams.sorted(by: { ($0.resolution ?? .p360) > ($1.resolution ?? .p360) }).first { - return (anyMuxed, nil) - } - - return nil - } - - /// Ranks muxed formats for fallback selection (lower = preferred). - /// Progressive formats win, then HLS; DASH is the last resort and is only - /// selected when it is the only format available. - private func muxedFormatRank(_ format: StreamFormat) -> Int { - switch format { - case .dash: return 2 - case .hls: return 1 - default: return 0 // progressive - } - } - - /// Returns codec priority for video streams (higher = better for MPV). - /// AV1 > VP9 > H.264/AVC - private func videoCodecPriority(_ codec: String?) -> Int { - guard let codec = codec?.lowercased() else { return 0 } - if codec.contains("av1") || codec.contains("av01") { - return 3 // Best compression, modern codec - } else if codec.contains("vp9") || codec.contains("vp09") { - return 2 // Good compression, widely supported - } else if codec.contains("avc") || codec.contains("h264") || codec.contains("h.264") { - return 1 // Most compatible, less efficient - } - return 0 - } - - /// Returns codec priority for audio streams (higher = better for MPV). - /// Opus > AAC - private func audioCodecPriority(_ codec: String?) -> Int { - guard let codec = codec?.lowercased() else { return 0 } - if codec.contains("opus") { - return 2 // Best quality/compression ratio - } else if codec.contains("aac") || codec.contains("mp4a") { - return 1 // Good compatibility - } - return 0 - } - - /// Perform visual transition between backends. - private func performTransition( - from source: any PlayerBackend, - to target: any PlayerBackend, - animation: BackendSwitchAnimation - ) async { - guard animation != .instant else { return } - - #if canImport(UIKit) - guard let sourceView = source.playerView, - let targetView = target.playerView, - let containerView = sourceView.superview else { - return - } - - // Add target view behind source - targetView.frame = containerView.bounds - targetView.alpha = 0 - containerView.insertSubview(targetView, belowSubview: sourceView) - - // Animate transition - await withCheckedContinuation { (continuation: CheckedContinuation) in - UIView.animate(withDuration: animation.duration, animations: { - switch animation { - case .crossfade: - sourceView.alpha = 0 - targetView.alpha = 1 - - case .slide: - sourceView.transform = CGAffineTransform(translationX: -sourceView.bounds.width, y: 0) - targetView.alpha = 1 - - case .instant: - break - } - }, completion: { _ in - sourceView.removeFromSuperview() - continuation.resume() - }) - } - - #elseif canImport(AppKit) - guard let sourceView = source.playerView, - let targetView = target.playerView, - let containerView = sourceView.superview else { - return - } - - // Add target view - targetView.frame = containerView.bounds - targetView.alphaValue = 0 - containerView.addSubview(targetView, positioned: .below, relativeTo: sourceView) - - // Animate transition - await withCheckedContinuation { (continuation: CheckedContinuation) in - NSAnimationContext.runAnimationGroup({ context in - context.duration = animation.duration - sourceView.animator().alphaValue = 0 - targetView.animator().alphaValue = 1 - }, completionHandler: { - sourceView.removeFromSuperview() - continuation.resume() - }) - } - #endif - } } // MARK: - Backend Factory diff --git a/Yattee/Services/Player/DeArrowAPI.swift b/Yattee/Services/Player/DeArrowAPI.swift index 181cf061..3a1e857a 100644 --- a/Yattee/Services/Player/DeArrowAPI.swift +++ b/Yattee/Services/Player/DeArrowAPI.swift @@ -138,18 +138,6 @@ actor DeArrowAPI { } } - /// Returns the current thumbnail base URL. - nonisolated func currentThumbnailBaseURL() -> URL { - // Note: This returns the default URL when called from nonisolated context. - // For dynamic URL access, use the async version. - Self.defaultThumbnailURL - } - - /// Returns the current thumbnail base URL (async version for isolation). - func getThumbnailBaseURL() -> URL { - thumbnailBaseURL - } - /// Fetches branding data for a YouTube video. /// - Parameter videoID: The YouTube video ID. /// - Returns: The branding data, or nil if not available. @@ -220,19 +208,6 @@ actor DeArrowAPI { return components.url! } - /// Generates a thumbnail URL using the default thumbnail base URL. - /// Use this when you need a URL synchronously without actor isolation. - nonisolated static func defaultThumbnailURL(for videoID: String, timestamp: Double? = nil) -> URL { - var components = URLComponents(url: defaultThumbnailURL, resolvingAgainstBaseURL: false)! - components.path = "/api/v1/getThumbnail" - var queryItems = [URLQueryItem(name: "videoID", value: videoID)] - if let timestamp { - queryItems.append(URLQueryItem(name: "time", value: String(format: "%.2f", timestamp))) - } - components.queryItems = queryItems - return components.url! - } - /// Result of fetching a thumbnail with timestamp verification. struct ThumbnailFetchResult: Sendable { let imageData: Data? diff --git a/Yattee/Services/Player/MPV/MPVClient.swift b/Yattee/Services/Player/MPV/MPVClient.swift index 47e1e52b..a01b9a22 100644 --- a/Yattee/Services/Player/MPV/MPVClient.swift +++ b/Yattee/Services/Player/MPV/MPVClient.swift @@ -653,16 +653,6 @@ final class MPVClient: @unchecked Sendable { observeProperty("sid", format: MPV_FORMAT_STRING) } - // MARK: - Options - - /// Set an MPV option (must be called before initialize or after load). - func setOption(_ name: String, _ value: String) { - mpvQueue.async { [weak self] in - guard let mpv = self?.mpv else { return } - mpv_set_option_string(mpv, name, value) - } - } - // MARK: - Playback Control /// Load a file/URL for playback. @@ -780,39 +770,14 @@ final class MPVClient: @unchecked Sendable { command(["stop"]) } - /// Seek to a specific time in seconds. - func seek(to time: Double, mode: String = "absolute") { - command(["seek", String(time), mode]) - } - /// Seek to a specific time in seconds asynchronously (non-blocking). /// Use this to avoid blocking the calling thread during seeks. func seekAsync(to time: Double, mode: String = "absolute") { commandAsync(["seek", String(time), mode]) } - /// Seek by a relative offset in seconds. - func seekRelative(_ offset: Double) { - command(["seek", String(offset), "relative"]) - } - - /// Seek by a relative offset in seconds asynchronously (non-blocking). - func seekRelativeAsync(_ offset: Double) { - commandAsync(["seek", String(offset), "relative"]) - } - // MARK: - Subtitles - /// Add an external subtitle file. - /// - Parameters: - /// - url: The subtitle URL (VTT, SRT, etc.) - /// - select: Whether to automatically select this subtitle - func addSubtitle(_ url: URL, select: Bool = true) { - // sub-add [ [ [<lang>]]] - let flags = select ? "select" : "auto" - command(["sub-add", url.absoluteString, flags]) - } - /// Add an external subtitle file asynchronously (does not block caller). /// Use this for remote URLs to avoid blocking the UI during download. /// - Parameters: @@ -828,16 +793,6 @@ final class MPVClient: @unchecked Sendable { setProperty("sid", "no") } - /// Enable subtitles by selecting the first track (if any). - func enableSubtitles() { - setProperty("sid", "auto") - } - - /// Remove all external subtitle tracks. - func removeAllSubtitles() { - command(["sub-remove"]) - } - /// Remove all external subtitle tracks asynchronously. func removeAllSubtitlesAsync() { commandAsync(["sub-remove"]) @@ -936,13 +891,6 @@ final class MPVClient: @unchecked Sendable { } } - /// Set a property value synchronously. Use when timing is critical. - func setPropertySync(_ name: String, _ value: Any) { - mpvQueue.sync { [weak self] in - self?.setPropertyUnsafe(name, value) - } - } - /// Internal property setter (must be called on mpvQueue). private func setPropertyUnsafe(_ name: String, _ value: Any) { guard let mpv, !isDestroyed else { return } @@ -1009,116 +957,6 @@ final class MPVClient: @unchecked Sendable { } } - /// Get a double property value. - /// **⚠️ BLOCKS CALLING THREAD** - Use getDoubleAsync() from @MainActor code. - func getDouble(_ name: String) -> Double? { - mpvQueue.sync { - guard let mpv, !isDestroyed else { return nil } - - var value: Double = 0 - let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value) - return result >= 0 ? value : nil - } - } - - /// Get a boolean property value. - /// **⚠️ BLOCKS CALLING THREAD** - Use getFlagAsync() from @MainActor code. - func getFlag(_ name: String) -> Bool? { - mpvQueue.sync { - guard let mpv, !isDestroyed else { return nil } - - var value: Int32 = 0 - let result = mpv_get_property(mpv, name, MPV_FORMAT_FLAG, &value) - return result >= 0 ? (value != 0) : nil - } - } - - /// Get a string property value. - /// **⚠️ BLOCKS CALLING THREAD** - Use getStringAsync() from @MainActor code. - func getString(_ name: String) -> String? { - mpvQueue.sync { - guard let mpv, !isDestroyed else { return nil } - - guard let cString = mpv_get_property_string(mpv, name) else { - return nil - } - let string = String(cString: cString) - mpv_free(cString) - return string - } - } - - /// Get an integer property value. - /// **⚠️ BLOCKS CALLING THREAD** - Use getIntAsync() from @MainActor code. - func getInt(_ name: String) -> Int? { - mpvQueue.sync { - guard let mpv, !isDestroyed else { return nil } - - var value: Int64 = 0 - let result = mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value) - return result >= 0 ? Int(value) : nil - } - } - - /// Get the demuxer cache state. - /// **⚠️ BLOCKS CALLING THREAD** - Use getCacheStateAsync() from @MainActor code. - func getCacheState() -> MPVCacheState? { - mpvQueue.sync { - guard let mpv, !isDestroyed else { return nil } - - var node = mpv_node() - let result = mpv_get_property(mpv, "demuxer-cache-state", MPV_FORMAT_NODE, &node) - guard result >= 0 else { - return nil - } - - defer { - mpv_free_node_contents(&node) - } - - return parseCacheStateSync(node) - } - } - - /// Get MPV version and build information. - /// **⚠️ BLOCKS CALLING THREAD** - Use getVersionInfoAsync() from @MainActor code. - func getVersionInfo() -> MPVVersionInfo? { - mpvQueue.sync { - guard let mpv, !isDestroyed else { return nil } - - // Get mpv-version property - var mpvVersion: String? - if let cString = mpv_get_property_string(mpv, "mpv-version") { - mpvVersion = String(cString: cString) - mpv_free(cString) - } - - // Get ffmpeg-version property - var ffmpegVersion: String? - if let cString = mpv_get_property_string(mpv, "ffmpeg-version") { - ffmpegVersion = String(cString: cString) - mpv_free(cString) - } - - // Get mpv-configuration property (compilation flags) - var configuration: String? - if let cString = mpv_get_property_string(mpv, "mpv-configuration") { - configuration = String(cString: cString) - mpv_free(cString) - } - - // Get libmpv API version - let apiVersion = mpv_client_api_version() - - return MPVVersionInfo( - mpvVersion: mpvVersion, - ffmpegVersion: ffmpegVersion, - configuration: configuration, - apiVersion: UInt(apiVersion) - ) - } - } - /// Raw debug properties fetched in a single sync block to minimize lock contention. struct DebugProperties { var videoCodec: String? @@ -1258,89 +1096,6 @@ final class MPVClient: @unchecked Sendable { } } - /// Get a double property value asynchronously (non-blocking). - /// Use this from MainActor code instead of getDouble() to avoid blocking the UI. - /// - Parameter name: The property name (e.g., "time-pos", "duration") - /// - Returns: The double value, or nil if the property doesn't exist or MPV is destroyed - func getDoubleAsync(_ name: String) async -> Double? { - await withCheckedContinuation { continuation in - mpvQueue.async { [weak self] in - guard let self, let mpv = self.mpv, !self.isDestroyed else { - continuation.resume(returning: nil) - return - } - var value: Double = 0 - let result = mpv_get_property(mpv, name, MPV_FORMAT_DOUBLE, &value) - continuation.resume(returning: result >= 0 ? value : nil) - } - } - } - - /// Get a string property value asynchronously (non-blocking). - /// Use this from MainActor code instead of getString() to avoid blocking the UI. - /// - Parameter name: The property name (e.g., "video-codec", "hwdec-current") - /// - Returns: The string value, or nil if the property doesn't exist or MPV is destroyed - func getStringAsync(_ name: String) async -> String? { - await withCheckedContinuation { continuation in - mpvQueue.async { [weak self] in - guard let self, let mpv = self.mpv, !self.isDestroyed else { - continuation.resume(returning: nil) - return - } - guard let cString = mpv_get_property_string(mpv, name) else { - continuation.resume(returning: nil) - return - } - let string = String(cString: cString) - mpv_free(cString) - continuation.resume(returning: string) - } - } - } - - /// Get an integer property value asynchronously (non-blocking). - /// Use this from MainActor code instead of getInt() to avoid blocking the UI. - /// - Parameter name: The property name (e.g., "width", "height") - /// - Returns: The integer value, or nil if the property doesn't exist or MPV is destroyed - func getIntAsync(_ name: String) async -> Int? { - await withCheckedContinuation { continuation in - mpvQueue.async { [weak self] in - guard let self, let mpv = self.mpv, !self.isDestroyed else { - continuation.resume(returning: nil) - return - } - var value: Int64 = 0 - let result = mpv_get_property(mpv, name, MPV_FORMAT_INT64, &value) - continuation.resume(returning: result >= 0 ? Int(value) : nil) - } - } - } - - /// Get the demuxer cache state asynchronously (non-blocking). - /// Use this from MainActor code instead of getCacheState() to avoid blocking the UI. - /// - Returns: The cache state information, or nil if unavailable or MPV is destroyed - func getCacheStateAsync() async -> MPVCacheState? { - await withCheckedContinuation { continuation in - mpvQueue.async { [weak self] in - guard let self, let mpv = self.mpv, !self.isDestroyed else { - continuation.resume(returning: nil) - return - } - - var node = mpv_node() - let result = mpv_get_property(mpv, "demuxer-cache-state", MPV_FORMAT_NODE, &node) - guard result >= 0 else { - continuation.resume(returning: nil) - return - } - - let cacheState = self.parseCacheStateSync(node) - mpv_free_node_contents(&node) - continuation.resume(returning: cacheState) - } - } - } - /// Get MPV version and build information asynchronously (non-blocking). /// Use this from MainActor code instead of getVersionInfo() to avoid blocking the UI. /// - Returns: The version information, or nil if unavailable or MPV is destroyed @@ -1858,12 +1613,6 @@ final class MPVClient: @unchecked Sendable { // MARK: - Render Context - /// Get the MPV handle for render context creation. - /// Must be called on mpvQueue. - var mpvHandle: OpaquePointer? { - mpvQueue.sync { mpv } - } - /// Create a render context for OpenGL rendering. /// - Parameter getProcAddress: Function to get OpenGL proc addresses /// - Returns: Whether creation succeeded diff --git a/Yattee/Services/Player/MPV/MPVOGLView.swift b/Yattee/Services/Player/MPV/MPVOGLView.swift index fb146f2a..38d462d8 100644 --- a/Yattee/Services/Player/MPV/MPVOGLView.swift +++ b/Yattee/Services/Player/MPV/MPVOGLView.swift @@ -57,11 +57,6 @@ final class MPVOGLView: NSView { /// Actual display link frame rate. var displayLinkActualFPS: Double = 60.0 - /// Current display link target frame rate (for debug overlay). - var displayLinkTargetFPS: Double { - displayLinkActualFPS - } - // MARK: - PiP Properties (forwarded to layer) /// Whether to capture frames for PiP. @@ -253,12 +248,6 @@ final class MPVOGLView: NSView { mpvClient?.reportSwap() } - /// Update display link for the current display. - func updateDisplayLink() { - // With CADisplayLink, we just need to update the refresh rate info - updateDisplayRefreshRate() - } - /// Update the cached display refresh rate. private func updateDisplayRefreshRate() { guard let screen = window?.screen else { diff --git a/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift b/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift index 54928e9a..97bda493 100644 --- a/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift +++ b/Yattee/Services/Player/MPV/MPVOpenGLLayer.swift @@ -59,7 +59,6 @@ struct MPVRenderHealth { var draws: UInt64 var skips: UInt64 var droppedDraws: UInt64 - var lastDrawAge: TimeInterval? } // MARK: - MPVOpenGLLayer @@ -113,7 +112,6 @@ final class MPVOpenGLLayer: CAOpenGLLayer { private var successfulDrawCount: UInt64 = 0 private var skipRenderCount: UInt64 = 0 private var droppedDrawCount: UInt64 = 0 - private var lastSuccessfulDrawTime: CFTimeInterval? private var skipsSinceLastDraw: UInt64 = 0 private let renderHealthLock = NSLock() @@ -377,7 +375,6 @@ final class MPVOpenGLLayer: CAOpenGLLayer { renderHealthLock.withLock { successfulDrawCount += 1 skipsSinceLastDraw = 0 - lastSuccessfulDrawTime = CACurrentMediaTime() } // Clear the buffer @@ -699,8 +696,7 @@ final class MPVOpenGLLayer: CAOpenGLLayer { MPVRenderHealth( draws: successfulDrawCount, skips: skipRenderCount, - droppedDraws: droppedDrawCount, - lastDrawAge: lastSuccessfulDrawTime.map { CACurrentMediaTime() - $0 } + droppedDraws: droppedDrawCount ) } } diff --git a/Yattee/Services/Player/MPV/MPVPiPBridge.swift b/Yattee/Services/Player/MPV/MPVPiPBridge.swift index 3a6e84f6..68b7141b 100644 --- a/Yattee/Services/Player/MPV/MPVPiPBridge.swift +++ b/Yattee/Services/Player/MPV/MPVPiPBridge.swift @@ -67,9 +67,6 @@ final class MPVPiPBridge: NSObject { /// KVO observation for isPictureInPicturePossible private var pipPossibleObservation: NSKeyValueObservation? - /// Current PiP render size from AVPictureInPictureController - private var currentPiPRenderSize: CMVideoDimensions? - /// Current video aspect ratio (width / height) private var videoAspectRatio: CGFloat = 16.0 / 9.0 @@ -396,36 +393,6 @@ final class MPVPiPBridge: NSObject { } #endif - /// Move the sample buffer layer to a new container view. - /// This is needed when transitioning from fullscreen to PiP, - /// as the layer must be in a visible window hierarchy. - func moveLayer(to containerView: PlatformView) { - sampleBufferLayer.removeFromSuperlayer() - self.containerView = containerView - if let frame = sanitizedGeometry(containerView.bounds, from: "moveLayer(to:)") { - sampleBufferLayer.frame = frame - } - #if os(iOS) - containerView.layer.addSublayer(sampleBufferLayer) - #elseif os(macOS) - containerView.wantsLayer = true - containerView.layer?.addSublayer(sampleBufferLayer) - #endif - } - - /// Clean up and release resources. - func cleanup() { - NotificationCenter.default.removeObserver(self) - pipPossibleObservation?.invalidate() - pipPossibleObservation = nil - lastPixelBuffer = nil - pipController?.stopPictureInPicture() - pipController = nil - sampleBufferLayer.removeFromSuperlayer() - sampleBufferLayer.sampleBufferRenderer.flush(removingDisplayedImage: true, completionHandler: nil) - mpvBackend = nil - } - /// Flush the sample buffer to clear any displayed frame. /// Call this when stopping playback to prevent stale frames when reusing backend. func flushBuffer() { @@ -633,20 +600,6 @@ final class MPVPiPBridge: NSObject { notifyPiPPossibleState() } #endif - - /// Toggle Picture-in-Picture. - func togglePiP() { - if isPiPActive { - stopPiP() - } else { - startPiP() - } - } - - /// Invalidate and update the playback state in PiP window. - func invalidatePlaybackState() { - pipController?.invalidatePlaybackState() - } } // MARK: - AVPictureInPictureSampleBufferPlaybackDelegate @@ -720,7 +673,6 @@ extension MPVPiPBridge: AVPictureInPictureSampleBufferPlaybackDelegate { didTransitionToRenderSize newRenderSize: CMVideoDimensions ) { Task { @MainActor in - currentPiPRenderSize = newRenderSize onPiPRenderSizeChanged?(newRenderSize) LoggingService.shared.debug("MPVPiPBridge: PiP render size changed to \(newRenderSize.width)x\(newRenderSize.height)", category: .mpv) } @@ -734,8 +686,6 @@ extension MPVPiPBridge: AVPictureInPictureControllerDelegate { _ pictureInPictureController: AVPictureInPictureController ) { Task { @MainActor in - // Reset PiP render size - will be updated by didTransitionToRenderSize - currentPiPRenderSize = nil // Reset restore flag - will be set if user clicks restore button restoreWasRequested = false // Show the sample buffer layer when PiP starts diff --git a/Yattee/Services/Player/MPV/MPVRenderView.swift b/Yattee/Services/Player/MPV/MPVRenderView.swift index 6b0b0ced..2318c3f2 100644 --- a/Yattee/Services/Player/MPV/MPVRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVRenderView.swift @@ -104,12 +104,8 @@ final class MPVRenderView: UIView { private var renderWidth: GLint = 0 private var renderHeight: GLint = 0 - /// Last known stable size (for throttling resize events) - private var lastStableSize: CGSize = .zero /// Pending resize work item (for debouncing) private var pendingResizeWorkItem: DispatchWorkItem? - /// Minimum time between framebuffer recreations - private let resizeDebounceInterval: TimeInterval = 0.1 /// Dedicated queue for OpenGL rendering (off main thread). private let renderQueue = DispatchQueue(label: "stream.yattee.mpv.render", qos: .userInteractive) @@ -507,7 +503,6 @@ final class MPVRenderView: UIView { // Always recreate framebuffer immediately to ensure video is properly sized // This is important during rotation animations - lastStableSize = bounds.size renderQueue.sync { destroyFramebuffer() createFramebuffer() @@ -565,7 +560,6 @@ final class MPVRenderView: UIView { // Create framebuffer only if view has valid bounds // (createFramebuffer makes the context current itself and unbinds on exit) - lastStableSize = bounds.size if bounds.size != .zero { renderQueue.sync { createFramebuffer() diff --git a/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift b/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift index 4924355e..1fd8362e 100644 --- a/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift +++ b/Yattee/Services/Player/MPV/MPVSoftwareRenderView.swift @@ -77,8 +77,6 @@ final class MPVSoftwareRenderView: UIView { /// Dedicated queue for rendering operations private let renderQueue = DispatchQueue(label: "stream.yattee.mpv.software-render", qos: .userInitiated) - /// Lock for buffer recreation - private let bufferLock = NSLock() private var isRecreatingBuffer = false // MARK: - Initialization diff --git a/Yattee/Services/Player/MPVBackend.swift b/Yattee/Services/Player/MPVBackend.swift index 94bc0979..6e08f32d 100644 --- a/Yattee/Services/Player/MPVBackend.swift +++ b/Yattee/Services/Player/MPVBackend.swift @@ -60,19 +60,6 @@ final class MPVBackend: PlayerBackend { } } - /// Panscan value (0.0 = aspect fit with black bars, 1.0 = aspect fill/crop) - var panscan: Double { - get { _panscan } - set { - _panscan = max(0, min(1, newValue)) - mpvClient?.setProperty("panscan", _panscan) - } - } - - var supportedFormats: Set<StreamFormat> { - Set(StreamFormat.allCases) // MPV supports all formats - } - // MARK: - Public Properties /// MPV version and build information (available after initialization). @@ -92,15 +79,9 @@ final class MPVBackend: PlayerBackend { private var _rate: Float = 1.0 private var _volume: Float = 1.0 private var _isMuted: Bool = false - private var _panscan: Double = 0.0 private var currentStream: Stream? - private var currentAudioStream: Stream? private var currentCaption: Caption? - private var pendingAutoplay: Bool = false - - /// Tracks reported by mpv for the loaded file (embedded and external). - private(set) var currentTracks: [MPVTrack] = [] // Retry mechanism: 4 attempts with increasing timeouts and delays // Attempt 1: 3s timeout, 1s delay | Attempt 2: 3s timeout, 3s delay @@ -447,9 +428,6 @@ final class MPVBackend: PlayerBackend { // Store for potential retries currentStream = stream - currentAudioStream = audioStream - pendingAutoplay = autoplay - pendingUseEDL = actualUseEDL retryCount = 0 // Reset retry state in UI @@ -459,8 +437,6 @@ final class MPVBackend: PlayerBackend { try await loadWithRetry(stream: stream, audioStream: audioStream, autoplay: autoplay, useEDL: actualUseEDL, loadingID: loadingID) } - private var pendingUseEDL: Bool = true - private func loadWithRetry(stream: Stream, audioStream: Stream?, autoplay: Bool, useEDL: Bool, loadingID: UUID) async throws { // Check if this load operation was cancelled (a new load started) guard currentLoadingID == loadingID else { @@ -495,7 +471,6 @@ final class MPVBackend: PlayerBackend { mpvClient?.removeAllSubtitlesAsync() // Clear stale track info; the observer repopulates it once the new file loads - currentTracks = [] delegate?.backend(self, didUpdateTracks: []) // Reset first-frame tracking for new content @@ -618,8 +593,6 @@ final class MPVBackend: PlayerBackend { private func resetRetryState() { retryCount = 0 currentStream = nil - currentAudioStream = nil - pendingAutoplay = false delegate?.backend(self, didUpdateRetryState: 0, maxRetries: maxRetries, isRetrying: false, exhausted: false) } @@ -863,43 +836,6 @@ final class MPVBackend: PlayerBackend { private var pendingInitialSeek = false - // MARK: - Backend Switching - - func captureState() -> BackendState { - BackendState( - currentTime: currentTime, - duration: duration, - rate: _rate, - volume: _volume, - isMuted: _isMuted, - isPlaying: isPlaying - ) - } - - func restore(state: BackendState) async { - _rate = state.rate - _volume = state.volume - _isMuted = state.isMuted - - // Apply to MPV - mpvClient?.setProperty("speed", Double(state.rate)) - mpvClient?.setProperty("volume", Double(state.volume * 100)) - mpvClient?.setProperty("mute", state.isMuted) - - if state.currentTime > 0 { - await seek(to: state.currentTime) - } - - if state.isPlaying { - play() - } - } - - func prepareForHandoff() { - mpvClient?.pause() - isPlaying = false - } - // MARK: - Subtitles /// Load and display a caption/subtitle track. @@ -922,11 +858,6 @@ final class MPVBackend: PlayerBackend { currentCaption = caption } - /// Get the currently loaded caption. - func getCurrentCaption() -> Caption? { - currentCaption - } - // MARK: - Embedded Tracks /// Select an embedded audio track by mpv track id (live, no reload). @@ -955,16 +886,6 @@ final class MPVBackend: PlayerBackend { mpvClient?.updateAudioDelay(milliseconds: milliseconds) } - /// Get the actual video track dimensions from MPV. - /// Returns (width, height) or nil if not available. - func getVideoSize() -> (width: Int, height: Int)? { - // Use cached values (updated via property observation) to avoid sync fetch - guard videoWidth > 0, videoHeight > 0 else { - return nil - } - return (videoWidth, videoHeight) - } - /// Get debug statistics from MPV for the debug overlay. /// Uses batch fetch to minimize lock contention (single sync block instead of ~25 separate calls). func getDebugStats() -> MPVDebugStats { @@ -1254,22 +1175,6 @@ final class MPVBackend: PlayerBackend { pipBridge.enqueueFrame(pixelBuffer, presentationTime: presentationTime) } - /// Clean up PiP resources. - func cleanupPiP() { - _playerView?.captureFramesForPiP = false - _playerView?.onFrameReady = nil - pipBridge?.cleanup() - pipBridge = nil - isPiPSetUp = false - } - - /// Move the PiP layer to a new container view. - /// Call this before starting PiP from fullscreen to ensure the layer - /// is in the main window's view hierarchy. - func movePiPLayer(to containerView: UIView) { - pipBridge?.moveLayer(to: containerView) - } - #elseif os(macOS) // MARK: - Picture-in-Picture (macOS) @@ -1437,20 +1342,6 @@ final class MPVBackend: PlayerBackend { pipBridge.enqueueFrame(pixelBuffer, presentationTime: presentationTime) } - - /// Clean up PiP resources. - func cleanupPiP() { - _playerView?.captureFramesForPiP = false - _playerView?.onFrameReady = nil - pipBridge?.cleanup() - pipBridge = nil - isPiPSetUp = false - } - - /// Move the PiP layer to a new container view. - func movePiPLayer(to containerView: NSView) { - pipBridge?.moveLayer(to: containerView) - } #endif // MARK: - Private Methods @@ -1609,7 +1500,6 @@ extension MPVBackend: MPVClientDelegate { nonisolated func mpvClient(_ client: MPVClient, didUpdateTrackList tracks: [MPVTrack]) { Task { @MainActor [weak self] in guard let self else { return } - self.currentTracks = tracks self.delegate?.backend(self, didUpdateTracks: tracks) } } diff --git a/Yattee/Services/Player/NowPlayingService.swift b/Yattee/Services/Player/NowPlayingService.swift index 975edd17..0b816c0d 100644 --- a/Yattee/Services/Player/NowPlayingService.swift +++ b/Yattee/Services/Player/NowPlayingService.swift @@ -37,7 +37,6 @@ final class NowPlayingService { weak var playerService: PlayerService? weak var deArrowBrandingProvider: DeArrowBrandingProvider? - weak var settingsManager: SettingsManager? weak var playerControlsLayoutService: PlayerControlsLayoutService? // MARK: - Initialization diff --git a/Yattee/Services/Player/PlayerBackend.swift b/Yattee/Services/Player/PlayerBackend.swift index d83e1180..ccb653a4 100644 --- a/Yattee/Services/Player/PlayerBackend.swift +++ b/Yattee/Services/Player/PlayerBackend.swift @@ -9,40 +9,6 @@ import Foundation import AVFoundation import SwiftUI -#if os(iOS) || os(tvOS) -import UIKit -#elseif os(macOS) -import AppKit -#endif - -// MARK: - Backend State - -/// Captured state for backend switching. -struct BackendState: Sendable { - let currentTime: TimeInterval - let duration: TimeInterval - let rate: Float - let volume: Float - let isMuted: Bool - let isPlaying: Bool - - init( - currentTime: TimeInterval = 0, - duration: TimeInterval = 0, - rate: Float = 1.0, - volume: Float = 1.0, - isMuted: Bool = false, - isPlaying: Bool = false - ) { - self.currentTime = currentTime - self.duration = duration - self.rate = rate - self.volume = volume - self.isMuted = isMuted - self.isPlaying = isPlaying - } -} - // MARK: - Stream Format /// Stream format categories for backend compatibility. @@ -57,12 +23,6 @@ enum StreamFormat: String, CaseIterable, Sendable { case audioOpus // Opus audio case audioMP3 // MP3 audio - /// Whether MPV can play this format. - var isMPVCompatible: Bool { - // MPV supports all formats - true - } - /// Detect format from stream properties. static func detect(from stream: Stream) -> StreamFormat { let format = stream.format.lowercased() @@ -148,7 +108,6 @@ protocol PlayerBackendDelegate: AnyObject { func backend(_ backend: any PlayerBackend, didChangeState state: PlaybackState) func backend(_ backend: any PlayerBackend, didUpdateBufferedTime time: TimeInterval) func backend(_ backend: any PlayerBackend, didUpdateBufferProgress progress: Int) - func backend(_ backend: any PlayerBackend, didEncounterError error: Error) func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int) func backend(_ backend: any PlayerBackend, didUpdateRetryState currentRetry: Int, maxRetries: Int, isRetrying: Bool, exhausted: Bool) func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?) @@ -169,21 +128,6 @@ protocol PlayerBackend: AnyObject { /// Delegate for callbacks. var delegate: PlayerBackendDelegate? { get set } - /// Current playback time in seconds. - var currentTime: TimeInterval { get } - - /// Total duration in seconds. - var duration: TimeInterval { get } - - /// Buffered time in seconds. - var bufferedTime: TimeInterval { get } - - /// Whether the backend is ready to play. - var isReady: Bool { get } - - /// Whether playback is currently active. - var isPlaying: Bool { get } - /// Current playback rate (1.0 = normal). var rate: Float { get set } @@ -193,9 +137,6 @@ protocol PlayerBackend: AnyObject { /// Whether audio is muted. var isMuted: Bool { get set } - /// Formats this backend can play. - var supportedFormats: Set<StreamFormat> { get } - // MARK: - Playback Control /// Load a stream for playback. @@ -226,27 +167,6 @@ protocol PlayerBackend: AnyObject { /// preventing a flash of the video at position 0 before jumping to resume position. func prepareForInitialSeek() - // MARK: - Backend Switching - - /// Capture current state for switching. - func captureState() -> BackendState - - /// Restore state after switching. - func restore(state: BackendState) async - - /// Prepare for handoff to another backend. - func prepareForHandoff() - - // MARK: - View - - #if os(iOS) || os(tvOS) - /// The view displaying video content (UIKit). - var playerView: UIView? { get } - #elseif os(macOS) - /// The view displaying video content (AppKit). - var playerView: NSView? { get } - #endif - // MARK: - Background Playback /// Handle scene phase changes for background playback. @@ -261,24 +181,6 @@ protocol PlayerBackend: AnyObject { // MARK: - Default Implementations extension PlayerBackend { - /// Check if this backend can play a given stream. - func canPlay(stream: Stream) -> Bool { - let format = StreamFormat.detect(from: stream) - return supportedFormats.contains(format) - } - - /// Capture current state with all properties. - func captureState() -> BackendState { - BackendState( - currentTime: currentTime, - duration: duration, - rate: rate, - volume: volume, - isMuted: isMuted, - isPlaying: isPlaying - ) - } - /// Default implementation does nothing. func handleScenePhase(_ phase: ScenePhase, isPiPActive: Bool) { // No-op by default diff --git a/Yattee/Services/Player/PlayerService.swift b/Yattee/Services/Player/PlayerService.swift index 2556b922..7a49f4f7 100644 --- a/Yattee/Services/Player/PlayerService.swift +++ b/Yattee/Services/Player/PlayerService.swift @@ -103,17 +103,11 @@ final class PlayerService { /// The current download being played, if any. private(set) var currentDownload: Download? - /// Whether we're currently playing downloaded content. - var isPlayingDownloadedContent: Bool { - currentDownload != nil - } - /// Whether online streams are currently being loaded. private(set) var isLoadingOnlineStreams: Bool = false // MARK: - Dependencies - private let httpClient: HTTPClient private let contentService: ContentService private let sponsorBlockAPI: SponsorBlockAPI private let returnYouTubeDislikeAPI: ReturnYouTubeDislikeAPI @@ -184,7 +178,6 @@ final class PlayerService { dataManager: DataManager, backendFactory: BackendFactory? = nil ) { - self.httpClient = httpClient self.contentService = contentService self.dataManager = dataManager self.sponsorBlockAPI = SponsorBlockAPI(httpClient: httpClient) @@ -227,22 +220,6 @@ final class PlayerService { // MARK: - Public Methods - /// Prepares a video for playback without loading streams. - /// Shows the video in the player with a play button overlay. - /// Call `play(video:)` to actually start loading and playing. - /// - Parameter video: The video to prepare - func prepare(video: Video) { - LoggingService.shared.logPlayer("Preparing video: \(video.id.id)") - - // Clear streams - availableStreams = [] - originalStreams = [] - - // Set video in state but keep idle (not loading) - state.setCurrentVideo(video, stream: nil) - state.setPlaybackState(.idle) - } - /// Plays a video with optional starting time. /// - Parameters: /// - video: The video to play @@ -1785,113 +1762,6 @@ final class PlayerService { // MARK: - Media Browser Stream Resolution - /// Resolves stream and captions for a media browser video on-demand. - /// - Parameters: - /// - video: The video to resolve - /// - context: Media browser context with source and folder files - /// - Returns: Tuple of (stream, captions) ready for playback - func resolveMediaBrowserStream( - for video: Video, - context: MediaBrowserQueueContext - ) async throws -> (stream: Stream, captions: [Caption]) { - // Find the MediaFile for this video in the folder - // videoID format: "sourceUUID:path" - guard let mediaFile = context.allFilesInFolder.first(where: { $0.id == video.id.videoID }) else { - throw MediaSourceError.pathNotFound("Video file not found in folder context") - } - - // Find matching subtitle files - let subtitleFiles = mediaFile.findMatchingSubtitles(in: context.allFilesInFolder) - - let source = context.source - let password = mediaSourcesManager?.password(for: source) - - switch source.type { - case .webdav: - // Get auth headers for WebDAV - var authHeaders: [String: String]? - if let webDAVClient { - authHeaders = await webDAVClient.authHeaders(for: source, password: password) - } - let stream = mediaFile.toStream(authHeaders: authHeaders) - // Subtitles can use remote URLs directly for WebDAV - return (stream, subtitleFiles) - - case .smb: - guard let smbClient else { - throw MediaSourceError.unknown("SMB client not available") - } - - let folderKey = source.id.uuidString + ":" + context.folderPath - - // Pre-download ALL subtitles in folder on first access. - // Must complete before MPV opens any SMB connection. - // For queued videos from the same folder, subtitles will already be cached. - if !preDownloadedSubtitleFolders.contains(folderKey) { - let allSubtitleFiles = context.allFilesInFolder.filter { $0.isSubtitle } - LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(context.folderPath)") - - for subtitleFile in allSubtitleFiles { - do { - let localURL = try await smbClient.downloadSubtitleToTemp( - file: subtitleFile, - source: source, - password: password, - videoID: folderKey - ) - downloadedSubtitlesCache[subtitleFile.id] = localURL - LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)") - } catch { - LoggingService.shared.error( - "Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)", - category: .general - ) - } - } - - preDownloadedSubtitleFolders.insert(folderKey) - - // Release app SMB context before MPV opens its connection. - // libsmbclient uses process-global talloc state that corrupts - // when two contexts access the same server concurrently. - await smbClient.clearCache(for: source) - LoggingService.shared.logMediaSources("Cleared SMB context cache before playback") - } - - // Construct video playback URL (pure string manipulation, no libsmbclient) - let playbackURL = try await smbClient.constructPlaybackURL( - for: mediaFile, - source: source, - password: password - ) - let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension) - - // Build captions from pre-downloaded local files - var localCaptions: [Caption] = [] - for subtitle in subtitleFiles { - if let subtitleFile = context.allFilesInFolder.first(where: { $0.url == subtitle.url }), - let localURL = downloadedSubtitlesCache[subtitleFile.id] { - localCaptions.append(Caption( - label: subtitle.label, - languageCode: subtitle.languageCode, - url: localURL - )) - LoggingService.shared.info( - "Using pre-downloaded subtitle: \(subtitleFile.name)", - category: .general - ) - } - } - - return (stream, localCaptions) - - case .localFolder: - // Local folder - no auth needed, use file:// URLs directly - let stream = mediaFile.toStream(authHeaders: nil) - return (stream, subtitleFiles) - } - } - /// Resolves stream and subtitles for a media source video by fetching folder contents on-demand. /// Works for all media source types (WebDAV, SMB, local folders) from any playback source /// (Media Browser, Continue Watching, etc.). @@ -2688,7 +2558,6 @@ final class PlayerService { func setSettingsManager(_ manager: SettingsManager) { self.settingsManager = manager self.backendSwitcher.settingsManager = manager - self.nowPlayingService.settingsManager = manager // Reconfigure remote commands now that we have settings nowPlayingService.configureRemoteCommands() // Note: preferredBackendType is now computed directly from settingsManager @@ -3184,10 +3053,6 @@ extension PlayerService: PlayerBackendDelegate { } } - func backend(_ backend: any PlayerBackend, didEncounterError error: Error) { - delegate?.playerService(self, didEncounterError: error) - } - func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int) { guard width > 0, height > 0 else { return } let aspectRatio = Double(width) / Double(height) diff --git a/Yattee/Services/Player/PlayerState.swift b/Yattee/Services/Player/PlayerState.swift index 1af8d9a2..09e0ec87 100644 --- a/Yattee/Services/Player/PlayerState.swift +++ b/Yattee/Services/Player/PlayerState.swift @@ -49,12 +49,6 @@ struct RetryState: Equatable, Sendable { let exhausted: Bool static let idle = RetryState(currentRetry: 0, maxRetries: 5, isRetrying: false, exhausted: false) - - /// Text to display during retries (e.g., "Retrying... (1/5)"). - var displayText: String? { - guard isRetrying, currentRetry > 0 else { return nil } - return String(localized: "player.retry.status \(currentRetry) \(maxRetries)") - } } /// Represents a queued video for playback. @@ -442,12 +436,6 @@ final class PlayerState { /// nil when not buffering or when using AVPlayer backend. var bufferProgress: Int? - /// Whether the video is vertical (portrait orientation). - var isVerticalVideo: Bool { - guard let ratio = videoAspectRatio else { return false } - return ratio < 1.0 - } - /// Display aspect ratio for UI - falls back to 16:9 if unknown. var displayAspectRatio: Double { videoAspectRatio ?? (16.0 / 9.0) diff --git a/Yattee/Services/Player/QueueManager.swift b/Yattee/Services/Player/QueueManager.swift index 7a5f78ea..3c3cfe92 100644 --- a/Yattee/Services/Player/QueueManager.swift +++ b/Yattee/Services/Player/QueueManager.swift @@ -283,22 +283,12 @@ final class QueueManager { playerService?.openVideo(videoToPlay) } - /// Clears the media browser context when switching to a different queue source. - func clearMediaBrowserContext() { - mediaBrowserContext = nil - } - /// Whether there are more items that can be loaded from the queue source. func hasMoreItems() -> Bool { guard let source = currentQueueSource else { return false } return source.supportsContinuation } - /// Sets the queue source for continuation loading. - func setQueueSource(_ source: QueueSource?) { - currentQueueSource = source - } - // MARK: - Proactive Continuation Loading /// Called when a video starts playing to trigger proactive loading. diff --git a/Yattee/Services/Player/ReturnYouTubeDislikeAPI.swift b/Yattee/Services/Player/ReturnYouTubeDislikeAPI.swift index 7b23452d..b508d8b6 100644 --- a/Yattee/Services/Player/ReturnYouTubeDislikeAPI.swift +++ b/Yattee/Services/Player/ReturnYouTubeDislikeAPI.swift @@ -78,9 +78,4 @@ actor ReturnYouTubeDislikeAPI { throw error } } - - /// Clears the cache. - func clearCache() { - votesCache.removeAll() - } } diff --git a/Yattee/Services/PlayerControls/PlayerControlsBackupService.swift b/Yattee/Services/PlayerControls/PlayerControlsBackupService.swift index 0e43a175..f95cc32f 100644 --- a/Yattee/Services/PlayerControls/PlayerControlsBackupService.swift +++ b/Yattee/Services/PlayerControls/PlayerControlsBackupService.swift @@ -63,24 +63,6 @@ actor PlayerControlsBackupService { return presets } - /// Checks if a valid backup exists. - /// - Returns: True if a valid backup file exists. - func hasValidBackup() async -> Bool { - guard fileManager.fileExists(atPath: backupFileURL.path) else { - return false - } - - // Try to decode to verify it's valid - do { - let data = try Data(contentsOf: backupFileURL) - _ = try decoder.decode([LayoutPreset].self, from: data) - return true - } catch { - LoggingService.shared.error("Backup file exists but is invalid: \(error.localizedDescription)") - return false - } - } - /// Attempts to create a recovered preset from corrupted data. /// - Parameter data: The corrupted data. /// - Returns: A recovered preset if partial recovery is possible. @@ -117,16 +99,6 @@ actor PlayerControlsBackupService { return nil } - /// Deletes the backup file. - func deleteBackup() async throws { - guard fileManager.fileExists(atPath: backupFileURL.path) else { - return - } - - try fileManager.removeItem(at: backupFileURL) - LoggingService.shared.info("Deleted player controls backup") - } - // MARK: - Helpers private func formattedDate() -> String { diff --git a/Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift b/Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift index e496da31..40c0af77 100644 --- a/Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift +++ b/Yattee/Services/PlayerControls/PlayerControlsLayoutService.swift @@ -179,22 +179,6 @@ actor PlayerControlsLayoutService { // MARK: - Preset Management - /// Returns all presets for the current device class. - func allPresets() async -> [LayoutPreset] { - if !isLoaded { - _ = try? await loadPresets() - } - return presets.filter { $0.deviceClass == .current } - } - - /// Returns a preset by ID. - func preset(forID id: UUID) async -> LayoutPreset? { - if !isLoaded { - _ = try? await loadPresets() - } - return presets.first { $0.id == id } - } - /// Saves a new or updated preset. func savePreset(_ preset: LayoutPreset) async throws { if !isLoaded { @@ -290,13 +274,6 @@ actor PlayerControlsLayoutService { } } - /// Duplicates a preset with a new name. - func duplicatePreset(_ preset: LayoutPreset, newName: String) async throws -> LayoutPreset { - let duplicate = preset.duplicate(name: newName) - try await savePreset(duplicate) - return duplicate - } - /// Renames a preset. func renamePreset(_ presetID: UUID, to newName: String) async throws { if !isLoaded { @@ -380,31 +357,6 @@ actor PlayerControlsLayoutService { return layout } - // MARK: - Validation - - /// Checks if a preset can be deleted. - func canDeletePreset(id: UUID) async -> Bool { - if !isLoaded { - _ = try? await loadPresets() - } - - guard let preset = presets.first(where: { $0.id == id }) else { - return false - } - - // Cannot delete built-in presets - if preset.isBuiltIn { - return false - } - - // Cannot delete active preset - if activePresetID() == id { - return false - } - - return true - } - // MARK: - NEW Badge Support /// Returns the last seen button version. diff --git a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift index 37fcedca..b3b40a11 100644 --- a/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift +++ b/Yattee/Services/RemoteControl/RemoteControlCoordinator.swift @@ -79,17 +79,6 @@ final class RemoteControlCoordinator { /// The device that's controlling us, if any. private(set) var controllingDevice: DiscoveredDevice? - /// This device's name for display. - var deviceName: String { - get { networkService.deviceName } - set { networkService.deviceName = newValue } - } - - /// This device's ID. - var deviceID: String { - networkService.deviceID - } - // MARK: - Dependencies private let networkService: LocalNetworkService @@ -337,25 +326,8 @@ final class RemoteControlCoordinator { } } - /// Send a command to all connected devices. - func broadcastCommand(_ command: RemoteControlCommand) async { - let commandDesc = String(describing: command).prefix(50) - rcDebug("COMMAND", "Broadcasting: \(commandDesc)") - await networkService.broadcast(command: command) - } - // MARK: - Convenience Commands - /// Send play command to a device. - func play(on device: DiscoveredDevice) async { - await sendCommand(.play, to: device) - } - - /// Send pause command to a device. - func pause(on device: DiscoveredDevice) async { - await sendCommand(.pause, to: device) - } - /// Send toggle play/pause command to a device. func togglePlayPause(on device: DiscoveredDevice) async { await sendCommand(.togglePlayPause, to: device) @@ -441,11 +413,6 @@ final class RemoteControlCoordinator { await sendCommand(.closeVideo, to: device) } - /// Toggle fullscreen on a device. - func toggleFullscreen(on device: DiscoveredDevice) async { - await sendCommand(.toggleFullscreen, to: device) - } - /// Play next video in queue on a device. func playNext(on device: DiscoveredDevice) async { await sendCommand(.playNext, to: device) @@ -1013,18 +980,6 @@ final class RemoteControlCoordinator { } } - private func expandPlayerIfNeeded() { - #if os(iOS) || os(macOS) - let isPiPActive = (playerService?.currentBackend as? MPVBackend)?.isPiPActive ?? false - rcDebug("PLAYER", "expandPlayerIfNeeded - isPiPActive=\(isPiPActive)") - if !isPiPActive { - navigationCoordinator?.expandPlayer() - } - #else - navigationCoordinator?.expandPlayer() - #endif - } - // MARK: - State Broadcasting private func startStateBroadcast() { diff --git a/Yattee/Services/RemotePlaylistsCache.swift b/Yattee/Services/RemotePlaylistsCache.swift index dbbdca1a..27c8edbb 100644 --- a/Yattee/Services/RemotePlaylistsCache.swift +++ b/Yattee/Services/RemotePlaylistsCache.swift @@ -105,11 +105,6 @@ final class RemotePlaylistsCache { lists[accountID] } - /// When the account's list was last fetched. - func listFetchDate(for accountID: UUID) -> Date? { - listFetchDates[accountID] - } - /// One playlist as last fetched, or `nil` if it never was. func snapshot(id: String, for accountID: UUID) -> RemotePlaylistSnapshot? { snapshots[accountID]?[id] diff --git a/Yattee/Services/SubscriptionService.swift b/Yattee/Services/SubscriptionService.swift index 5e2d5db9..4136e893 100644 --- a/Yattee/Services/SubscriptionService.swift +++ b/Yattee/Services/SubscriptionService.swift @@ -377,25 +377,6 @@ final class SubscriptionService { } } - /// Refreshes the subscription cache from the current provider. - func refreshCache() async throws { - guard let provider = readProvider else { - throw SubscriptionError.providerNotAvailable - } - - isLoading = true - lastError = nil - - do { - try await provider.refreshCache() - isLoading = false - } catch { - lastError = error - isLoading = false - throw error - } - } - // MARK: - Import /// Imports parsed external subscriptions (YouTube CSV / OPML) into the active account. diff --git a/Yattee/Services/Subscriptions/DataContextValidator.swift b/Yattee/Services/Subscriptions/DataContextValidator.swift index b706a0bf..91ecceaa 100644 --- a/Yattee/Services/Subscriptions/DataContextValidator.swift +++ b/Yattee/Services/Subscriptions/DataContextValidator.swift @@ -59,7 +59,6 @@ enum DataContextValidity: Hashable, Sendable { @Observable final class DataContextValidator { private let accountsManager: AccountsManager - private let instancesManager: InstancesManager private let toastManager: ToastManager private weak var appEnvironment: AppEnvironment? @@ -69,7 +68,6 @@ final class DataContextValidator { toastManager: ToastManager ) { self.accountsManager = accountsManager - self.instancesManager = instancesManager self.toastManager = toastManager } diff --git a/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift b/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift index 30853219..043d3f80 100644 --- a/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/InvidiousSubscriptionProvider.swift @@ -18,7 +18,6 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { /// The account this provider is bound to. One provider per account, so two /// logins on the same instance never share a subscription cache. let account: Account - var context: DataContext { .account(account.id) } private let invidiousAPI: InvidiousAPI private let credentialsManager: InvidiousCredentialsManager @@ -36,7 +35,6 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { accountsManager.recordServerOutcome(error, for: account) } - private let settingsManager: SettingsManager /// In-memory cache of subscribed channel IDs for fast lookup. private var cachedChannelIDs: Set<String> = [] @@ -60,7 +58,6 @@ final class InvidiousSubscriptionProvider: SubscriptionProvider { self.invidiousAPI = invidiousAPI self.credentialsManager = credentialsManager self.accountsManager = accountsManager - self.settingsManager = settingsManager } // MARK: - SubscriptionProvider diff --git a/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift b/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift index 1d27b41c..4798bc7f 100644 --- a/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/LocalSubscriptionProvider.swift @@ -14,7 +14,6 @@ import Foundation final class LocalSubscriptionProvider: SubscriptionProvider { // MARK: - Properties - let context: DataContext = .local private let dataManager: DataManager // MARK: - Initialization diff --git a/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift b/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift index 311468fa..78dc404d 100644 --- a/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/PipedSubscriptionProvider.swift @@ -18,7 +18,6 @@ final class PipedSubscriptionProvider: SubscriptionProvider { /// The account this provider is bound to. One provider per account, so two /// logins on the same instance never share a subscription cache. let account: Account - var context: DataContext { .account(account.id) } private let pipedAPI: PipedAPI private let credentialsManager: PipedCredentialsManager @@ -36,7 +35,6 @@ final class PipedSubscriptionProvider: SubscriptionProvider { accountsManager.recordServerOutcome(error, for: account) } - private let settingsManager: SettingsManager /// In-memory cache of subscribed channel IDs for fast lookup. private var cachedChannelIDs: Set<String> = [] @@ -60,7 +58,6 @@ final class PipedSubscriptionProvider: SubscriptionProvider { self.pipedAPI = pipedAPI self.credentialsManager = credentialsManager self.accountsManager = accountsManager - self.settingsManager = settingsManager } // MARK: - SubscriptionProvider diff --git a/Yattee/Services/Subscriptions/SubscriptionProvider.swift b/Yattee/Services/Subscriptions/SubscriptionProvider.swift index 7ab9d368..a71ecfb2 100644 --- a/Yattee/Services/Subscriptions/SubscriptionProvider.swift +++ b/Yattee/Services/Subscriptions/SubscriptionProvider.swift @@ -12,13 +12,6 @@ import Foundation /// Each provider implementation handles a specific subscription source. @MainActor protocol SubscriptionProvider: Sendable { - /// The data context this provider serves. - /// - /// A context rather than a backend type: an instance can hold several - /// accounts, so "which Invidious" is no longer enough to identify a - /// provider — each account gets its own, with its own cache. - var context: DataContext { get } - /// Fetches all subscriptions from the provider. /// - Returns: Array of channels the user is subscribed to. func fetchSubscriptions() async throws -> [Channel] diff --git a/Yattee/Services/ToastManager.swift b/Yattee/Services/ToastManager.swift index 71fa1280..8a0e812c 100644 --- a/Yattee/Services/ToastManager.swift +++ b/Yattee/Services/ToastManager.swift @@ -258,50 +258,6 @@ final class ToastManager { dismissTasks[id] = task } - // MARK: - Player Scope Convenience Methods - - /// Show a loading toast (player-only scope, longer auto-dismiss delay). - @discardableResult - func showPlayerLoading(_ title: String, subtitle: String? = nil) -> UUID { - show( - scopes: [.player], - category: .loading, - title: title, - subtitle: subtitle, - icon: nil, // Will show ProgressView - iconColor: nil, - autoDismissDelay: 30.0 // Long timeout for loading states - ) - } - - /// Show a retry toast (player-only scope). - @discardableResult - func showPlayerRetry(_ title: String, subtitle: String? = nil) -> UUID { - show( - scopes: [.player], - category: .retry, - title: title, - subtitle: subtitle, - icon: "arrow.clockwise", - iconColor: .orange, - autoDismissDelay: 5.0 - ) - } - - /// Show an error toast (player-only scope). - @discardableResult - func showPlayerError(_ title: String, subtitle: String? = nil) -> UUID { - show( - scopes: [.player], - category: .error, - title: title, - subtitle: subtitle, - icon: "exclamationmark.triangle.fill", - iconColor: .red, - autoDismissDelay: 5.0 - ) - } - // MARK: - General Convenience Methods /// Show an error toast (shows in player if visible, otherwise main). @@ -350,42 +306,6 @@ final class ToastManager { dismissInternal(id: id, animated: animated) } - /// Dismiss all toasts in a category (across all scopes). - func dismissCategory(_ category: ToastCategory) { - let toastsToDismiss = activeToasts.filter { $0.category == category } - for toast in toastsToDismiss { - dismissInternal(id: toast.id, animated: true) - } - } - - /// Dismiss all toasts in a category for a specific active scope. - func dismissCategory(_ category: ToastCategory, scope: ToastScope) { - let toastsToDismiss = activeToasts.filter { $0.category == category && $0.activeScope == scope } - for toast in toastsToDismiss { - dismissInternal(id: toast.id, animated: true) - } - } - - /// Dismiss all toasts in a specific active scope. - func dismissScope(_ scope: ToastScope) { - let toastsToDismiss = activeToasts.filter { $0.activeScope == scope } - for toast in toastsToDismiss { - dismissInternal(id: toast.id, animated: true) - } - } - - /// Dismiss all toasts. - func dismissAll() { - for task in dismissTasks.values { - task.cancel() - } - dismissTasks.removeAll() - - withAnimation(.easeInOut(duration: 0.25)) { - activeToasts.removeAll() - } - } - // MARK: - Private private func dismissInternal(id: UUID, animated: Bool) { diff --git a/Yattee/ViewModels/OpenedLinksViewModel.swift b/Yattee/ViewModels/OpenedLinksViewModel.swift index afd2ec12..94b94a2c 100644 --- a/Yattee/ViewModels/OpenedLinksViewModel.swift +++ b/Yattee/ViewModels/OpenedLinksViewModel.swift @@ -72,13 +72,6 @@ final class OpenedLinksViewModel { } } - var isLoading: Bool { - links.contains { link in - if case .loading = link.state { return true } - return false - } - } - /// Starts resolving once. Owned by the model rather than the view's /// `.task`, which is cancelled when a result's screen is pushed and would /// fail the links still loading. diff --git a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index 9528db3b..ac2bae0a 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -16,7 +16,6 @@ final class PlayerControlsSettingsViewModel { // MARK: - Dependencies private let layoutService: PlayerControlsLayoutService - let settingsManager: SettingsManager /// Observer for preset changes from CloudKit sync. @ObservationIgnored private var presetsChangedObserver: NSObjectProtocol? @@ -54,12 +53,6 @@ final class PlayerControlsSettingsViewModel { activePreset?.id } - /// Whether the active preset can be deleted (not built-in and not active). - var canDeleteActivePreset: Bool { - guard let activePreset else { return false } - return !activePreset.isBuiltIn - } - /// Whether the active preset can be edited (not built-in). var canEditActivePreset: Bool { guard let activePreset else { return false } @@ -76,16 +69,6 @@ final class PlayerControlsSettingsViewModel { activePreset?.layout.centerSettings ?? .default } - /// Seek backward seconds - exposed for direct observation. - var seekBackwardSeconds: Int { - activePreset?.layout.centerSettings.seekBackwardSeconds ?? 10 - } - - /// Seek forward seconds - exposed for direct observation. - var seekForwardSeconds: Int { - activePreset?.layout.centerSettings.seekForwardSeconds ?? 10 - } - /// Current progress bar settings. var progressBarSettings: ProgressBarSettings { activePreset?.layout.progressBarSettings ?? .default @@ -101,15 +84,6 @@ final class PlayerControlsSettingsViewModel { presets.filter { !$0.isBuiltIn }.sorted { $0.name < $1.name } } - /// Button types that are new since last seen. - var newButtonTypes: [ControlButtonType] { - Task.detached { [layoutService] in - await layoutService.newButtonTypes() - } - // Return empty for now; actual implementation will load async - return [] - } - // MARK: - Initialization init( @@ -117,7 +91,6 @@ final class PlayerControlsSettingsViewModel { settingsManager: SettingsManager ) { self.layoutService = layoutService - self.settingsManager = settingsManager // Observe preset changes from CloudKit sync presetsChangedObserver = NotificationCenter.default.addObserver( @@ -204,13 +177,6 @@ final class PlayerControlsSettingsViewModel { LoggingService.shared.info("Selected preset: \(preset.name)") } - /// Selects a preset by ID. - /// - Parameter id: The ID of the preset to activate. - func selectPreset(id: UUID) { - guard let preset = presets.first(where: { $0.id == id }) else { return } - selectPreset(preset) - } - // MARK: - Preset Management /// Creates a new custom preset with the given name. @@ -290,30 +256,6 @@ final class PlayerControlsSettingsViewModel { // MARK: - Layout Editing - /// Updates the top section of the current layout. - /// - Parameter section: The new top section configuration. - func updateTopSection(_ section: LayoutSection) async { - await updateLayout { layout in - layout.topSection = section - } - } - - /// Updates the bottom section of the current layout. - /// - Parameter section: The new bottom section configuration. - func updateBottomSection(_ section: LayoutSection) async { - await updateLayout { layout in - layout.bottomSection = section - } - } - - /// Updates the center settings of the current layout. - /// - Parameter settings: The new center section settings. - func updateCenterSettings(_ settings: CenterSectionSettings) async { - await updateLayout { layout in - layout.centerSettings = settings - } - } - /// Synchronously updates center settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the center settings. @@ -338,14 +280,6 @@ final class PlayerControlsSettingsViewModel { queueDebouncedSave(preset: preset) } - /// Updates the global settings of the current layout. - /// - Parameter settings: The new global settings. - func updateGlobalSettings(_ settings: GlobalLayoutSettings) async { - await updateLayout { layout in - layout.globalSettings = settings - } - } - /// Synchronously updates global settings with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameter mutation: A closure that mutates the global settings. @@ -382,21 +316,6 @@ final class PlayerControlsSettingsViewModel { queueDebouncedSave(preset: preset) } - /// Updates a button configuration in the specified section. - /// - Parameters: - /// - config: The updated button configuration. - /// - section: The section containing the button. - func updateButtonConfiguration(_ config: ControlButtonConfiguration, in section: LayoutSectionType) async { - await updateLayout { layout in - switch section { - case .top: - layout.topSection.update(button: config) - case .bottom: - layout.bottomSection.update(button: config) - } - } - } - /// Synchronously updates a button configuration with immediate UI feedback. /// Saves are debounced to avoid rapid disk writes. /// - Parameters: @@ -422,22 +341,6 @@ final class PlayerControlsSettingsViewModel { queueDebouncedSave(preset: preset) } - /// Adds a button to the specified section. - /// - Parameters: - /// - buttonType: The type of button to add. - /// - section: The section to add the button to. - func addButton(_ buttonType: ControlButtonType, to section: LayoutSectionType) async { - await updateLayout { layout in - let config = ControlButtonConfiguration.defaultConfiguration(for: buttonType) - switch section { - case .top: - layout.topSection.add(button: config) - case .bottom: - layout.bottomSection.add(button: config) - } - } - } - /// Synchronously adds a button with immediate UI feedback. /// - Parameters: /// - buttonType: The type of button to add. @@ -462,21 +365,6 @@ final class PlayerControlsSettingsViewModel { queueDebouncedSave(preset: preset) } - /// Removes a button from the specified section. - /// - Parameters: - /// - buttonID: The ID of the button to remove. - /// - section: The section containing the button. - func removeButton(_ buttonID: UUID, from section: LayoutSectionType) async { - await updateLayout { layout in - switch section { - case .top: - layout.topSection.remove(id: buttonID) - case .bottom: - layout.bottomSection.remove(id: buttonID) - } - } - } - /// Synchronously removes a button with immediate UI feedback. /// - Parameters: /// - buttonID: The ID of the button to remove. @@ -500,22 +388,6 @@ final class PlayerControlsSettingsViewModel { queueDebouncedSave(preset: preset) } - /// Moves a button within a section. - /// - Parameters: - /// - source: Source indices. - /// - destination: Destination index. - /// - section: The section containing the buttons. - func moveButton(fromOffsets source: IndexSet, toOffset destination: Int, in section: LayoutSectionType) async { - await updateLayout { layout in - switch section { - case .top: - layout.topSection.move(fromOffsets: source, toOffset: destination) - case .bottom: - layout.bottomSection.move(fromOffsets: source, toOffset: destination) - } - } - } - /// Synchronously moves a button with immediate UI feedback. /// - Parameters: /// - source: Source indices. @@ -850,13 +722,6 @@ final class PlayerControlsSettingsViewModel { } } - /// Moves pages within the panel page order. - func movePanelPages(fromOffsets source: IndexSet, toOffset destination: Int) { - updatePanelSettingsSync { settings in - settings.movePages(fromOffsets: source, toOffset: destination) - } - } - /// Reorders the pages a settings list displays; offsets are relative to /// `visible` (see `PanelSettings.moveVisiblePages`). func movePanelPages(visible: [PanelPageType], fromOffsets source: IndexSet, toOffset destination: Int) { @@ -981,32 +846,6 @@ final class PlayerControlsSettingsViewModel { // MARK: - Private Helpers - /// Updates the layout and saves to storage. - /// - Parameter mutation: A closure that mutates the layout. - private func updateLayout(_ mutation: (inout PlayerControlsLayout) -> Void) async { - guard let activePreset, !activePreset.isBuiltIn else { - error = "Cannot edit built-in presets. Duplicate it first." - return - } - - var layout = activePreset.layout - mutation(&layout) - - // Update local state immediately for responsive UI - self.activePreset = activePreset.withUpdatedLayout(layout) - - // Save to storage - isSaving = true - do { - try await layoutService.updatePresetLayout(activePreset.id, layout: layout) - await refreshPresets() - } catch { - self.error = error.localizedDescription - LoggingService.shared.error("Failed to save layout: \(error.localizedDescription)") - } - isSaving = false - } - /// Queues a debounced save operation. /// Cancels any pending save and waits before persisting to avoid rapid disk writes. /// - Parameter preset: The preset to save. @@ -1029,21 +868,6 @@ final class PlayerControlsSettingsViewModel { } } - // MARK: - NEW Badge Support - - /// Marks all button types as seen, hiding NEW badges. - func markAllButtonsAsSeen() async { - await layoutService.markAllButtonsAsSeen() - } - - /// Returns whether a button type should show the NEW badge. - /// - Parameter type: The button type to check. - /// - Returns: True if the button was added after the last seen version. - func isNewButton(_ type: ControlButtonType) async -> Bool { - let newTypes = await layoutService.newButtonTypes() - return newTypes.contains(type) - } - // MARK: - Error Handling /// Clears the current error. diff --git a/Yattee/Views/Channel/ChannelView.swift b/Yattee/Views/Channel/ChannelView.swift index 4db5d715..a76f3d44 100644 --- a/Yattee/Views/Channel/ChannelView.swift +++ b/Yattee/Views/Channel/ChannelView.swift @@ -307,11 +307,6 @@ struct ChannelView: View { avatarSize - (avatarSize - collapsedAvatarSize) * collapseProgress } - /// Opacity for expanded content (fades out as we scroll) - private var expandedContentOpacity: CGFloat { - 1 - min(collapseProgress * 2, 1) - } - /// Opacity for collapsed title (fades in as we scroll) private var collapsedTitleOpacity: CGFloat { max((collapseProgress - 0.5) * 2, 0) @@ -785,11 +780,6 @@ struct ChannelView: View { .animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked) } - @ViewBuilder - private func tvOSAvatar(for channel: Channel) -> some View { - tvOSAvatar(name: channel.name, thumbnailURL: channel.thumbnailURL) - } - @ViewBuilder private func tvOSAvatar(name: String, thumbnailURL: URL?) -> some View { LazyImage(url: thumbnailURL) { state in @@ -2246,13 +2236,6 @@ struct ChannelView: View { isSearchLoading = false } - private func clearSearch() { - isSearchActive = false - hasSearched = false - searchResults = .empty - searchText = "" - } - // MARK: - Error View private func errorView(_ error: String) -> some View { @@ -2272,11 +2255,6 @@ struct ChannelView: View { // MARK: - Helpers - private func formatSubscribers(_ count: Int) -> String { - let formatted = CountFormatter.compact(count) - return String(localized: "channel.subscriberCount \(formatted)") - } - /// Returns the appropriate instance for the channel's content source private func instanceForSource() -> Instance? { appEnvironment?.instancesManager.instance(for: source) diff --git a/Yattee/Views/Components/BookmarkRowView.swift b/Yattee/Views/Components/BookmarkRowView.swift index c76612e8..ef5416b1 100644 --- a/Yattee/Views/Components/BookmarkRowView.swift +++ b/Yattee/Views/Components/BookmarkRowView.swift @@ -13,8 +13,6 @@ import SwiftUI /// Automatically handles DeArrow integration. /// Supports optional queue context for auto-play functionality. struct BookmarkRowView: View { - @Environment(\.appEnvironment) private var appEnvironment - let bookmark: Bookmark var style: VideoRowStyle = .regular var watchProgress: Double? = nil @@ -27,10 +25,6 @@ struct BookmarkRowView: View { var videoIndex: Int? = nil var loadMoreVideos: LoadMoreVideosCallback? = nil - private var accentColor: Color { - appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor - } - private var isBookmarkValid: Bool { bookmark.modelContext != nil && !bookmark.isDeleted } diff --git a/Yattee/Views/Components/ExpandedCommentsView.swift b/Yattee/Views/Components/ExpandedCommentsView.swift index 6f0cbd76..b0d617f2 100644 --- a/Yattee/Views/Components/ExpandedCommentsView.swift +++ b/Yattee/Views/Components/ExpandedCommentsView.swift @@ -27,14 +27,6 @@ struct ExpandedCommentsView: View { @State private var scrollBounceOffset: CGFloat = 0 @State private var isDismissThresholdReached = false - private var playerState: PlayerState? { appEnvironment?.playerService.state } - private var contentService: ContentService? { appEnvironment?.contentService } - private var instancesManager: InstancesManager? { appEnvironment?.instancesManager } - - private var comments: [Comment] { playerState?.comments ?? [] } - private var commentsState: CommentsLoadState { playerState?.commentsState ?? .idle } - private var videoSource: ContentSource? { playerState?.currentVideo?.id.source } - private var backgroundColor: Color { #if os(iOS) Color(.systemBackground) @@ -363,53 +355,6 @@ struct CommentsListContent: View { } } -// MARK: - Scroll Fade Overlay - -/// A view modifier that applies a gradient fade overlay to the top and bottom edges of a scroll view. -private struct ScrollFadeOverlayModifier: ViewModifier { - let fadeHeight: CGFloat - - func body(content: Content) -> some View { - content - .overlay(alignment: .top) { - // Top gradient fade - LinearGradient( - colors: [backgroundColor, .clear], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: fadeHeight) - .allowsHitTesting(false) - } - .overlay(alignment: .bottom) { - // Bottom gradient fade - LinearGradient( - colors: [.clear, backgroundColor], - startPoint: .top, - endPoint: .bottom - ) - .frame(height: fadeHeight) - .allowsHitTesting(false) - } - } - - private var backgroundColor: Color { - #if os(iOS) - Color(.systemBackground) - #elseif os(macOS) - Color(nsColor: .windowBackgroundColor) - #else - Color.black - #endif - } -} - -private extension View { - func scrollFadeMask(fadeHeight: CGFloat = 24) -> some View { - modifier(ScrollFadeOverlayModifier(fadeHeight: fadeHeight)) - } -} - // MARK: - Scroll Detection #if os(iOS) diff --git a/Yattee/Views/Components/LoadStateView.swift b/Yattee/Views/Components/LoadStateView.swift index 5be39d25..ec508bad 100644 --- a/Yattee/Views/Components/LoadStateView.swift +++ b/Yattee/Views/Components/LoadStateView.swift @@ -83,52 +83,6 @@ struct ErrorStateView: View { } } -// MARK: - Load State View (Generic Container) - -/// A generic container that handles loading, error, and content states. -/// -/// Example usage: -/// ```swift -/// LoadStateView( -/// isLoading: viewModel.isLoading, -/// errorMessage: viewModel.error, -/// data: viewModel.data, -/// loadingMessage: "Loading...", -/// errorTitle: "Error", -/// onRetry: { await viewModel.load() } -/// ) { data in -/// ContentView(data: data) -/// } -/// ``` -struct LoadStateView<Content: View, Data>: View { - let isLoading: Bool - let errorMessage: String? - let data: Data? - var loadingMessage: String? = nil - var loadingSubtext: String? = nil - var errorTitle: String = "Error" - var onRetry: (() async -> Void)? = nil - var onDismiss: (() -> Void)? = nil - @ViewBuilder let content: (Data) -> Content - - var body: some View { - Group { - if isLoading && data == nil { - LoadingView(message: loadingMessage, subtext: loadingSubtext) - } else if let error = errorMessage, data == nil { - ErrorStateView( - title: errorTitle, - message: error, - onRetry: onRetry, - onDismiss: onDismiss - ) - } else if let data { - content(data) - } - } - } -} - // MARK: - Previews #Preview("Loading") { @@ -143,38 +97,3 @@ struct LoadStateView<Content: View, Data>: View { onDismiss: {} ) } - -#Preview("LoadStateView - Loading") { - LoadStateView( - isLoading: true, - errorMessage: nil, - data: nil as String?, - loadingMessage: "Fetching data..." - ) { data in - Text(data) - } -} - -#Preview("LoadStateView - Error") { - LoadStateView( - isLoading: false, - errorMessage: "Network error occurred", - data: nil as String?, - errorTitle: "Failed to Load", - onRetry: {} - ) { data in - Text(data) - } -} - -#Preview("LoadStateView - Content") { - LoadStateView( - isLoading: false, - errorMessage: nil, - data: "Hello, World!", - loadingMessage: "Loading..." - ) { data in - Text(data) - .font(.largeTitle) - } -} diff --git a/Yattee/Views/Components/ShareSheet.swift b/Yattee/Views/Components/ShareSheet.swift index 4c1e4d20..592eec94 100644 --- a/Yattee/Views/Components/ShareSheet.swift +++ b/Yattee/Views/Components/ShareSheet.swift @@ -24,34 +24,4 @@ struct ShareSheet: UIViewControllerRepresentable { func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } -#elseif os(macOS) -struct ShareSheet: View { - let items: [Any] - - var body: some View { - VStack(spacing: 12) { - if let text = items.first as? String { - ScrollView { - Text(text) - .font(.caption) - .fontDesign(.monospaced) - .textSelection(.enabled) - .frame(maxWidth: .infinity, alignment: .leading) - .padding() - } - .frame(maxHeight: 400) - } - - Button(String(localized: "settings.advanced.logs.export.copy")) { - if let text = items.first as? String { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - } - .keyboardShortcut(.defaultAction) - } - .padding() - .frame(minWidth: 420, minHeight: 320) - } -} #endif diff --git a/Yattee/Views/Components/SourceListContainer.swift b/Yattee/Views/Components/SourceListContainer.swift deleted file mode 100644 index 20fd700e..00000000 --- a/Yattee/Views/Components/SourceListContainer.swift +++ /dev/null @@ -1,144 +0,0 @@ -// -// SourceListContainer.swift -// Yattee -// -// Container for source list views with inset/plain styling. -// - -import SwiftUI - -/// A container that handles inset/plain list styling for source views. -/// -/// Simplified version of VideoListContainer for simpler source rows. -struct SourceListContainer<Header: View, Content: View>: View { - let listStyle: VideoListStyle - - @ViewBuilder let header: () -> Header - @ViewBuilder let content: () -> Content - - /// Corner radius for card container in inset style. - private let cardCornerRadius: CGFloat = 10 - - /// Horizontal padding for card container. - private let cardHorizontalPadding: CGFloat = 16 - - /// Bottom padding for card container. - private let cardBottomPadding: CGFloat = 16 - - var body: some View { - #if os(tvOS) - // tvOS: Simple ScrollView without background overlay - ScrollView { - LazyVStack(spacing: 0) { - header() - content() - } - } - #else - // iOS/macOS: Background overlay pattern - backgroundStyle.color - .ignoresSafeArea() - .overlay( - ScrollView { - LazyVStack(spacing: 0) { - header() - - if listStyle == .inset { - insetCardContent - } else { - plainContent - } - } - } - ) - #endif - } - - // MARK: - Private - - private var backgroundStyle: ListBackgroundStyle { - listStyle == .inset ? .grouped : .plain - } - - private var insetCardContent: some View { - LazyVStack(spacing: 0) { - content() - } - .background(ListBackgroundStyle.card.color) - .clipShape(RoundedRectangle(cornerRadius: cardCornerRadius)) - .padding(.horizontal, cardHorizontalPadding) - .padding(.bottom, cardBottomPadding) - } - - private var plainContent: some View { - LazyVStack(spacing: 0) { - content() - } - .padding(.bottom, cardBottomPadding) - } -} - -// MARK: - Convenience Initializers - -extension SourceListContainer where Header == EmptyView { - /// Creates a source list container without a header. - init( - listStyle: VideoListStyle, - @ViewBuilder content: @escaping () -> Content - ) { - self.listStyle = listStyle - self.header = { EmptyView() } - self.content = content - } -} - -// MARK: - Preview - -#Preview("Inset Style") { - SourceListContainer(listStyle: .inset) { - Text("Header") - .padding() - } content: { - ForEach(0..<5) { index in - SourceListRow(isLast: index == 4, listStyle: .inset) { - HStack(spacing: 12) { - Image(systemName: "server.rack") - .font(.title2) - .foregroundStyle(.blue) - .frame(width: 32) - VStack(alignment: .leading) { - Text("Source \(index + 1)") - .font(.headline) - Text("example.com") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - } - } - } -} - -#Preview("Plain Style") { - SourceListContainer(listStyle: .plain) { - ForEach(0..<5) { index in - SourceListRow(isLast: index == 4, listStyle: .plain) { - HStack(spacing: 12) { - Image(systemName: "server.rack") - .font(.title2) - .foregroundStyle(.blue) - .frame(width: 32) - VStack(alignment: .leading) { - Text("Source \(index + 1)") - .font(.headline) - Text("example.com") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - } - } - } - } -} diff --git a/Yattee/Views/Components/SwipeActionModifier.swift b/Yattee/Views/Components/SwipeActionModifier.swift index 6c49924b..ceba497b 100644 --- a/Yattee/Views/Components/SwipeActionModifier.swift +++ b/Yattee/Views/Components/SwipeActionModifier.swift @@ -76,7 +76,6 @@ struct SwipeActionModifier: ViewModifier { @State private var progress: CGFloat = 0 // Scroll tracking for auto-close on scroll - @State private var currentScrollOffset: CGFloat = 0 @State private var storedScrollOffset: CGFloat? // Shared state reference (computed to avoid inclusion in memberwise init) diff --git a/Yattee/Views/Components/TextTruncationEffect.swift b/Yattee/Views/Components/TextTruncationEffect.swift deleted file mode 100644 index 41ba7e61..00000000 --- a/Yattee/Views/Components/TextTruncationEffect.swift +++ /dev/null @@ -1,236 +0,0 @@ -// -// TextTruncationEffect.swift -// Yattee -// -// Animated text truncation effect with "...more" indicator. -// Adapted from Balaji Venkatesh's TruncationEffect implementation. -// - -import SwiftUI - -#if os(macOS) -import AppKit -#else -import UIKit -#endif - -extension Text { - /// Applies an animated truncation effect that shows "...more" when collapsed. - /// - Parameters: - /// - length: Number of lines to show when truncated - /// - isEnabled: Whether truncation is enabled (false = expanded) - /// - animation: Animation to use for expand/collapse transitions - @ViewBuilder - func truncationEffect(length: Int, isEnabled: Bool, animation: Animation) -> some View { - self.modifier( - TruncationEffectViewModifier( - length: length, - isEnabled: isEnabled, - animation: animation - ) - ) - } -} - -// MARK: - View Modifier - -private struct TruncationEffectViewModifier: ViewModifier { - var length: Int - var isEnabled: Bool - var animation: Animation - - @State private var limitedSize: CGSize = .zero - @State private var fullSize: CGSize = .zero - @State private var animatedProgress: CGFloat = 0 - - func body(content: Content) -> some View { - content - .lineLimit(length) - .opacity(0) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity, alignment: .leading) - .onGeometryChange(for: CGSize.self) { - $0.size - } action: { newValue in - limitedSize = newValue - } - .frame(height: isExpanded ? fullSize.height : nil) - .overlay { - // Full content with animation - GeometryReader { proxy in - let contentSize = proxy.size - - content - .textRenderer( - TruncationTextRenderer( - length: length, - progress: animatedProgress - ) - ) - .fixedSize(horizontal: false, vertical: true) - .onGeometryChange(for: CGSize.self) { - $0.size - } action: { newValue in - fullSize = newValue - } - .frame( - width: contentSize.width, - height: contentSize.height, - alignment: isExpanded ? .leading : .topLeading - ) - } - } - .contentShape(.rect) - .onChange(of: isEnabled) { _, newValue in - withAnimation(animation) { - animatedProgress = !newValue ? 1 : 0 - } - } - .onAppear { - // Set initial value without animation - animatedProgress = !isEnabled ? 1 : 0 - } - } - - var isExpanded: Bool { - animatedProgress == 1 - } -} - -// MARK: - Text Renderer - -@Animatable -private struct TruncationTextRenderer: TextRenderer { - @AnimatableIgnored var length: Int - var progress: CGFloat - - /// Minimum number of hidden characters to show "...more" indicator. - /// If less than this, just show the full text without truncation indicator. - private let minHiddenCharacters = 10 - - func draw(layout: Text.Layout, in ctx: inout GraphicsContext) { - let totalLines = layout.count - let hasExtraLines = totalLines > length - - // Count characters in lines beyond the limit - let hiddenCharacterCount: Int = { - guard hasExtraLines else { return 0 } - var count = 0 - for index in length..<totalLines { - count += layout[index].flatMap { $0 }.count - } - return count - }() - - // If hidden content is too short, don't show "...more" - just draw normally - let shouldShowMoreIndicator = hasExtraLines && hiddenCharacterCount >= minHiddenCharacters - - for (index, line) in layout.enumerated() { - var copyContext = ctx - if index == length - 1 && shouldShowMoreIndicator { - drawMoreTextAtEnd(line: line, context: ©Context) - } else { - if index < length { - // Draw all visible lines - copyContext.draw(line) - } else if shouldShowMoreIndicator { - drawLinesWithBlurEffect(index: index, layout: layout, in: ©Context) - } else { - // No "...more" indicator - just draw with fade based on progress - copyContext.opacity = progress - copyContext.draw(line) - } - } - } - } - - private func drawLinesWithBlurEffect(index: Int, layout: Text.Layout, in ctx: inout GraphicsContext) { - let line = layout[index] - - let lineIndex = Double(index - length) - let totalExtraLines = Double(layout.count - length) - - // Divide the animation progress among all extra lines - let lineStartProgress = lineIndex / max(1, totalExtraLines) - let lineEndProgress = (lineIndex + 1) / max(1, totalExtraLines) - - // Calculate this specific line's progress - let lineProgress = max(0, min(1, (progress - lineStartProgress) / (lineEndProgress - lineStartProgress))) - - ctx.opacity = lineProgress - ctx.addFilter(.blur(radius: blurRadius - (blurRadius * lineProgress))) - ctx.draw(line) - } - - private func drawMoreTextAtEnd(line: Text.Layout.Element, context: inout GraphicsContext) { - let runs = line.flatMap { $0 } - let runsCount = runs.count - let text = " ..." + String(localized: "common.more") - let textCount = text.count - - // Draw runs until the text count - for index in 0..<max(runsCount - textCount, 0) { - let run = runs[index] - context.draw(run) - } - - // Draw remaining runs with opacity filter - for index in max(runsCount - textCount, 0)..<runsCount { - let run = runs[index] - context.opacity = progress - context.draw(run) - } - - // Draw "...more" text - let textRunIndex = max(runsCount - textCount, 0) - var typography: Text.Layout.TypographicBounds - if !runs.isEmpty { - typography = runs[textRunIndex].typographicBounds - } else { - typography = line.typographicBounds - } - - let fontSize: CGFloat = typography.ascent - let font = platformFont(ofSize: fontSize) - - let spacing: CGFloat = textWidth(text, font: font) / 2 - - let swiftUIText = Text(text) - .font(Font(font)) - .foregroundStyle(.secondary) - - let origin = CGPoint( - x: typography.rect.minX + spacing, - y: typography.rect.midY - ) - - context.opacity = 1 - progress - context.draw(swiftUIText, at: origin) - } - - private var blurRadius: CGFloat { - 5 - } - - // MARK: - Cross-Platform Font Helpers - - #if os(macOS) - private func platformFont(ofSize size: CGFloat) -> NSFont { - NSFont.systemFont(ofSize: size) - } - - private func textWidth(_ text: String, font: NSFont) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: font] - return (text as NSString).size(withAttributes: attributes).width - } - #else - private func platformFont(ofSize size: CGFloat) -> UIFont { - UIFont.systemFont(ofSize: size) - } - - private func textWidth(_ text: String, font: UIFont) -> CGFloat { - let attributes: [NSAttributedString.Key: Any] = [.font: font] - return (text as NSString).size(withAttributes: attributes).width - } - #endif -} diff --git a/Yattee/Views/Components/VideoListContainer.swift b/Yattee/Views/Components/VideoListContainer.swift index 52f6d331..a477de5b 100644 --- a/Yattee/Views/Components/VideoListContainer.swift +++ b/Yattee/Views/Components/VideoListContainer.swift @@ -138,22 +138,6 @@ extension VideoListContainer where Footer == EmptyView { } } -extension VideoListContainer where Header == EmptyView { - /// Creates a video list container with a footer but no header. - init( - listStyle: VideoListStyle, - rowStyle: VideoRowStyle, - @ViewBuilder content: @escaping () -> Content, - @ViewBuilder footer: @escaping () -> Footer - ) { - self.listStyle = listStyle - self.rowStyle = rowStyle - self.header = { EmptyView() } - self.content = content - self.footer = footer - } -} - // MARK: - Preview #Preview("Inset Style") { diff --git a/Yattee/Views/Components/VideoNavigationButton.swift b/Yattee/Views/Components/VideoNavigationButton.swift deleted file mode 100644 index f10d3426..00000000 --- a/Yattee/Views/Components/VideoNavigationButton.swift +++ /dev/null @@ -1,77 +0,0 @@ -// -// VideoNavigationButton.swift -// Yattee -// -// Navigation button for switching between videos in VideoInfoView. -// - -import SwiftUI - -/// A floating circular button for navigating between videos in a queue. -/// Used in VideoInfoView to switch to previous/next video. -struct VideoNavigationButton: View { - let direction: Direction - let action: () -> Void - var isLoading: Bool = false - var hasError: Bool = false - - enum Direction { - case previous - case next - - var icon: String { - switch self { - case .previous: return "chevron.left" - case .next: return "chevron.right" - } - } - - var accessibilityLabel: String { - switch self { - case .previous: return String(localized: "video.navigation.previous") - case .next: return String(localized: "video.navigation.next") - } - } - } - - var body: some View { - Button(action: action) { - ZStack { - if isLoading { - ProgressView() - .controlSize(.small) - .tint(.primary) - } else { - Image(systemName: direction.icon) - .font(.title2) - .fontWeight(.semibold) - } - } - .foregroundStyle(.primary) - .frame(width: 44, height: 44) - .glassBackground(.regular, in: .circle) - .shadow(color: .black.opacity(0.1), radius: 8, y: 4) - .overlay { - if hasError { - Circle() - .stroke(Color.red, lineWidth: 2) - } - } - } - .buttonStyle(.plain) - .disabled(isLoading) - .accessibilityLabel(direction.accessibilityLabel) - } -} - -// MARK: - Preview - -#Preview("Previous") { - VideoNavigationButton(direction: .previous) {} - .padding() -} - -#Preview("Next") { - VideoNavigationButton(direction: .next) {} - .padding() -} diff --git a/Yattee/Views/Components/VideoSwipeActionsModifier.swift b/Yattee/Views/Components/VideoSwipeActionsModifier.swift index 9cb0545d..7e6f4128 100644 --- a/Yattee/Views/Components/VideoSwipeActionsModifier.swift +++ b/Yattee/Views/Components/VideoSwipeActionsModifier.swift @@ -11,7 +11,6 @@ import SwiftUI /// View modifier that applies user-configurable swipe actions plus fixed context-specific actions. struct VideoSwipeActionsModifier: ViewModifier { @Environment(\.appEnvironment) private var appEnvironment - @Environment(\.videoQueueContext) private var queueContext @Environment(\.presentAppSheet) private var presentAppSheet let video: Video diff --git a/Yattee/Views/Components/ViewOptionsSheet.swift b/Yattee/Views/Components/ViewOptionsSheet.swift index 5aae42a6..a1be0555 100644 --- a/Yattee/Views/Components/ViewOptionsSheet.swift +++ b/Yattee/Views/Components/ViewOptionsSheet.swift @@ -12,7 +12,6 @@ import SwiftUI /// Shows layout picker (list/grid), size options, and filter toggles. /// Reusable across different views with their own storage bindings. struct ViewOptionsSheet: View { - @Environment(\.appEnvironment) private var appEnvironment @Binding var layout: VideoListLayout @Binding var rowStyle: VideoRowStyle @Binding var gridColumns: Int diff --git a/Yattee/Views/Downloads/DownloadsView.swift b/Yattee/Views/Downloads/DownloadsView.swift index cd0f392f..4804eca3 100644 --- a/Yattee/Views/Downloads/DownloadsView.swift +++ b/Yattee/Views/Downloads/DownloadsView.swift @@ -297,18 +297,6 @@ private struct ActiveDownloadsSectionContentView: View { } } - // MARK: - Section Header - - private func sectionHeader(_ text: String) -> some View { - Text(text) - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, listStyle == .inset ? 16 : 16) - .padding(.top, 8) - .padding(.bottom, 8) - } - // MARK: - Swipe Actions private func swipeActionsFor(_ download: Download) -> [SwipeAction] { diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index 696f177d..f572605a 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -83,7 +83,6 @@ struct OpenLinkView: View { struct OpenLinkFormView: View { @Environment(\.appEnvironment) private var appEnvironment - let prefilledURL: URL? let onRequestDismiss: (() -> Void)? @State private var urlText: String @@ -103,7 +102,6 @@ struct OpenLinkFormView: View { fileprivate static let maxURLs = 20 init(prefilledURL: URL?, onRequestDismiss: (() -> Void)?) { - self.prefilledURL = prefilledURL self.onRequestDismiss = onRequestDismiss _urlText = State(initialValue: prefilledURL?.absoluteString ?? "") } diff --git a/Yattee/Views/Instances/InstanceBrowseView.swift b/Yattee/Views/Instances/InstanceBrowseView.swift index ec2c9a90..0599bfe5 100644 --- a/Yattee/Views/Instances/InstanceBrowseView.swift +++ b/Yattee/Views/Instances/InstanceBrowseView.swift @@ -87,12 +87,6 @@ struct InstanceBrowseView: View { } private var yatteeServerURL: URL? { yatteeServer?.url } - /// Auth header for Yattee Server instances (when browsing a Yattee Server directly) - private var yatteeServerAuthHeader: String? { - guard instance.type == .yatteeServer else { return nil } - return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: instance) - } - /// Auth header for avatar loading (uses Yattee Server for YouTube channel avatars) private var avatarAuthHeader: String? { guard let server = yatteeServer else { return nil } diff --git a/Yattee/Views/MediaBrowser/MediaBrowserView.swift b/Yattee/Views/MediaBrowser/MediaBrowserView.swift index dd234c7f..f96fbb70 100644 --- a/Yattee/Views/MediaBrowser/MediaBrowserView.swift +++ b/Yattee/Views/MediaBrowser/MediaBrowserView.swift @@ -11,7 +11,6 @@ struct MediaBrowserView: View { @Environment(\.appEnvironment) private var appEnvironment let source: MediaSource - let initialPath: String @Namespace private var sheetTransition @State private var currentPath: String @@ -33,7 +32,6 @@ struct MediaBrowserView: View { init(source: MediaSource, path: String = "/", showOnlyPlayable: Bool = false) { self.source = source - self.initialPath = path _currentPath = State(initialValue: path) let defaults = UserDefaults.standard diff --git a/Yattee/Views/Navigation/SidebarAdaptableModifier.swift b/Yattee/Views/Navigation/SidebarAdaptableModifier.swift deleted file mode 100644 index 93b5835b..00000000 --- a/Yattee/Views/Navigation/SidebarAdaptableModifier.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// SidebarAdaptableModifier.swift -// Yattee -// -// Navigation-related view modifiers. -// - -import SwiftUI - -// MARK: - Sidebar Adaptable Modifier - -#if os(iOS) -struct SidebarAdaptableModifier: ViewModifier { - func body(content: Content) -> some View { - content.tabViewStyle(.sidebarAdaptable) - } -} - -extension View { - /// Applies sidebar adaptable style. - func sidebarAdaptable() -> some View { - modifier(SidebarAdaptableModifier()) - } -} -#endif diff --git a/Yattee/Views/Navigation/SidebarChannelRow.swift b/Yattee/Views/Navigation/SidebarChannelRow.swift deleted file mode 100644 index a2f05c9d..00000000 --- a/Yattee/Views/Navigation/SidebarChannelRow.swift +++ /dev/null @@ -1,69 +0,0 @@ -// -// SidebarChannelRow.swift -// Yattee -// -// Compact channel row for sidebar display. -// - -import SwiftUI -import NukeUI - -struct SidebarChannelRow: View { - let name: String - let avatarURL: URL? - var authHeader: String? - - var body: some View { - HStack(spacing: 8) { - // Avatar - LazyImage(request: AvatarURLBuilder.imageRequest(url: avatarURL, authHeader: authHeader)) { state in - if let image = state.image { - image - .resizable() - .aspectRatio(contentMode: .fill) - } else { - avatarPlaceholder - } - } - .frame(width: 24, height: 24) - .clipShape(Circle()) - - // Name - Text(name) - .lineLimit(1) - .truncationMode(.tail) - } - } - - private var avatarPlaceholder: some View { - Circle() - .fill(.quaternary) - .overlay { - Text(String(name.prefix(1)).uppercased()) - .font(.caption2.bold()) - .foregroundStyle(.secondary) - } - } -} - -// MARK: - Preview - -#if !os(tvOS) -#Preview { - List { - SidebarChannelRow( - name: "Technology Reviews", - avatarURL: nil - ) - SidebarChannelRow( - name: "Music & Sound Design", - avatarURL: nil - ) - SidebarChannelRow( - name: "A Very Long Channel Name That Should Be Truncated", - avatarURL: nil - ) - } - .listStyle(.sidebar) -} -#endif diff --git a/Yattee/Views/Navigation/SidebarPlaylistRow.swift b/Yattee/Views/Navigation/SidebarPlaylistRow.swift deleted file mode 100644 index 2dd3474f..00000000 --- a/Yattee/Views/Navigation/SidebarPlaylistRow.swift +++ /dev/null @@ -1,63 +0,0 @@ -// -// SidebarPlaylistRow.swift -// Yattee -// -// Compact playlist row for sidebar display. -// - -import SwiftUI - -struct SidebarPlaylistRow: View { - let title: String - let videoCount: Int - - var body: some View { - HStack(spacing: 8) { - // Icon - Image(systemName: "list.bullet.rectangle") - .font(.system(size: 14)) - .foregroundStyle(.secondary) - .frame(width: 24, height: 24) - - // Title and count - Text(title) - .lineLimit(1) - .truncationMode(.tail) - - Spacer() - - // Video count badge - if videoCount > 0 { - Text("\(videoCount)") - .font(.caption2.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - } -} - -// MARK: - Preview - -#if !os(tvOS) -#Preview { - List { - SidebarPlaylistRow( - title: "Watch Later", - videoCount: 12 - ) - SidebarPlaylistRow( - title: "Favorites", - videoCount: 45 - ) - SidebarPlaylistRow( - title: "A Very Long Playlist Name That Should Be Truncated", - videoCount: 100 - ) - SidebarPlaylistRow( - title: "Empty Playlist", - videoCount: 0 - ) - } - .listStyle(.sidebar) -} -#endif diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift index 08a42c61..d3f7de2f 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+Layouts.swift @@ -13,9 +13,6 @@ import NukeUI extension ExpandedPlayerSheet { // MARK: - Layout Constants - /// Minimum width required to show widescreen layout with floating panel. - static let minWidthForWidescreen: CGFloat = 700 - /// Minimum width required for the corner layout; below this the corner /// preset setting falls back to the stacked widescreen layout (Split View, /// narrow windows). @@ -98,91 +95,6 @@ extension ExpandedPlayerSheet { } #endif - // MARK: - Download Action Handler - - #if !os(tvOS) - /// Handles download button action based on current state. - func handleDownloadAction(for video: Video) { - let isDownloaded = downloadManager?.isDownloaded(video.id) ?? false - let download = downloadManager?.download(for: video.id) - let isDownloading = download?.status == .downloading || download?.status == .queued - - if isDownloading, let download { - Task { - await downloadManager?.cancel(download) - } - } else if isDownloaded { - showingDeleteDownloadAlert = true - } else { - startDownload(for: video) - } - } - - /// Starts a download either automatically or by showing the quality sheet. - private func startDownload(for video: Video) { - guard let appEnvironment else { - presentDownloadOptions() - return - } - - // Media source videos (SMB/WebDAV/local) use direct file URLs - no API call needed - if video.isFromMediaSource { - Task { - do { - try await appEnvironment.downloadManager.autoEnqueueMediaSource( - video, - mediaSourcesManager: appEnvironment.mediaSourcesManager, - webDAVClient: appEnvironment.webDAVClient, - smbClient: appEnvironment.smbClient - ) - } catch { - appEnvironment.toastManager.show( - category: .error, - title: String(localized: "download.error.title"), - subtitle: error.localizedDescription, - icon: "exclamationmark.triangle", - iconColor: .red - ) - } - } - return - } - - let downloadSettings = appEnvironment.downloadSettings - - // Check if auto-download mode - if downloadSettings.preferredDownloadQuality != .ask, - let instance = appEnvironment.instancesManager.instance(for: video) { - Task { - do { - try await appEnvironment.downloadManager.autoEnqueue( - video, - preferredQuality: downloadSettings.preferredDownloadQuality, - preferredAudioLanguage: appEnvironment.settingsManager.preferredAudioLanguage, - preferredSubtitlesLanguage: appEnvironment.settingsManager.preferredSubtitlesLanguage, - includeSubtitles: downloadSettings.includeSubtitlesInAutoDownload, - contentService: appEnvironment.contentService, - instance: instance - ) - } catch { - appEnvironment.toastManager.show( - category: .error, - title: String(localized: "download.error.title"), - subtitle: error.localizedDescription, - icon: "exclamationmark.triangle", - iconColor: .red - ) - } - } - } else { - presentDownloadOptions() - } - } - #else - /// No-op on tvOS. - func handleDownloadAction(for video: Video) {} - #endif - // MARK: - Standard/Portrait Layout /// Standard player content for portrait mode with video at top and panel below. @@ -768,20 +680,6 @@ extension ExpandedPlayerSheet { appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = newValue } - /// Hide floating panel with animation - private func hideFloatingPanel() { - withAnimation(.easeInOut(duration: 0.3)) { - isPortraitPanelVisible = false - } - navigationCoordinator?.isPortraitPanelVisible = false - appEnvironment?.settingsManager.portraitDetailsPanelLastVisible = false - - // Reset main window transform when hiding panel to fix safe area clipping (iOS only) - #if os(iOS) - ExpandedPlayerWindowManager.shared.resetMainWindowImmediate() - #endif - } - // MARK: - Portrait Player Area /// Player area for portrait layout that separates video (panscan-scaled) from controls. @@ -1688,8 +1586,6 @@ extension ExpandedPlayerSheet { panelSide: panelSide, isPanelPinned: isPanelPinned, isCornerLayout: appEnvironment?.navigationCoordinator.isCornerLayoutActive == true, - layoutLeadingSafeArea: 0.01, // Non-zero indicates safe areas handled externally - layoutTrailingSafeArea: 0.01, onToggleOrientationLock: { [weak appEnvironment] in appEnvironment?.settingsManager.inAppOrientationLock.toggle() }, diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift index 86ad4f8d..7eb8e927 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift @@ -12,23 +12,6 @@ import SwiftUI extension ExpandedPlayerSheet { // MARK: - Scroll & Comments - /// Scrolls the content to the top. - func scrollToTop() { - withAnimation(.easeOut(duration: 0.25)) { - scrollPosition.scrollTo(y: 0) - } - } - - /// Expands the comments overlay. - func expandComments() { - // Scroll player to top so video is fully visible - scrollPosition.scrollTo(y: 0) - // Use same animation as player sheet expand (0.3s, no bounce) - withAnimation(.smooth(duration: 0.3)) { - isCommentsExpanded = true - } - } - /// Collapses the comments overlay. func collapseComments() { navigationCoordinator?.commentsFrame = .zero diff --git a/Yattee/Views/Player/ExpandedPlayerSheet.swift b/Yattee/Views/Player/ExpandedPlayerSheet.swift index 8c69435f..9316d789 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet.swift @@ -13,7 +13,6 @@ import SwiftUI struct ExpandedPlayerSheet: View { @Environment(\.appEnvironment) var appEnvironment - @Environment(\.dismiss) private var dismiss // MARK: - Sheet State @@ -23,9 +22,7 @@ struct ExpandedPlayerSheet: View { @State var commentsDismissOffset: CGFloat = 0 /// Direct scroll position control - use scrollTo(y:) to scroll to top @State var scrollPosition: ScrollPosition = .init(y: 0) - @State private var currentScrollOffset: CGFloat = 0 @State var currentPlayerHeight: CGFloat = 0 - @State private var isBottomOverscroll: Bool = false @State var isPortraitPanelVisible: Bool = true /// Temporarily hide controls during fullscreen transition to avoid animation glitch @State var hideControlsDuringTransition: Bool = false @@ -95,9 +92,6 @@ struct ExpandedPlayerSheet: View { /// Task for preloading comments - cancelled when video changes @State var commentsPreloadTask: Task<Void, Never>? - /// Namespace for matchedGeometryEffect transitions - @Namespace var playerNamespace - // MARK: - Layout State (iOS) #if os(iOS) @@ -120,9 +114,7 @@ struct ExpandedPlayerSheet: View { var playerService: PlayerService? { appEnvironment?.playerService } var playerState: PlayerState? { playerService?.state } - var downloadManager: DownloadManager? { appEnvironment?.downloadManager } var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } - var accentColor: Color { appEnvironment?.settingsManager.resolvedAccentColor ?? .accentColor } #if os(iOS) var inAppOrientationLock: Bool { appEnvironment?.settingsManager.inAppOrientationLock ?? false } @@ -136,7 +128,6 @@ struct ExpandedPlayerSheet: View { // MARK: - Queue Helpers var queue: [QueuedVideo] { playerState?.queue ?? [] } - var history: [QueuedVideo] { playerState?.history ?? [] } var isQueueEnabled: Bool { appEnvironment?.settingsManager.queueEnabled ?? true } var hasQueueItems: Bool { !queue.isEmpty && isQueueEnabled } @@ -165,7 +156,6 @@ struct ExpandedPlayerSheet: View { let isFailed = if case .failed = state { true } else { false } let hasBackend = playerService?.currentBackend != nil && !isLoading && !isIdle && !isEnded && !isFailed return PlaybackInfo( - state: state, isLoading: isLoading, isIdle: isIdle, isEnded: isEnded, @@ -188,18 +178,6 @@ struct ExpandedPlayerSheet: View { presentSheet(.addToPlaylist(video)) } - #if !os(tvOS) - func presentDownloadOptions() { - guard let video = playerState?.currentVideo, let playerService = appEnvironment?.playerService else { return } - presentSheet(.downloadOptions( - video, - streams: playerService.availableStreams, - captions: playerService.availableCaptions, - dislikeCount: playerState?.dislikeCount - )) - } - #endif - // MARK: - Body var body: some View { diff --git a/Yattee/Views/Player/ExpandedPlayerWindow.swift b/Yattee/Views/Player/ExpandedPlayerWindow.swift index 29e1b38f..9a010b1f 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindow.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindow.swift @@ -187,14 +187,12 @@ private final class DragToDismissGestureHandler: NSObject, UIGestureRecognizerDe // Main window scaling callbacks (Apple Music-style effect) var onMainWindowScaleChanged: ((CGFloat) -> Void)? var onMainWindowScaleAnimated: ((CGFloat) -> Void)? // Animated scale to progress - var onMainWindowScaleReset: ((Bool) -> Void)? // Bool = animated private let dismissThreshold: CGFloat = 100 private var isDismissing = false private var scrollViewAtTop = false private weak var trackedScrollView: UIScrollView? private var scrollObservation: NSKeyValueObservation? - private var originalBackgroundColor: UIColor? // Panscan gesture state private var basePanscan: Double = 0.0 @@ -632,12 +630,6 @@ final class ExpandedPlayerWindowManager { return window } - /// The view to apply transforms to (first subview of main window) - /// Note: For actual transformations, use scaledPresentingView which caches the reference - private var presentingView: UIView? { - mainWindow?.subviews.first - } - /// Applies scale and corner radius to main window based on progress (0 = normal, 1 = fully scaled) func scaleMainWindow(progress: CGFloat) { // Capture the presenting view reference on first call @@ -689,18 +681,6 @@ final class ExpandedPlayerWindowManager { } } - /// Resets main window to normal state with animation - func resetMainWindowAnimated(duration: TimeInterval = 0.3) { - let view = scaledPresentingView ?? mainWindow?.subviews.first - UIView.animate(withDuration: duration) { - view?.layer.cornerRadius = 0 - view?.transform = .identity - } completion: { [weak self] _ in - view?.layer.masksToBounds = false - self?.scaledPresentingView = nil - } - } - /// Resets main window to normal state immediately func resetMainWindowImmediate() { // Reset ALL subviews of the main window to handle rotation edge cases @@ -764,10 +744,6 @@ final class ExpandedPlayerWindowManager { } } - var isPresented: Bool { - expandedWindow != nil - } - func show(with appEnvironment: AppEnvironment, animated: Bool = true) { LoggingService.shared.logPlayer("[ExpandedPlayerWindowManager] show called, expandedWindow=\(expandedWindow != nil), animated=\(animated), retryCount=\(showRetryCount)") guard expandedWindow == nil else { return } diff --git a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift index 0dfd7865..9e191c1c 100644 --- a/Yattee/Views/Player/ExpandedPlayerWindowManager.swift +++ b/Yattee/Views/Player/ExpandedPlayerWindowManager.swift @@ -660,30 +660,6 @@ final class ExpandedPlayerWindowManager: NSObject { LoggingService.shared.debug("ExpandedPlayerWindowManager: restored corner panels after fullscreen", category: .player) } - /// Restores a window that was hidden for PiP mode. - /// Call this when returning from PiP to show the player window again. - func restoreFromPiP(animated: Bool = true) { - guard let window = playerWindow else { - LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP - no window to restore", category: .player) - return - } - - LoggingService.shared.debug("ExpandedPlayerWindowManager: restoreFromPiP called", category: .player) - - if animated { - window.alphaValue = 0 - window.makeKeyAndOrderFront(nil) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - window.animator().alphaValue = 1 - } - } else { - window.alphaValue = 1 - window.makeKeyAndOrderFront(nil) - } - } - /// Cleans up a window that was hidden for PiP when PiP ends without restoring. /// Call this when PiP is closed via the X button (not restore). func cleanupAfterPiP() { diff --git a/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift b/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift index 3cd233be..40ec8b24 100644 --- a/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift +++ b/Yattee/Views/Player/Gestures/GestureSeekPreviewView.swift @@ -13,10 +13,8 @@ import SwiftUI struct GestureSeekPreviewView: View { let storyboard: Storyboard? let seekTime: TimeInterval - let duration: TimeInterval let storyboardService: StoryboardService let buttonBackground: ButtonBackgroundStyle - let theme: ControlsTheme let chapters: [VideoChapter] let isActive: Bool var availableWidth: CGFloat = 320 @@ -42,8 +40,7 @@ struct GestureSeekPreviewView: View { storyboard: storyboard, seekTime: seekTime, storyboardService: storyboardService, - buttonBackground: buttonBackground, - theme: theme + buttonBackground: buttonBackground ) } } @@ -70,10 +67,8 @@ struct GestureSeekPreviewView: View { GestureSeekPreviewView( storyboard: nil, seekTime: 180, - duration: 600, storyboardService: StoryboardService(), buttonBackground: .regularGlass, - theme: .dark, chapters: [], isActive: true ) diff --git a/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift b/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift index 9ae81eb5..3fe83b71 100644 --- a/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift +++ b/Yattee/Views/Player/Gestures/TapGestureFeedbackView.swift @@ -203,7 +203,6 @@ struct SeekFeedbackView: View { // Ripple circles ForEach(0..<3) { index in SeekRipple( - isForward: isForward, delay: Double(index) * 0.1, isActive: rippleCount > index ) @@ -252,7 +251,6 @@ struct SeekFeedbackView: View { } private struct SeekRipple: View { - let isForward: Bool let delay: Double let isActive: Bool diff --git a/Yattee/Views/Player/Gestures/TapZoneCalculator.swift b/Yattee/Views/Player/Gestures/TapZoneCalculator.swift index d554e934..695e8667 100644 --- a/Yattee/Views/Player/Gestures/TapZoneCalculator.swift +++ b/Yattee/Views/Player/Gestures/TapZoneCalculator.swift @@ -63,88 +63,5 @@ enum TapZoneCalculator { } } } - - /// Returns the frame for a specific zone position within bounds. - /// - Parameters: - /// - position: The zone position. - /// - bounds: The total gesture-recognizable area bounds. - /// - layout: The current tap zone layout. - /// - Returns: The frame for the zone, or nil if position doesn't match layout. - static func frame( - for position: TapZonePosition, - in bounds: CGRect, - layout: TapZoneLayout - ) -> CGRect? { - // Use safe area for calculations - let safeArea = bounds.insetBy(dx: safeMargin, dy: safeMargin) - - switch layout { - case .single: - guard position == .full else { return nil } - return safeArea - - case .horizontalSplit: - let halfWidth = safeArea.width / 2 - switch position { - case .left: - return CGRect(x: safeArea.minX, y: safeArea.minY, - width: halfWidth, height: safeArea.height) - case .right: - return CGRect(x: safeArea.minX + halfWidth, y: safeArea.minY, - width: halfWidth, height: safeArea.height) - default: - return nil - } - - case .verticalSplit: - let halfHeight = safeArea.height / 2 - switch position { - case .top: - return CGRect(x: safeArea.minX, y: safeArea.minY, - width: safeArea.width, height: halfHeight) - case .bottom: - return CGRect(x: safeArea.minX, y: safeArea.minY + halfHeight, - width: safeArea.width, height: halfHeight) - default: - return nil - } - - case .threeColumns: - let thirdWidth = safeArea.width / 3 - switch position { - case .leftThird: - return CGRect(x: safeArea.minX, y: safeArea.minY, - width: thirdWidth, height: safeArea.height) - case .center: - return CGRect(x: safeArea.minX + thirdWidth, y: safeArea.minY, - width: thirdWidth, height: safeArea.height) - case .rightThird: - return CGRect(x: safeArea.minX + thirdWidth * 2, y: safeArea.minY, - width: thirdWidth, height: safeArea.height) - default: - return nil - } - - case .quadrants: - let halfWidth = safeArea.width / 2 - let halfHeight = safeArea.height / 2 - switch position { - case .topLeft: - return CGRect(x: safeArea.minX, y: safeArea.minY, - width: halfWidth, height: halfHeight) - case .topRight: - return CGRect(x: safeArea.minX + halfWidth, y: safeArea.minY, - width: halfWidth, height: halfHeight) - case .bottomLeft: - return CGRect(x: safeArea.minX, y: safeArea.minY + halfHeight, - width: halfWidth, height: halfHeight) - case .bottomRight: - return CGRect(x: safeArea.minX + halfWidth, y: safeArea.minY + halfHeight, - width: halfWidth, height: halfHeight) - default: - return nil - } - } - } } #endif diff --git a/Yattee/Views/Player/MiniPlayerView.swift b/Yattee/Views/Player/MiniPlayerView.swift index 38c402c8..d3d7e224 100644 --- a/Yattee/Views/Player/MiniPlayerView.swift +++ b/Yattee/Views/Player/MiniPlayerView.swift @@ -25,7 +25,6 @@ struct MiniPlayerView: View { private var deArrowProvider: DeArrowBrandingProvider? { appEnvironment?.deArrowBrandingProvider } private var queueManager: QueueManager? { appEnvironment?.queueManager } private var playerControlsLayoutService: PlayerControlsLayoutService? { appEnvironment?.playerControlsLayoutService } - private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } private var currentVideo: Video? { playerState?.currentVideo } diff --git a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift index 649148cd..d44174ad 100644 --- a/Yattee/Views/Player/Panels/PlayerPanelContentView.swift +++ b/Yattee/Views/Player/Panels/PlayerPanelContentView.swift @@ -71,18 +71,6 @@ struct PlayerPanelContentView: View { } } - /// Enabled pages that currently have content, in configured order. - static func visiblePages( - panelSettings: PanelSettings, - region: PanelRegion?, - video: Video, - appEnvironment: AppEnvironment? - ) -> [PanelPageConfiguration] { - panelSettings.enabledPages(in: region).filter { - isRuntimeVisible($0.pageType, video: video, appEnvironment: appEnvironment) - } - } - /// Enabled pages for this host in configured order, before runtime /// visibility filtering. The selection tracks this list so a page that /// loads late (e.g. related) still wins once its content arrives. @@ -194,7 +182,7 @@ struct PlayerPanelContentView: View { ChaptersPanelPage(context: context, settings: page.chaptersSettings, scrollConfig: scrollConfig) .id(PanelPageType.chapters) case .queue: - QueuePanelPage(context: context, settings: page.queueSettings, bottomInset: scrollConfig.bottomInset) + QueuePanelPage(settings: page.queueSettings, bottomInset: scrollConfig.bottomInset) .id(PanelPageType.queue) } } diff --git a/Yattee/Views/Player/Panels/QueuePanelPage.swift b/Yattee/Views/Player/Panels/QueuePanelPage.swift index 345654da..9db072ab 100644 --- a/Yattee/Views/Player/Panels/QueuePanelPage.swift +++ b/Yattee/Views/Player/Panels/QueuePanelPage.swift @@ -10,7 +10,6 @@ import SwiftUI #if os(iOS) || os(macOS) struct QueuePanelPage: View { - let context: PanelPageContext var settings: QueuePageSettings = QueuePageSettings() var bottomInset: CGFloat = 0 diff --git a/Yattee/Views/Player/PlayerControlsActions.swift b/Yattee/Views/Player/PlayerControlsActions.swift index f018adfa..31aea796 100644 --- a/Yattee/Views/Player/PlayerControlsActions.swift +++ b/Yattee/Views/Player/PlayerControlsActions.swift @@ -95,9 +95,7 @@ struct PlayerControlsActions { let currentAudioStream: Stream? /// Embedded (in-container) tracks reported by mpv for the loaded file - var embeddedAudioTracks: [MPVTrack] = [] var embeddedSubtitleTracks: [MPVTrack] = [] - var currentEmbeddedAudioTrackID: Int? = nil var currentEmbeddedSubtitleTrackID: Int? = nil #if os(iOS) @@ -234,11 +232,6 @@ struct PlayerControlsActions { return String(format: "%.2gx", rate.rawValue) } - /// Whether share is available (has video with share URL) - var canShare: Bool { - currentVideo != nil - } - /// Whether add to playlist is available var canAddToPlaylist: Bool { guard let video = currentVideo else { return false } diff --git a/Yattee/Views/Player/PlayerControlsView.swift b/Yattee/Views/Player/PlayerControlsView.swift index b17b3732..637175eb 100644 --- a/Yattee/Views/Player/PlayerControlsView.swift +++ b/Yattee/Views/Player/PlayerControlsView.swift @@ -45,9 +45,6 @@ struct PlayerControlsView: View { var isPanelPinned: Bool = false /// Corner layout: fullscreen button toggles the panels var isCornerLayout: Bool = false - /// Safe area padding from layout (when panel is pinned) - var layoutLeadingSafeArea: CGFloat = 0 - var layoutTrailingSafeArea: CGFloat = 0 var onToggleOrientationLock: (() -> Void)? = nil var isOrientationLocked: Bool = false var onToggleDetailsVisibility: (() -> Void)? = nil @@ -140,11 +137,8 @@ struct PlayerControlsView: View { @State private var targetSeekProgress: Double = 0 @State private var hideTimer: Timer? @State private var showControls: Bool? - @State private var isAdjustingVolume = false - @State private var isAdjustingBrightness = false /// Tracks brightness value for side slider (needed because UIScreen.main.brightness isn't observable) @State private var currentBrightness: Double = UIScreen.main.brightness - @State private var playNextTapCount = 0 /// Track safe area to force re-render when it changes @State private var currentSafeArea: UIEdgeInsets = .zero /// Window hosting these controls, captured by `HostWindowReader`. Safe @@ -440,9 +434,7 @@ struct PlayerControlsView: View { availableStreams: availableStreams, currentStream: currentStream, currentAudioStream: currentAudioStream, - embeddedAudioTracks: embeddedAudioTracks, embeddedSubtitleTracks: embeddedSubtitleTracks, - currentEmbeddedAudioTrackID: currentEmbeddedAudioTrackID, currentEmbeddedSubtitleTrackID: currentEmbeddedSubtitleTrackID, panscanValue: panscanValue, isPanscanAllowed: isPanscanAllowed, @@ -1044,8 +1036,7 @@ struct PlayerControlsView: View { storyboard: storyboard, seekTime: seekTime, storyboardService: StoryboardService.shared, - buttonBackground: activeLayout.globalSettings.buttonBackground, - theme: activeLayout.globalSettings.theme + buttonBackground: activeLayout.globalSettings.buttonBackground ) .position(x: xPosition, y: yPosition) .transition(.opacity.combined(with: .scale(scale: 0.9))) @@ -1212,10 +1203,8 @@ struct PlayerControlsView: View { GestureSeekPreviewView( storyboard: playerState.preferredStoryboard, seekTime: seekGesturePreviewTime, - duration: playerState.duration, storyboardService: StoryboardService.shared, buttonBackground: activeLayout.globalSettings.buttonBackground, - theme: activeLayout.globalSettings.theme, chapters: playerState.chapters, isActive: isSeekGestureActive, availableWidth: gestureGeometry.size.width diff --git a/Yattee/Views/Player/PlayerHelperViews.swift b/Yattee/Views/Player/PlayerHelperViews.swift index 2e055d1f..86a57998 100644 --- a/Yattee/Views/Player/PlayerHelperViews.swift +++ b/Yattee/Views/Player/PlayerHelperViews.swift @@ -13,7 +13,6 @@ import SwiftUI /// Holds computed playback state flags to avoid duplicating these checks struct PlaybackInfo { - let state: PlaybackState let isLoading: Bool let isIdle: Bool let isEnded: Bool diff --git a/Yattee/Views/Player/PortraitDetailsPanel.swift b/Yattee/Views/Player/PortraitDetailsPanel.swift index 5393817c..8b4d9e89 100644 --- a/Yattee/Views/Player/PortraitDetailsPanel.swift +++ b/Yattee/Views/Player/PortraitDetailsPanel.swift @@ -59,8 +59,6 @@ struct PortraitDetailsPanel: View { // Queue helpers private var queue: [QueuedVideo] { playerState?.queue ?? [] } - private var history: [QueuedVideo] { playerState?.history ?? [] } - private var isQueueEnabled: Bool { settingsManager?.queueEnabled ?? true } // Player pill helpers private var playerPillSettings: PlayerPillSettings { diff --git a/Yattee/Views/Player/QualitySelector/QualitySelectorTypes.swift b/Yattee/Views/Player/QualitySelector/QualitySelectorTypes.swift index 311adb7f..61fc1736 100644 --- a/Yattee/Views/Player/QualitySelector/QualitySelectorTypes.swift +++ b/Yattee/Views/Player/QualitySelector/QualitySelectorTypes.swift @@ -12,18 +12,6 @@ enum QualitySelectorTab: String, CaseIterable, Sendable { case video case audio case subtitles - - /// Localized display label for the tab. - var label: String { - switch self { - case .video: - String(localized: "player.quality.video") - case .audio: - String(localized: "stream.audio") - case .subtitles: - String(localized: "stream.subtitles") - } - } } /// Navigation destination for quality selector detail pages. diff --git a/Yattee/Views/Player/QualitySelectorView+StreamHelpers.swift b/Yattee/Views/Player/QualitySelectorView+StreamHelpers.swift index bf4427ae..be853427 100644 --- a/Yattee/Views/Player/QualitySelectorView+StreamHelpers.swift +++ b/Yattee/Views/Player/QualitySelectorView+StreamHelpers.swift @@ -349,77 +349,4 @@ extension QualitySelectorView { } return allRates[currentIndex + 1] } - - // MARK: - Formatting Helpers - - /// Formats a file size from bytes. - func formatFileSize(_ bytes: Int64) -> String { - ByteCountFormatter.string(fromByteCount: bytes, countStyle: .file) - } - - /// Formats stream details (bitrate and file size). - func formatStreamDetails(bitrate: Int?, fileSize: String?) -> String { - var parts: [String] = [] - if let bitrate { - parts.append(formatBitrate(bitrate)) - } - if let fileSize { - parts.append(fileSize) - } - return parts.joined(separator: " · ") - } - - /// Formats audio details (track type, bitrate, file size). - func formatAudioDetails(trackType: String?, bitrate: Int?, fileSize: String?) -> String { - var parts: [String] = [] - if let trackType { - parts.append(trackType) - } - if let bitrate { - parts.append(formatBitrate(bitrate)) - } - if let fileSize { - parts.append(fileSize) - } - return parts.joined(separator: " · ") - } - - /// Formats a codec string for display. - func formatCodec(_ codec: String) -> String { - let lowercased: String = codec.lowercased() - if lowercased.contains("avc") || lowercased.contains("h264") { - return "H.264" - } else if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") { - return "HEVC" - } else if lowercased.contains("vp9") || lowercased.contains("vp09") { - return "VP9" - } else if lowercased.contains("av1") || lowercased.contains("av01") { - return "AV1" - } - return codec.uppercased() - } - - /// Returns a color for a codec badge. - func codecColor(_ codec: String) -> Color { - let lowercased: String = codec.lowercased() - if lowercased.contains("av1") || lowercased.contains("av01") { - return .blue - } else if lowercased.contains("vp9") || lowercased.contains("vp09") { - return .orange - } else if lowercased.contains("avc") || lowercased.contains("h264") { - return .red - } else if lowercased.contains("hev") || lowercased.contains("h265") || lowercased.contains("hevc") { - return .green - } - return .gray - } - - /// Formats a bitrate for display. - func formatBitrate(_ bitrate: Int) -> String { - if bitrate >= 1_000_000 { - return String(format: "%.1f Mbps", Double(bitrate) / 1_000_000) - } else { - return "\(bitrate / 1000) kbps" - } - } } diff --git a/Yattee/Views/Player/QueueManagementSheet.swift b/Yattee/Views/Player/QueueManagementSheet.swift index 0e1fea10..9ac5a9d4 100644 --- a/Yattee/Views/Player/QueueManagementSheet.swift +++ b/Yattee/Views/Player/QueueManagementSheet.swift @@ -26,12 +26,9 @@ struct QueueManagementSheet: View { private var queueManager: QueueManager? { appEnvironment?.queueManager } private var playerState: PlayerState? { appEnvironment?.playerService.state } - private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } private var queue: [QueuedVideo] { playerState?.queue ?? [] } private var history: [QueuedVideo] { playerState?.history ?? [] } - private var isLoadingMore: Bool { queueManager?.isLoadingMore ?? false } - private var hasMoreItems: Bool { queueManager?.hasMoreItems() ?? false } private var queueSourceLabel: String? { queueManager?.currentQueueSourceLabel } var body: some View { diff --git a/Yattee/Views/Player/SeekPreviewView.swift b/Yattee/Views/Player/SeekPreviewView.swift index ad1b83f3..9240bd0f 100644 --- a/Yattee/Views/Player/SeekPreviewView.swift +++ b/Yattee/Views/Player/SeekPreviewView.swift @@ -50,7 +50,6 @@ struct SeekPreviewView: View { let seekTime: TimeInterval let storyboardService: StoryboardService let buttonBackground: ButtonBackgroundStyle - let theme: ControlsTheme @State private var thumbnail: PlatformImage? @State private var loadTask: Task<Void, Never>? diff --git a/Yattee/Views/Player/SegmentedProgressBar.swift b/Yattee/Views/Player/SegmentedProgressBar.swift index 32c62f90..5d99ec27 100644 --- a/Yattee/Views/Player/SegmentedProgressBar.swift +++ b/Yattee/Views/Player/SegmentedProgressBar.swift @@ -102,10 +102,8 @@ struct SegmentedProgressBar: View { ChapterSegmentView( chapter: chapter, nextChapterStart: chapterEnd, - duration: duration, currentTime: currentTime, bufferedTime: bufferedTime, - segmentWidth: segmentWidth, playedColor: playedColor, bufferedColor: bufferedColor, backgroundColor: backgroundColor, @@ -182,10 +180,8 @@ struct SegmentedProgressBar: View { private struct ChapterSegmentView: View { let chapter: VideoChapter let nextChapterStart: TimeInterval - let duration: TimeInterval let currentTime: TimeInterval let bufferedTime: TimeInterval - let segmentWidth: CGFloat let playedColor: Color let bufferedColor: Color let backgroundColor: Color diff --git a/Yattee/Views/Player/macOS/MacOSControlBar.swift b/Yattee/Views/Player/macOS/MacOSControlBar.swift index 2ea8bccd..e406879c 100644 --- a/Yattee/Views/Player/macOS/MacOSControlBar.swift +++ b/Yattee/Views/Player/macOS/MacOSControlBar.swift @@ -57,11 +57,6 @@ struct MacOSControlBar: View { isDragging ? dragProgress : playerState.progress } - private var bufferedProgress: Double { - guard playerState.duration > 0 else { return 0 } - return playerState.bufferedTime / playerState.duration - } - // MARK: - Body var body: some View { @@ -251,8 +246,7 @@ struct MacOSControlBar: View { storyboard: storyboard, seekTime: seekTime, storyboardService: StoryboardService.shared, - buttonBackground: .none, - theme: .dark + buttonBackground: .none ) .transition(.opacity.combined(with: .scale(scale: 0.9))) .animation(.easeInOut(duration: 0.15), value: isDragging || isHoveringProgress) diff --git a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift index 53cc83cd..1cbb8ce0 100644 --- a/Yattee/Views/Player/tvOS/TVDetailsPanel.swift +++ b/Yattee/Views/Player/tvOS/TVDetailsPanel.swift @@ -12,7 +12,6 @@ import NukeUI /// Details panel that slides up from the bottom showing video information. struct TVDetailsPanel: View { let video: Video? - let initialTab: TVDetailsTab let onDismiss: () -> Void @Environment(\.appEnvironment) private var appEnvironment @@ -22,7 +21,6 @@ struct TVDetailsPanel: View { init(video: Video?, initialTab: TVDetailsTab = .info, onDismiss: @escaping () -> Void) { self.video = video - self.initialTab = initialTab self.onDismiss = onDismiss _selectedTab = State(initialValue: initialTab) } @@ -302,7 +300,6 @@ enum TVDetailsTab: String, CaseIterable { enum TVDetailsFocusItem: Hashable { case tabPicker case channelButton - case description } /// Scrollable description view with click-to-lock scrolling. @@ -615,7 +612,7 @@ struct TVFocusableCommentView: View { } label: { commentContent } - .buttonStyle(TVCommentButtonStyle(hasReplies: comment.replyCount > 0)) + .buttonStyle(TVCommentButtonStyle()) // Replies section if showReplies && comment.replyCount > 0 { @@ -791,7 +788,7 @@ struct TVReplyView: View { } label: { replyContent } - .buttonStyle(TVCommentButtonStyle(hasReplies: false)) + .buttonStyle(TVCommentButtonStyle()) } private var replyContent: some View { @@ -865,7 +862,6 @@ struct TVReplyView: View { /// Button style for focusable comments. struct TVCommentButtonStyle: ButtonStyle { - let hasReplies: Bool @Environment(\.isFocused) private var isFocused func makeBody(configuration: Configuration) -> some View { diff --git a/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift b/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift index bdcf74f5..7f5b3595 100644 --- a/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift +++ b/Yattee/Views/Player/tvOS/TVPlayerProgressBar.swift @@ -76,12 +76,6 @@ struct TVPlayerProgressBar: View { return min(max(displayTime / duration, 0), 1) } - /// Buffered progress as a fraction (0-1). - private var bufferedProgress: Double { - guard duration > 0 else { return 0 } - return min(bufferedTime / duration, 1) - } - var body: some View { Button { if isScrubbing { @@ -92,7 +86,7 @@ struct TVPlayerProgressBar: View { } label: { progressContent } - .buttonStyle(TVProgressBarButtonStyle(isFocused: isFocused)) + .buttonStyle(TVProgressBarButtonStyle()) .disabled(isLive) .overlay { // Gesture capture layer (only when scrubbing). Siri Remote pan @@ -602,8 +596,6 @@ class TVPanGestureUIView: UIView { /// Button style for the progress bar. struct TVProgressBarButtonStyle: ButtonStyle { - let isFocused: Bool - func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.98 : 1.0) diff --git a/Yattee/Views/Playlist/PlaylistVideoRowView.swift b/Yattee/Views/Playlist/PlaylistVideoRowView.swift index 21f5f309..ba1a1473 100644 --- a/Yattee/Views/Playlist/PlaylistVideoRowView.swift +++ b/Yattee/Views/Playlist/PlaylistVideoRowView.swift @@ -38,18 +38,6 @@ struct PlaylistVideoRowView: View { } } -// MARK: - Convenience Initializers - -extension PlaylistVideoRowView { - /// Initialize from a LocalPlaylistItem model. - init(item: LocalPlaylistItem, index: Int, watchProgress: Double? = nil, onRemove: @escaping () -> Void) { - self.index = index - self.video = item.toVideo() - self.watchProgress = watchProgress - self.onRemove = onRemove - } -} - // MARK: - Preview #Preview { diff --git a/Yattee/Views/Playlist/TappablePlaylistVideoRow.swift b/Yattee/Views/Playlist/TappablePlaylistVideoRow.swift deleted file mode 100644 index 2c7a3208..00000000 --- a/Yattee/Views/Playlist/TappablePlaylistVideoRow.swift +++ /dev/null @@ -1,52 +0,0 @@ -// -// TappablePlaylistVideoRow.swift -// Yattee -// -// Tappable row view for playlist videos. -// - -import SwiftUI - -/// A playlist video row that plays the video when tapped. -/// -/// Note: For new code, consider using `PlaylistVideoRowView(...).tappableVideo(video, includeContextMenu: false)` directly. -struct TappablePlaylistVideoRow: View { - let video: Video - let index: Int - - var body: some View { - PlaylistVideoRowView( - index: index, - video: video - ) - .padding(.horizontal) - .padding(.vertical, 8) - .tappableVideo(video, includeContextMenu: false) - } -} - -// MARK: - Preview - -#Preview { - List { - TappablePlaylistVideoRow( - video: Video( - id: .global("test"), - title: "Test Video", - description: nil, - author: Author(id: "author", name: "Test Channel"), - duration: 360, - publishedAt: nil, - publishedText: "2 days ago", - viewCount: 10000, - likeCount: nil, - thumbnails: [], - isLive: false, - isUpcoming: false, - scheduledStartTime: nil - ), - index: 1 - ) - } - .appEnvironment(.preview) -} diff --git a/Yattee/Views/RemoteControl/RemoteControlView.swift b/Yattee/Views/RemoteControl/RemoteControlView.swift index 1c9d82f1..7bb93ec9 100644 --- a/Yattee/Views/RemoteControl/RemoteControlView.swift +++ b/Yattee/Views/RemoteControl/RemoteControlView.swift @@ -804,11 +804,6 @@ struct RemoteControlView: View { isConnecting = false } - private func disconnect() { - remoteControl?.disconnect(from: device) - isConnected = false - } - /// Returns the previous playback rate, or nil if at minimum. private func previousRate() -> PlaybackRate? { let allRates = PlaybackRate.allCases diff --git a/Yattee/Views/Search/SearchView.swift b/Yattee/Views/Search/SearchView.swift index 999f67ed..9ccd1a69 100644 --- a/Yattee/Views/Search/SearchView.swift +++ b/Yattee/Views/Search/SearchView.swift @@ -89,10 +89,6 @@ struct SearchView: View { searchViewModel?.hasResults ?? false } - private var hasSearched: Bool { - searchViewModel?.hasSearched ?? false - } - /// Initialize SearchView with optional external search text binding init(searchText: Binding<String>? = nil, initialQuery: String? = nil) { self.externalSearchText = searchText @@ -1372,11 +1368,6 @@ struct SearchView: View { appEnvironment?.dataManager.deleteSearchQuery(history) } - private func clearAllHistory() { - appEnvironment?.dataManager.clearSearchHistory() - isSearchHistoryExpanded = false - } - private func executeSearch(_ query: String) { dismissKeyboard() suppressNextTextChangeSearch = (query != searchTextBinding.wrappedValue) @@ -1417,14 +1408,6 @@ struct SearchView: View { appEnvironment?.dataManager.deleteRecentPlaylist(playlist) } - private func clearAllRecentChannels() { - appEnvironment?.dataManager.clearRecentChannels() - } - - private func clearAllRecentPlaylists() { - appEnvironment?.dataManager.clearRecentPlaylists() - } - private func clearAllRecents() { appEnvironment?.dataManager.clearSearchHistory() appEnvironment?.dataManager.clearRecentChannels() diff --git a/Yattee/Views/Settings/EditSourceView.swift b/Yattee/Views/Settings/EditSourceView.swift index 2f901d0a..5fb2d00a 100644 --- a/Yattee/Views/Settings/EditSourceView.swift +++ b/Yattee/Views/Settings/EditSourceView.swift @@ -10,9 +10,6 @@ import SwiftUI struct EditSourceView: View { let source: UnifiedSource - @Environment(\.dismiss) private var dismiss - @Environment(\.appEnvironment) private var appEnvironment - var body: some View { switch source { case .remoteServer(let instance): @@ -400,10 +397,6 @@ private struct EditRemoteServerContent: View { // MARK: - Computed Properties - private var hasYatteeServer: Bool { - appEnvironment?.instancesManager.hasYatteeServerInstances ?? false - } - private func invidiousDisplayValue(_ info: InstanceDetectorModels.YatteeServerFullInfo) -> String { guard let invidiousInstance = info.config?.invidiousInstance else { return String(localized: "sources.serverInfo.notConfigured") diff --git a/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift b/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift index a0268ffc..22721ac3 100644 --- a/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift +++ b/Yattee/Views/Settings/PlayerControls/MiniPlayerEditorView.swift @@ -17,8 +17,7 @@ struct MiniPlayerEditorView: View { var body: some View { List { MiniPlayerPreviewView( - buttons: viewModel.miniPlayerButtons, - showVideo: showVideo + buttons: viewModel.miniPlayerButtons ) .listRowInsets(EdgeInsets()) .listRowBackground(Color.clear) @@ -213,7 +212,6 @@ struct MiniPlayerEditorView: View { private struct MiniPlayerPreviewView: View { let buttons: [ControlButtonConfiguration] - let showVideo: Bool private let buttonSize: CGFloat = 28 private let buttonSpacing: CGFloat = 4 diff --git a/Yattee/Views/Settings/PlayerControls/PlayerControlsPreviewView.swift b/Yattee/Views/Settings/PlayerControls/PlayerControlsPreviewView.swift index 32812b59..e3fb4363 100644 --- a/Yattee/Views/Settings/PlayerControls/PlayerControlsPreviewView.swift +++ b/Yattee/Views/Settings/PlayerControls/PlayerControlsPreviewView.swift @@ -12,7 +12,6 @@ import SwiftUI /// A view modifier that applies the controls theme color scheme to preview. struct PreviewThemeModifier: ViewModifier { let theme: ControlsTheme - @Environment(\.colorScheme) private var systemColorScheme func body(content: Content) -> some View { if let forcedScheme = theme.colorScheme { diff --git a/Yattee/Views/Settings/RemoteControlSettingsView.swift b/Yattee/Views/Settings/RemoteControlSettingsView.swift deleted file mode 100644 index 1de3ad90..00000000 --- a/Yattee/Views/Settings/RemoteControlSettingsView.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// RemoteControlSettingsView.swift -// Yattee -// -// Settings for remote control configuration. -// - -import SwiftUI - -struct RemoteControlSettingsView: View { - var body: some View { - RemoteControlContentView(navigationStyle: .link) - .navigationTitle(String(localized: "remoteControl.title")) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - #endif - } -} - -// MARK: - Preview - -#Preview { - NavigationStack { - RemoteControlSettingsView() - } - .appEnvironment(.preview) -} diff --git a/Yattee/Views/Settings/SourceRow.swift b/Yattee/Views/Settings/SourceRow.swift deleted file mode 100644 index e5354f6e..00000000 --- a/Yattee/Views/Settings/SourceRow.swift +++ /dev/null @@ -1,161 +0,0 @@ -// -// SourceRow.swift -// Yattee -// -// Unified row view for displaying any source type in the sources list. -// - -import SwiftUI - -struct SourceRow: View { - let source: UnifiedSource - let onEdit: () -> Void - - @Environment(\.appEnvironment) private var appEnvironment - - /// Computed property that checks password status reactively for WebDAV sources - private var needsPassword: Bool { - guard case .fileSource(let mediaSource) = source, - mediaSource.type == .webdav else { return false } - return appEnvironment?.mediaSourcesManager.needsPassword(for: mediaSource) ?? false - } - - /// Computed property that checks instance status for auth issues - private var instanceStatus: InstanceStatus? { - guard case .remoteServer(let instance) = source else { return nil } - return appEnvironment?.instancesManager.status(for: instance) - } - - /// Whether this instance has auth issues (failed or required) - private var hasAuthIssue: Bool { - guard let status = instanceStatus else { return false } - return status == .authFailed || status == .authRequired - } - - /// Whether a remote server instance requires credentials that are missing from - /// the Keychain (e.g. after reinstalling and importing sources from iCloud). - private var needsCredentials: Bool { - guard case .remoteServer(let instance) = source else { return false } - return appEnvironment?.needsCredentials(for: instance) ?? false - } - - /// Whether one of this source's logins is being refused by the server. - /// - /// The instance-level statuses know nothing about accounts, so a rejected - /// login would otherwise show up nowhere the user looks for their sources. - private var hasRejectedAccount: Bool { - guard case .remoteServer(let instance) = source, - let accountsManager = appEnvironment?.accountsManager else { return false } - - return accountsManager.accounts(forInstance: instance.id).contains { - accountsManager.status(for: $0.id) == .credentialRejected - } - } - - var body: some View { - #if os(tvOS) - Button(action: onEdit) { - rowContent - } - .buttonStyle(.card) - .accessibilityIdentifier(accessibilityId) - #else - rowContent - .contentShape(Rectangle()) - .onTapGesture(perform: onEdit) - .accessibilityElement(children: .combine) - .accessibilityIdentifier(accessibilityId) - #endif - } - - private var rowContent: some View { - HStack { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text(source.name) - .font(.headline) - - if !source.isEnabled { - Text(String(localized: "sources.status.disabled")) - .font(.caption) - .foregroundStyle(.secondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(.quaternary) - .clipShape(Capsule()) - } - } - - Text(source.urlDisplayString) - .font(.caption) - .foregroundStyle(.secondary) - - statusView - } - - Spacer() - } - } - - /// Generates a unique accessibility identifier for the source row. - /// Format: sources.row.<type>.<host> - private var accessibilityId: String { - switch source { - case .remoteServer(let instance): - let host = instance.url.host ?? "unknown" - return "sources.row.\(instance.type.rawValue).\(host)" - case .fileSource(let mediaSource): - let identifier = mediaSource.url.host ?? mediaSource.name.replacingOccurrences(of: " ", with: "_") - return "sources.row.\(mediaSource.type.rawValue).\(identifier)" - } - } - - @ViewBuilder - private var statusView: some View { - // A refused login comes first: it names one thing the user has to do, - // and two warnings stacked on one row say less than either alone. - if hasRejectedAccount { - Label( - AccountStatusBadge.text(for: .credentialRejected), - systemImage: "person.crop.circle.badge.exclamationmark" - ) - .font(.caption2) - .foregroundStyle(AccountStatusBadge.color(for: .credentialRejected)) - } else if needsPassword || needsCredentials { - Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") - .font(.caption2) - .foregroundStyle(.orange) - } else if hasAuthIssue { - // Show auth issue for remote servers (Yattee Server auth failed) - if instanceStatus == .authFailed { - Label(String(localized: "sources.status.authFailed"), systemImage: "exclamationmark.triangle.fill") - .font(.caption2) - .foregroundStyle(.orange) - } else { - Label(String(localized: "sources.status.authRequired"), systemImage: "key.fill") - .font(.caption2) - .foregroundStyle(.orange) - } - } - } -} - -// MARK: - Preview - -#Preview { - List { - SourceRow( - source: .remoteServer(Instance(type: .invidious, url: URL(string: "https://invidious.example.com")!)), - onEdit: {} - ) - SourceRow( - source: .fileSource(.webdav(name: "My NAS", url: URL(string: "https://nas.local:5006")!)), - onEdit: {} - ) - SourceRow( - source: .fileSource(.localFolder(name: "Movies", url: URL(fileURLWithPath: "/Users/user/Movies"))), - onEdit: {} - ) - } - .appEnvironment(.preview) -} diff --git a/Yattee/Views/Subscriptions/ManageChannelsView.swift b/Yattee/Views/Subscriptions/ManageChannelsView.swift index 29f37133..a7941119 100644 --- a/Yattee/Views/Subscriptions/ManageChannelsView.swift +++ b/Yattee/Views/Subscriptions/ManageChannelsView.swift @@ -131,7 +131,6 @@ struct ManageChannelsView: View { private var dataManager: DataManager? { appEnvironment?.dataManager } private var subscriptionService: SubscriptionService? { appEnvironment?.subscriptionService } - private var settingsManager: SettingsManager? { appEnvironment?.settingsManager } private var toastManager: ToastManager? { appEnvironment?.toastManager } private var yatteeServer: Instance? { appEnvironment?.instancesManager.enabledYatteeServerInstances.first diff --git a/YatteeTests/AccountSyncTests.swift b/YatteeTests/AccountSyncTests.swift index 590352a5..cf2d9be6 100644 --- a/YatteeTests/AccountSyncTests.swift +++ b/YatteeTests/AccountSyncTests.swift @@ -55,7 +55,6 @@ struct AccountSyncTests { } private struct Harness { - let defaults: UserDefaults let accounts: AccountsManager let store: AccountSyncStore let coordinator: AccountSyncCoordinator @@ -109,7 +108,6 @@ struct AccountSyncTests { coordinator.attach(database: nil) { recorder.record($0) } return Harness( - defaults: defaults, accounts: accounts, store: store, coordinator: coordinator, @@ -1152,7 +1150,6 @@ struct AccountSyncTests { let scope: CredentialIdentityScope let instances: InstancesManager let accounts: AccountsManager - let store: AccountSyncStore let coordinator: AccountSyncCoordinator /// Held for the length of the test: the manager's activation hook /// only borrows it. @@ -1261,7 +1258,6 @@ struct AccountSyncTests { scope: scope, instances: instances, accounts: accounts, - store: store, coordinator: coordinator, importer: importer, recorder: recorder, diff --git a/YatteeTests/AccountsScreenTests.swift b/YatteeTests/AccountsScreenTests.swift index faa17b7f..20bf162c 100644 --- a/YatteeTests/AccountsScreenTests.swift +++ b/YatteeTests/AccountsScreenTests.swift @@ -231,7 +231,6 @@ private final class DeletionOrderStore: AccountCredentialsManager { self.accountsManager = accountsManager } - var loggedInAccountIDs: Set<UUID> { [] } var isScopeResolved: Bool { true } func setCredential(_: String, for _: Account) {} @@ -245,7 +244,6 @@ private final class DeletionOrderStore: AccountCredentialsManager { func migrateCredential(forAccountID _: UUID, fromScope _: String) -> Bool { true } func deleteCredential(forAccountID _: UUID, inScope _: String) -> Bool { true } - func resyncCredentialsWithCurrentSetting() {} func setUsername(_: String, for _: Account) {} func username(for _: Account) -> String? { nil } func isLoggedIn(for _: Account) -> Bool { false } diff --git a/YatteeTests/Integration/TestConstants.swift b/YatteeTests/Integration/TestConstants.swift index 7adf7736..9f6d2365 100644 --- a/YatteeTests/Integration/TestConstants.swift +++ b/YatteeTests/Integration/TestConstants.swift @@ -26,12 +26,6 @@ enum IntegrationTestConstants { /// Rick Astley's channel ID. static let testChannelID = "UCuAXFkgsw1L7xaCfnd5JJOw" - /// A popular music playlist ID. - static let testPlaylistID = "PLrAXtmErZgOeiKm4sgNOknGvNjby9efdf" - /// A stable search query. static let testSearchQuery = "never gonna give you up" - - /// Timeout for network requests (30 seconds). - static let networkTimeout: TimeInterval = 30 } diff --git a/YatteeTests/LocalNamespaceImportTests.swift b/YatteeTests/LocalNamespaceImportTests.swift index f16b6f13..3a4657d8 100644 --- a/YatteeTests/LocalNamespaceImportTests.swift +++ b/YatteeTests/LocalNamespaceImportTests.swift @@ -849,7 +849,6 @@ private final class TestCredentialsManager: AccountCredentialsManager { ) } - var loggedInAccountIDs: Set<UUID> { credentials.loggedInAccountIDs } var isScopeResolved: Bool { credentials.isScopeResolved } func setCredential(_ credential: String, for account: Account) { @@ -864,8 +863,6 @@ private final class TestCredentialsManager: AccountCredentialsManager { credentials.deleteCredential(forAccountID: accountID) } - func resyncCredentialsWithCurrentSetting() { credentials.resyncCredentialsWithCurrentSetting() } - func setUsername(_ username: String, for account: Account) { credentials.setUsername(username, for: account) } @@ -916,14 +913,12 @@ private final class FlakyCredentialsManager: AccountCredentialsManager { private(set) var migrateCalls = 0 private(set) var deleteCalls = 0 - var loggedInAccountIDs: Set<UUID> { [] } var isScopeResolved: Bool { true } func setCredential(_: String, for _: Account) {} func credential(for _: Account) -> String? { nil } func deleteCredential(for _: Account) {} func deleteCredential(forAccountID _: UUID) {} - func resyncCredentialsWithCurrentSetting() {} func setUsername(_: String, for _: Account) {} func username(for _: Account) -> String? { nil } func isLoggedIn(for _: Account) -> Bool { false }