Fix tvOS focus trap on empty Home after fresh install

Render a focusable empty state on tvOS Home when no sections have content,
with an "Open Sources" button that switches the sidebar selection. Without
a focusable view the tvOS focus engine had no target, leaving the sidebar
unreachable after the initial iCloud alert was dismissed.

Also wire the selectedSidebarItem onChange handler into the tvOS TabView,
which was missing and prevented programmatic sidebar selection.
This commit is contained in:
Arkadiusz Fal
2026-04-18 12:11:06 +02:00
parent 823b8ae686
commit f60a6e3eec
3 changed files with 92 additions and 3 deletions

View File

@@ -3624,6 +3624,39 @@
}
}
},
"home.tvos.empty.title" : {
"comment" : "tvOS Home empty-state title shown on fresh install",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Welcome to Yattee"
}
}
}
},
"home.tvos.empty.message" : {
"comment" : "tvOS Home empty-state message shown on fresh install",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Add a media source to start watching."
}
}
}
},
"home.tvos.empty.openSources" : {
"comment" : "tvOS Home empty-state button that opens the Sources screen",
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Open Sources"
}
}
}
},
"home.history.clear" : {
"comment" : "Clear history button",
"localizations" : {

View File

@@ -166,14 +166,65 @@ struct HomeView: View {
// MARK: - Main Content
#if os(tvOS)
/// True on tvOS when no Home section would render content prevents a focus trap
/// on fresh installs where the detail pane would otherwise be completely empty.
private var isHomeEmpty: Bool {
let sections = settingsManager?.visibleSections() ?? HomeSectionItem.defaultOrder.filter { HomeSectionItem.defaultVisibility[$0] == true }
for section in sections {
switch section {
case .continueWatching:
if !recentContinueWatching.isEmpty { return false }
case .feed:
if !feedCache.videos.isEmpty { return false }
case .bookmarks:
if !recentBookmarks.isEmpty { return false }
case .history:
if !recentHistory.isEmpty { return false }
case .downloads:
break
case .instanceContent, .mediaSource:
return false
}
}
return true
}
private var emptyHomeView: some View {
VStack(spacing: 32) {
VStack(spacing: 12) {
Text(String(localized: "home.tvos.empty.title"))
.font(.title2)
.fontWeight(.semibold)
Text(String(localized: "home.tvos.empty.message"))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
Button {
appEnvironment?.navigationCoordinator.selectedSidebarItem = .sources
} label: {
Label(String(localized: "home.tvos.empty.openSources"), systemImage: "externaldrive.connected.to.line.below")
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
}
.padding(60)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
#endif
@ViewBuilder
private var mainContent: some View {
#if os(tvOS)
if isHomeEmpty {
emptyHomeView
} else {
ScrollView {
LazyVStack(spacing: 0) {
homeContent
}
}
}
#else
let backgroundStyle: ListBackgroundStyle = listStyle == .inset ? .grouped : .plain
backgroundStyle.color

View File

@@ -664,6 +664,11 @@ struct UnifiedTabView: View {
.onChange(of: navigationCoordinator?.pendingNavigation) { _, newValue in
handlePendingNavigation(newValue)
}
.onChange(of: navigationCoordinator?.selectedSidebarItem) { _, newItem in
guard let item = newItem else { return }
selection = item
navigationCoordinator?.selectedSidebarItem = nil
}
}
/// Applies the configured startup tab on first appearance.