From f60a6e3eec6a7960607f28615bc7e30e204842d4 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Sat, 18 Apr 2026 12:11:06 +0200 Subject: [PATCH] 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. --- Yattee/Localizable.xcstrings | 33 ++++++++++++ Yattee/Views/Home/HomeView.swift | 57 ++++++++++++++++++-- Yattee/Views/Navigation/UnifiedTabView.swift | 5 ++ 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index 6f882446..3d19040f 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -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" : { diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index 649bc2bb..84c2aed9 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -166,12 +166,63 @@ 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) - ScrollView { - LazyVStack(spacing: 0) { - homeContent + if isHomeEmpty { + emptyHomeView + } else { + ScrollView { + LazyVStack(spacing: 0) { + homeContent + } } } #else diff --git a/Yattee/Views/Navigation/UnifiedTabView.swift b/Yattee/Views/Navigation/UnifiedTabView.swift index ac0543be..d1fe0ed8 100644 --- a/Yattee/Views/Navigation/UnifiedTabView.swift +++ b/Yattee/Views/Navigation/UnifiedTabView.swift @@ -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.