diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index a67e8197..cb17f6ef 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -14195,6 +14195,28 @@ } } }, + "sidebar.mainItem.openURL" : { + "comment" : "Sidebar main navigation item for opening a URL", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open URL" + } + } + } + }, + "sidebar.mainItem.remoteControl" : { + "comment" : "Sidebar main navigation item for remote control", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remote Control" + } + } + } + }, "sidebar.mainItem.search" : { "comment" : "Sidebar main navigation item for search", "localizations" : { diff --git a/Yattee/Models/Navigation/SidebarItem.swift b/Yattee/Models/Navigation/SidebarItem.swift index 24c2ba19..9568cf25 100644 --- a/Yattee/Models/Navigation/SidebarItem.swift +++ b/Yattee/Models/Navigation/SidebarItem.swift @@ -15,6 +15,8 @@ enum SidebarItem: Hashable, Identifiable { case sources case settings case nowPlaying + case openURL + case remoteControl // MARK: - Dynamic Channel Items case channel(channelID: String, name: String, source: ContentSource) @@ -49,6 +51,10 @@ enum SidebarItem: Hashable, Identifiable { return "settings" case .nowPlaying: return "now-playing" + case .openURL: + return "open-url" + case .remoteControl: + return "remote-control" case .channel(let channelID, _, let source): return "channel-\(source.provider)-\(channelID)" case .playlist(let id, _): @@ -84,6 +90,10 @@ enum SidebarItem: Hashable, Identifiable { return String(localized: "tabs.settings") case .nowPlaying: return String(localized: "sidebar.nowPlaying") + case .openURL: + return String(localized: "sidebar.mainItem.openURL") + case .remoteControl: + return String(localized: "sidebar.mainItem.remoteControl") case .channel(_, let name, _): return name case .playlist(_, let title): @@ -117,6 +127,10 @@ enum SidebarItem: Hashable, Identifiable { return "gear" case .nowPlaying: return "play.circle.fill" + case .openURL: + return "link" + case .remoteControl: + return "antenna.radiowaves.left.and.right" case .channel: return "person.circle" case .playlist: @@ -144,7 +158,7 @@ enum SidebarItem: Hashable, Identifiable { /// Returns nil for items that are root views (home, search) which don't push. func navigationDestination() -> NavigationDestination? { switch self { - case .home, .search, .sources, .settings, .nowPlaying: + case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl: // These are root tabs, not push destinations return nil case .channel(let channelID, _, let source): @@ -174,7 +188,7 @@ enum SidebarItem: Hashable, Identifiable { /// Whether this is a fixed navigation item (always visible). var isFixedNavigation: Bool { switch self { - case .home, .search, .sources, .settings, .nowPlaying: + case .home, .search, .sources, .settings, .nowPlaying, .openURL, .remoteControl: return true default: return false diff --git a/Yattee/Models/Navigation/SidebarMainItem.swift b/Yattee/Models/Navigation/SidebarMainItem.swift index 37524740..7fc1d4e6 100644 --- a/Yattee/Models/Navigation/SidebarMainItem.swift +++ b/Yattee/Models/Navigation/SidebarMainItem.swift @@ -18,16 +18,19 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable { case channels case sources case settings + case openURL + case remoteControl var id: String { rawValue } /// Default order for sidebar main items. static var defaultOrder: [SidebarMainItem] { - [.search, .home, .subscriptions, .bookmarks, .history, .channels, .sources, .downloads, .settings] + [.search, .home, .subscriptions, .bookmarks, .history, .channels, .sources, .openURL, .remoteControl, .downloads, .settings] } /// Default visibility (all visible except subscriptions and channels). static var defaultVisibility: [SidebarMainItem: Bool] { + #if os(tvOS) [ .search: true, .home: true, @@ -37,8 +40,25 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable { .downloads: true, .channels: false, .sources: true, - .settings: true + .settings: true, + .openURL: false, + .remoteControl: true ] + #else + [ + .search: true, + .home: true, + .subscriptions: false, + .bookmarks: false, + .history: false, + .downloads: true, + .channels: false, + .sources: true, + .settings: true, + .openURL: false, + .remoteControl: false + ] + #endif } /// SF Symbol icon name. @@ -53,6 +73,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable { case .channels: "person.2" case .sources: "server.rack" case .settings: "gear" + case .openURL: "link" + case .remoteControl: "antenna.radiowaves.left.and.right" } } @@ -68,6 +90,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable { case .channels: String(localized: "sidebar.mainItem.channels") case .sources: String(localized: "sidebar.mainItem.sources") case .settings: String(localized: "sidebar.mainItem.settings") + case .openURL: String(localized: "sidebar.mainItem.openURL") + case .remoteControl: String(localized: "sidebar.mainItem.remoteControl") } } @@ -110,6 +134,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable { case .channels: return TabBarItem.channels.rawValue case .sources: return TabBarItem.sources.rawValue case .settings: return TabBarItem.settings.rawValue + case .openURL: return "open-url" + case .remoteControl: return "remote-control" } } @@ -125,6 +151,8 @@ enum SidebarMainItem: String, CaseIterable, Codable, Identifiable, Sendable { case .channels: return .manageChannels case .sources: return .sources case .settings: return .settings + case .openURL: return .openURL + case .remoteControl: return .remoteControl } } diff --git a/Yattee/Views/Home/OpenLinkSheet.swift b/Yattee/Views/Home/OpenLinkSheet.swift index b1ccc5ae..e9e4ebf8 100644 --- a/Yattee/Views/Home/OpenLinkSheet.swift +++ b/Yattee/Views/Home/OpenLinkSheet.swift @@ -40,8 +40,45 @@ private enum ExtractionStatus { /// Supports multiple URLs (one per line, max 20). struct OpenLinkSheet: View { @Environment(\.dismiss) private var dismiss + let prefilledURL: URL? + + init(prefilledURL: URL? = nil) { + self.prefilledURL = prefilledURL + } + + var body: some View { + NavigationStack { + OpenLinkFormView(prefilledURL: prefilledURL, onRequestDismiss: { dismiss() }) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button(String(localized: "common.cancel")) { + dismiss() + } + } + } + } + } +} + +// MARK: - OpenLinkView + +/// Standalone view for entering URLs — used as a tab root (e.g. tvOS sidebar). +/// Not wrapped in a NavigationStack; the containing tab provides one. +struct OpenLinkView: View { + var body: some View { + OpenLinkFormView(prefilledURL: nil, onRequestDismiss: nil) + } +} + +// MARK: - OpenLinkFormView + +/// Shared form body used by both OpenLinkSheet (sheet) and OpenLinkView (tab root). +struct OpenLinkFormView: View { @Environment(\.appEnvironment) private var appEnvironment + let prefilledURL: URL? + let onRequestDismiss: (() -> Void)? + @State private var urlText: String @State private var clipboardURLs: [URL] = [] @FocusState private var isTextEditorFocused: Bool @@ -56,54 +93,50 @@ struct OpenLinkSheet: View { @State private var pendingDownloadItems: [ExtractedItem] = [] /// Maximum number of URLs allowed. - private static let maxURLs = 20 + fileprivate static let maxURLs = 20 - /// Initialize with optional pre-filled URL. - init(prefilledURL: URL? = nil) { + init(prefilledURL: URL?, onRequestDismiss: (() -> Void)?) { + self.prefilledURL = prefilledURL + self.onRequestDismiss = onRequestDismiss _urlText = State(initialValue: prefilledURL?.absoluteString ?? "") } + private func dismissIfRequested() { + onRequestDismiss?() + } + var body: some View { - NavigationStack { - Form { - urlInputSection - extractionResultsSection - actionButtonsSection - yatteeServerWarningSection - } - .navigationTitle(String(localized: "openLink.title")) - #if os(iOS) - .navigationBarTitleDisplayMode(.inline) - .scrollDismissesKeyboard(.immediately) - #endif - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(String(localized: "common.cancel")) { - dismiss() - } - } - } - .onAppear { - checkClipboard() - if urlText.isEmpty { - isTextEditorFocused = true - } - } - #if !os(tvOS) - .sheet(isPresented: $showingDownloadSheet, onDismiss: { - // Close OpenLinkSheet when download sheet is dismissed (if no errors) - if !hasErrors { - dismiss() - } - }) { - BatchDownloadQualitySheet(videoCount: pendingDownloadItems.count) { quality, includeSubtitles in - Task { - await downloadPendingItems(quality: quality, includeSubtitles: includeSubtitles) - } - } - } - #endif + Form { + urlInputSection + extractionResultsSection + actionButtonsSection + yatteeServerWarningSection } + .navigationTitle(String(localized: "openLink.title")) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .scrollDismissesKeyboard(.immediately) + #endif + .onAppear { + checkClipboard() + if urlText.isEmpty { + isTextEditorFocused = true + } + } + #if !os(tvOS) + .sheet(isPresented: $showingDownloadSheet, onDismiss: { + // Close sheet when download sheet is dismissed (if no errors and we're in sheet mode) + if !hasErrors { + dismissIfRequested() + } + }) { + BatchDownloadQualitySheet(videoCount: pendingDownloadItems.count) { quality, includeSubtitles in + Task { + await downloadPendingItems(quality: quality, includeSubtitles: includeSubtitles) + } + } + } + #endif } // MARK: - URL Input Section @@ -456,7 +489,7 @@ struct OpenLinkSheet: View { subtitle: String(localized: "openLink.queuedSuccess.subtitle \(successCount)") ) } - dismiss() + dismissIfRequested() } else if successCount > 0 { appEnvironment.toastManager.show( category: .error, @@ -595,7 +628,7 @@ struct OpenLinkSheet: View { subtitle: String(localized: "openLink.downloadQueued.subtitle \(successCount)") ) } - dismiss() + dismissIfRequested() } else if successCount > 0 { appEnvironment.toastManager.show( category: .error, @@ -695,7 +728,7 @@ struct OpenLinkSheet: View { ) } if !hasErrors { - dismiss() + dismissIfRequested() } } else { appEnvironment.toastManager.show( diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index 29d4f679..f8e40f54 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -35,6 +35,8 @@ struct UnifiedTabView: View { @State private var manageChannelsPath = NavigationPath() @State private var sourcesPath = NavigationPath() @State private var settingsPath = NavigationPath() + @State private var openURLPath = NavigationPath() + @State private var remoteControlPath = NavigationPath() // Current selection - initial value is a placeholder; actual startup tab is applied in onAppear @State private var selection: SidebarItem = .home @@ -199,6 +201,27 @@ struct UnifiedTabView: View { } label: { Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage) } + + case .openURL: + Tab(value: SidebarItem.openURL) { + NavigationStack(path: $openURLPath) { + OpenLinkView() + .withNavigationDestinations() + } + } label: { + Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage) + } + + case .remoteControl: + Tab(value: SidebarItem.remoteControl) { + NavigationStack(path: $remoteControlPath) { + RemoteControlContentView(navigationStyle: .link) + .navigationTitle(String(localized: "remoteControl.title")) + .withNavigationDestinations() + } + } label: { + Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage) + } } } @@ -319,6 +342,8 @@ struct UnifiedTabView: View { @State private var manageChannelsPath = NavigationPath() @State private var sourcesPath = NavigationPath() @State private var settingsPath = NavigationPath() + @State private var openURLPath = NavigationPath() + @State private var remoteControlPath = NavigationPath() // Current selection - initial value is a placeholder; actual startup tab is applied in onAppear @State private var selection: SidebarItem = .home @@ -463,6 +488,26 @@ struct UnifiedTabView: View { } label: { Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage) } + + case .openURL: + Tab(value: SidebarItem.openURL) { + NavigationStack(path: $openURLPath) { + OpenLinkView().withNavigationDestinations() + } + } label: { + Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage) + } + + case .remoteControl: + Tab(value: SidebarItem.remoteControl) { + NavigationStack(path: $remoteControlPath) { + RemoteControlContentView(navigationStyle: .link) + .navigationTitle(String(localized: "remoteControl.title")) + .withNavigationDestinations() + } + } label: { + Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage) + } } } @@ -538,6 +583,8 @@ struct UnifiedTabView: View { @State private var manageChannelsPath = NavigationPath() @State private var sourcesPath = NavigationPath() @State private var settingsPath = NavigationPath() + @State private var openURLPath = NavigationPath() + @State private var remoteControlPath = NavigationPath() // Current selection - initial value is a placeholder; actual startup tab is applied in onAppear @State private var selection: SidebarItem = .home @@ -700,6 +747,27 @@ struct UnifiedTabView: View { } label: { Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage) } + + case .openURL: + Tab(value: SidebarItem.openURL) { + NavigationStack(path: $openURLPath) { + OpenLinkView() + .withNavigationDestinations() + } + } label: { + Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage) + } + + case .remoteControl: + Tab(value: SidebarItem.remoteControl) { + NavigationStack(path: $remoteControlPath) { + RemoteControlContentView(navigationStyle: .link) + .navigationTitle(String(localized: "remoteControl.title")) + .withNavigationDestinations() + } + } label: { + Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage) + } } } @@ -814,6 +882,10 @@ extension UnifiedTabView { settingsPath.append(destination) case .nowPlaying: break // Now Playing is a root tab, not a push destination + case .openURL: + openURLPath.append(destination) + case .remoteControl: + remoteControlPath.append(destination) } navigationCoordinator?.clearPendingNavigation() }