Add Open URL and Remote Control as sidebar items

After disabling home shortcuts on tvOS, Open URL and Remote Control had
no entry point. Add them as configurable sidebar main items. Remote
Control defaults to visible on tvOS; Open URL defaults to hidden on all
platforms.
This commit is contained in:
Arkadiusz Fal
2026-04-15 06:09:32 +02:00
parent e141a168f0
commit d422bf13e5
5 changed files with 218 additions and 49 deletions

View File

@@ -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" : {

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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(

View File

@@ -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()
}