mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 13:12:53 +00:00
Show the sidebar layout on the iPhone Duo inner screen
The sidebar layout was gated on iPad idiom. It now appears wherever there is room for it: regular width and regular height, or an iPad with regular width (a short Stage Manager window keeps it). That covers the Duo inner screen in both orientations, while large iPhones in landscape and the Duo outer screen keep the tab bar. The subscriptions feed's channel sidebar follows the same rule, and Sidebar settings are offered on devices with a hinge. SwiftUI's sidebarAdaptable TabView never shows a sidebar on iPhone idiom, so the iOS sidebar layout now uses the NavigationSplitView with a list sidebar that macOS already used, on iPad as well. The sidebar layout shows the floating mini player instead of the tab bar accessory.
This commit is contained in:
@@ -174,6 +174,7 @@ struct ContentView: View {
|
||||
/// selection and every stack.
|
||||
struct AdaptiveTabView: View {
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
let appEnvironment: AppEnvironment
|
||||
|
||||
@@ -199,7 +200,7 @@ struct AdaptiveTabView: View {
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
if horizontalSizeClass == .compact || UIDevice.current.userInterfaceIdiom == .phone {
|
||||
if !usesSidebar {
|
||||
CompactTabView(router: appEnvironment.navigationCoordinator.router)
|
||||
.environment(appEnvironment.settingsManager)
|
||||
} else {
|
||||
@@ -207,8 +208,9 @@ struct AdaptiveTabView: View {
|
||||
.environment(appEnvironment.settingsManager)
|
||||
}
|
||||
|
||||
// iOS 26.1+ shows the mini player as the tab bar's bottom accessory
|
||||
if !Self.usesTabBottomAccessory {
|
||||
// The tab bar layout on iOS 26.1+ shows the mini player as the tab
|
||||
// bar's bottom accessory; the split-view sidebar layout has no tab bar.
|
||||
if usesSidebar || !Self.usesTabBottomAccessory {
|
||||
miniPlayerOverlay
|
||||
}
|
||||
|
||||
@@ -263,6 +265,10 @@ struct AdaptiveTabView: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var usesSidebar: Bool {
|
||||
NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
|
||||
}
|
||||
|
||||
private static var usesTabBottomAccessory: Bool {
|
||||
if #available(iOS 26.1, *) { true } else { false }
|
||||
}
|
||||
@@ -279,9 +285,9 @@ struct AdaptiveTabView: View {
|
||||
MiniPlayerView()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
// Compact width has a bottom tab bar (49pt) to clear; regular width
|
||||
// (sidebarAdaptable) keeps its tabs at the top, so only float above the edge.
|
||||
.padding(.bottom, horizontalSizeClass == .compact ? 49 + 8 : 12)
|
||||
// The tab bar layout has a bottom tab bar (49pt) to clear; the sidebar
|
||||
// layout (sidebarAdaptable) keeps its tabs at the top, so only float above the edge.
|
||||
.padding(.bottom, usesSidebar ? 12 : 49 + 8)
|
||||
// Use move-only transition (no opacity) to prevent thumbnail flash during collapse
|
||||
// The opacity transition caused video to appear faded during player sheet dismiss
|
||||
.transition(.move(edge: .bottom))
|
||||
|
||||
@@ -144,3 +144,22 @@ final class NavigationRouter {
|
||||
selection = host
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Layout Choice
|
||||
|
||||
#if os(iOS)
|
||||
enum NavigationLayout {
|
||||
/// Whether the sidebar layout fits: regular width and height (iPad, the
|
||||
/// iPhone Duo inner screen in either orientation). A large iPhone in
|
||||
/// landscape is regular width but compact height, so it keeps the tab
|
||||
/// bar. An iPad keeps the sidebar for as long as its width is regular,
|
||||
/// even in a short Stage Manager window.
|
||||
static func usesSidebar(
|
||||
horizontalSizeClass: UserInterfaceSizeClass?,
|
||||
verticalSizeClass: UserInterfaceSizeClass?
|
||||
) -> Bool {
|
||||
guard horizontalSizeClass == .regular else { return false }
|
||||
return verticalSizeClass == .regular || UIDevice.current.userInterfaceIdiom == .pad
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -9,276 +9,9 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
// MARK: - iOS Implementation
|
||||
// MARK: - iOS Tab Bar Helpers
|
||||
|
||||
#if os(iOS)
|
||||
struct UnifiedTabView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
|
||||
let router: NavigationRouter
|
||||
|
||||
// Zoom transition namespace (local to this tab view)
|
||||
@Namespace private var zoomTransition
|
||||
|
||||
private var shouldShowAccessory: Bool {
|
||||
guard let state = appEnvironment?.playerService.state else { return false }
|
||||
return state.currentVideo != nil
|
||||
}
|
||||
|
||||
private var zoomTransitionsEnabled: Bool {
|
||||
appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true
|
||||
}
|
||||
|
||||
private var yatteeServerAuthHeader: String? {
|
||||
guard let server = appEnvironment?.instancesManager.enabledYatteeServerInstances.first else { return nil }
|
||||
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
@Bindable var router = router
|
||||
|
||||
TabView(selection: $router.selection) {
|
||||
mainTabs
|
||||
sidebarSections
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
.tabViewSidebarBottomBar {
|
||||
// Same chrome the macOS sidebar hangs below its list: the sidebar
|
||||
// is the one surface every tab shares, so the switcher lives here
|
||||
// rather than on each screen's toolbar.
|
||||
if DataContextSwitcherVisibility.showsPickers(in: appEnvironment) {
|
||||
// The bottom bar pins over the list without a background of its
|
||||
// own, so sidebar rows scrolled to the end show through the
|
||||
// switcher. A pill of its own keeps what's beneath it blurred.
|
||||
DataContextSwitcherSidebarRow()
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.sidebarBottomBarPill()
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.iOS26TabFeatures(shouldShowAccessory: shouldShowAccessory, settingsManager: appEnvironment?.settingsManager)
|
||||
.zoomTransitionNamespace(zoomTransition)
|
||||
.zoomTransitionsEnabled(zoomTransitionsEnabled)
|
||||
.onAppear {
|
||||
configureSidebarManager()
|
||||
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home)
|
||||
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
|
||||
}
|
||||
.onChange(of: router.selection) { _, _ in
|
||||
ensureSelectionIsAvailable(visibleMainItems: visibleMainItems)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Visible Main Items
|
||||
|
||||
private var visibleMainItems: [SidebarMainItem] {
|
||||
settingsManager?.visibleSidebarMainItems() ?? SidebarMainItem.defaultOrder
|
||||
}
|
||||
|
||||
// MARK: - Tab Builders
|
||||
|
||||
@TabContentBuilder<SidebarItem>
|
||||
private var mainTabs: some TabContent<SidebarItem> {
|
||||
ForEach(visibleMainItems) { item in
|
||||
mainTab(for: item)
|
||||
}
|
||||
}
|
||||
|
||||
@TabContentBuilder<SidebarItem>
|
||||
private func mainTab(for item: SidebarMainItem) -> some TabContent<SidebarItem> {
|
||||
switch item {
|
||||
case .search:
|
||||
Tab(value: SidebarItem.search, role: .search) {
|
||||
NavigationStack(path: router.path(for: .search)) {
|
||||
SearchView(searchText: Bindable(router).searchText)
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.search.title, systemImage: SidebarItem.search.systemImage)
|
||||
}
|
||||
|
||||
case .home:
|
||||
Tab(value: SidebarItem.home) {
|
||||
NavigationStack(path: router.path(for: .home)) {
|
||||
HomeView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.home.title, systemImage: SidebarItem.home.systemImage)
|
||||
}
|
||||
|
||||
case .subscriptions:
|
||||
Tab(value: SidebarItem.subscriptionsFeed) {
|
||||
NavigationStack(path: router.path(for: .subscriptionsFeed)) {
|
||||
SubscriptionsView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.subscriptionsFeed.title, systemImage: SidebarItem.subscriptionsFeed.systemImage)
|
||||
}
|
||||
|
||||
case .bookmarks:
|
||||
Tab(value: SidebarItem.bookmarks) {
|
||||
NavigationStack(path: router.path(for: .bookmarks)) {
|
||||
BookmarksListView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.bookmarks.title, systemImage: SidebarItem.bookmarks.systemImage)
|
||||
}
|
||||
|
||||
case .history:
|
||||
Tab(value: SidebarItem.history) {
|
||||
NavigationStack(path: router.path(for: .history)) {
|
||||
HistoryListView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.history.title, systemImage: SidebarItem.history.systemImage)
|
||||
}
|
||||
|
||||
case .downloads:
|
||||
Tab(value: SidebarItem.downloads) {
|
||||
NavigationStack(path: router.path(for: .downloads)) {
|
||||
DownloadsView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.downloads.title, systemImage: SidebarItem.downloads.systemImage)
|
||||
}
|
||||
|
||||
case .channels:
|
||||
Tab(value: SidebarItem.manageChannels) {
|
||||
NavigationStack(path: router.path(for: .manageChannels)) {
|
||||
ManageChannelsView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.manageChannels.title, systemImage: SidebarItem.manageChannels.systemImage)
|
||||
}
|
||||
|
||||
case .playlists:
|
||||
Tab(value: SidebarItem.playlistsList) {
|
||||
NavigationStack(path: router.path(for: .playlistsList)) {
|
||||
PlaylistsListView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.playlistsList.title, systemImage: SidebarItem.playlistsList.systemImage)
|
||||
}
|
||||
|
||||
case .sources:
|
||||
Tab(value: SidebarItem.sources) {
|
||||
NavigationStack(path: router.path(for: .sources)) {
|
||||
MediaSourcesView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.sources.title, systemImage: SidebarItem.sources.systemImage)
|
||||
}
|
||||
|
||||
case .settings:
|
||||
Tab(value: SidebarItem.settings) {
|
||||
NavigationStack(path: router.path(for: .settings)) {
|
||||
SettingsView(showCloseButton: false)
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.settings.title, systemImage: SidebarItem.settings.systemImage)
|
||||
}
|
||||
|
||||
case .openURL:
|
||||
Tab(value: SidebarItem.openURL) {
|
||||
NavigationStack(path: router.path(for: .openURL)) {
|
||||
OpenLinkView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.openURL.title, systemImage: SidebarItem.openURL.systemImage)
|
||||
}
|
||||
|
||||
case .remoteControl:
|
||||
Tab(value: SidebarItem.remoteControl) {
|
||||
NavigationStack(path: router.path(for: .remoteControl)) {
|
||||
RemoteControlContentView(navigationStyle: .link)
|
||||
.navigationTitle(String(localized: "remoteControl.title"))
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.remoteControl.title, systemImage: SidebarItem.remoteControl.systemImage)
|
||||
}
|
||||
|
||||
case .continueWatching:
|
||||
Tab(value: SidebarItem.continueWatching) {
|
||||
NavigationStack(path: router.path(for: .continueWatching)) {
|
||||
ContinueWatchingView()
|
||||
.withNavigationDestinations()
|
||||
}
|
||||
} label: {
|
||||
Label(SidebarItem.continueWatching.title, systemImage: SidebarItem.continueWatching.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@TabContentBuilder<SidebarItem>
|
||||
private var sidebarSections: some TabContent<SidebarItem> {
|
||||
// Unified Sources Section (sidebar only - shows instances + media sources)
|
||||
TabSection(String(localized: "sidebar.section.sources")) {
|
||||
ForEach(sidebarManager.sortedSourceItems) { item in
|
||||
Tab(value: item) {
|
||||
sourceContent(for: item)
|
||||
} label: {
|
||||
Label(item.title, systemImage: item.systemImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
.defaultVisibility(.hidden, for: .tabBar)
|
||||
.hidden(
|
||||
horizontalSizeClass == .compact ||
|
||||
sidebarManager.hasNoSources ||
|
||||
!(settingsManager?.sidebarSourcesEnabled ?? true)
|
||||
)
|
||||
|
||||
// Channels Section (sidebar only - shows subscribed channels)
|
||||
TabSection(String(localized: "sidebar.section.channels")) {
|
||||
ForEach(sidebarManager.channelItems) { item in
|
||||
Tab(value: item) {
|
||||
channelContent(for: item)
|
||||
} label: {
|
||||
channelLabel(for: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.defaultVisibility(.hidden, for: .tabBar)
|
||||
.hidden(
|
||||
horizontalSizeClass == .compact ||
|
||||
sidebarManager.channelItems.isEmpty ||
|
||||
!(settingsManager?.sidebarChannelsEnabled ?? true)
|
||||
)
|
||||
|
||||
// Playlists Section (sidebar only)
|
||||
TabSection(String(localized: "sidebar.section.playlists")) {
|
||||
ForEach(sidebarManager.playlistItems) { item in
|
||||
Tab(value: item) {
|
||||
playlistContent(for: item)
|
||||
} label: {
|
||||
playlistLabel(for: item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.defaultVisibility(.hidden, for: .tabBar)
|
||||
.hidden(
|
||||
horizontalSizeClass == .compact ||
|
||||
sidebarManager.playlistItems.isEmpty ||
|
||||
!(settingsManager?.sidebarPlaylistsEnabled ?? true)
|
||||
)
|
||||
}
|
||||
|
||||
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
||||
}
|
||||
|
||||
private extension View {
|
||||
@ViewBuilder
|
||||
func sidebarBottomBarPill() -> some View {
|
||||
@@ -322,15 +55,14 @@ extension MiniPlayerMinimizeBehavior {
|
||||
}
|
||||
}
|
||||
|
||||
#Preview("Unified Tab View - iOS") {
|
||||
UnifiedTabView(router: NavigationRouter())
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - macOS Implementation
|
||||
// MARK: - iOS and macOS Implementation
|
||||
|
||||
#if os(macOS)
|
||||
#if os(iOS) || os(macOS)
|
||||
/// Sidebar layout for iPad, the iPhone Duo inner screen and macOS: a
|
||||
/// split view with a list sidebar. SwiftUI's sidebarAdaptable TabView never
|
||||
/// shows a sidebar on iPhone idiom, so the split view is used on iOS too.
|
||||
struct UnifiedTabView: View {
|
||||
@Environment(\.appEnvironment) private var appEnvironment
|
||||
|
||||
@@ -344,6 +76,12 @@ struct UnifiedTabView: View {
|
||||
return appEnvironment?.basicAuthCredentialsManager.basicAuthHeader(for: server)
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private var zoomTransitionsEnabled: Bool {
|
||||
appEnvironment?.settingsManager.zoomTransitionsEnabled ?? true
|
||||
}
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
sidebar
|
||||
@@ -351,6 +89,9 @@ struct UnifiedTabView: View {
|
||||
detailForSelection
|
||||
}
|
||||
.zoomTransitionNamespace(zoomTransition)
|
||||
#if os(iOS)
|
||||
.zoomTransitionsEnabled(zoomTransitionsEnabled)
|
||||
#endif
|
||||
.onAppear {
|
||||
configureSidebarManager()
|
||||
router.applyStartupTabIfNeeded(settingsManager?.effectiveStartupTabForSidebar() ?? .home)
|
||||
@@ -385,13 +126,29 @@ struct UnifiedTabView: View {
|
||||
}
|
||||
#else
|
||||
sidebarList
|
||||
.safeAreaInset(edge: .bottom) {
|
||||
if DataContextSwitcherVisibility.showsPickers(in: appEnvironment) {
|
||||
// The sidebar is the one surface every section shares, so
|
||||
// the switcher lives here rather than on each screen's toolbar.
|
||||
DataContextSwitcherSidebarRow()
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.sidebarBottomBarPill()
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
private var sidebarList: some View {
|
||||
@Bindable var router = router
|
||||
// List selection is optional; deselecting keeps the current section.
|
||||
let selection = Binding<SidebarItem?>(
|
||||
get: { router.selection },
|
||||
set: { if let item = $0 { router.selection = item } }
|
||||
)
|
||||
|
||||
return List(selection: $router.selection) {
|
||||
return List(selection: selection) {
|
||||
ForEach(visibleMainItems) { item in
|
||||
sidebarRow(for: item)
|
||||
}
|
||||
@@ -448,7 +205,7 @@ struct UnifiedTabView: View {
|
||||
}
|
||||
case .search:
|
||||
NavigationStack(path: router.path(for: .search)) {
|
||||
SearchView().withNavigationDestinations()
|
||||
SearchView(searchText: Bindable(router).searchText).withNavigationDestinations()
|
||||
}
|
||||
case .subscriptionsFeed:
|
||||
NavigationStack(path: router.path(for: .subscriptionsFeed)) {
|
||||
@@ -480,7 +237,11 @@ struct UnifiedTabView: View {
|
||||
}
|
||||
case .settings:
|
||||
NavigationStack(path: router.path(for: .settings)) {
|
||||
#if os(iOS)
|
||||
SettingsView(showCloseButton: false).withNavigationDestinations()
|
||||
#else
|
||||
SettingsView().withNavigationDestinations()
|
||||
#endif
|
||||
}
|
||||
case .openURL:
|
||||
NavigationStack(path: router.path(for: .openURL)) {
|
||||
@@ -511,7 +272,7 @@ struct UnifiedTabView: View {
|
||||
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
||||
}
|
||||
|
||||
#Preview("Unified Tab View - macOS") {
|
||||
#Preview("Unified Tab View") {
|
||||
UnifiedTabView(router: NavigationRouter())
|
||||
.appEnvironment(.preview)
|
||||
}
|
||||
|
||||
@@ -78,6 +78,10 @@ private struct HapticsSection: View {
|
||||
// MARK: - Customization Section
|
||||
|
||||
private struct CustomizationSection: View {
|
||||
#if os(iOS)
|
||||
@State private var hasHinge = HingeLayoutObserver.forceTabletop
|
||||
#endif
|
||||
|
||||
var body: some View {
|
||||
SettingsFormSection {
|
||||
#if os(tvOS)
|
||||
@@ -100,8 +104,9 @@ private struct CustomizationSection: View {
|
||||
TabBarSettingsView()
|
||||
}
|
||||
|
||||
// Sidebar settings only on iPad (not iPhone)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
||||
// Sidebar settings where the sidebar layout can appear: iPad and
|
||||
// foldable iPhones (inner screen)
|
||||
if UIDevice.current.userInterfaceIdiom == .pad || hasHinge {
|
||||
SettingsNavigationRow("settings.sidebar.title", systemImage: "sidebar.leading") {
|
||||
SidebarSettingsView()
|
||||
}
|
||||
@@ -123,6 +128,9 @@ private struct CustomizationSection: View {
|
||||
}
|
||||
#endif
|
||||
}
|
||||
#if os(iOS)
|
||||
.observeHinge(hasHinge: $hasHinge)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ struct SubscriptionsView: View {
|
||||
#endif
|
||||
#if os(iOS)
|
||||
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
#endif
|
||||
@State private var feedCache = SubscriptionFeedCache.shared
|
||||
@State private var subscriptions: [Subscription] = []
|
||||
@@ -1138,8 +1139,10 @@ struct SubscriptionsView: View {
|
||||
private static let iPadSidebarMinWidth = 220.0
|
||||
private static let iPadSidebarMaxWidth = 420.0
|
||||
|
||||
/// Room for the channel sidebar next to the feed: wherever the app itself
|
||||
/// uses the sidebar layout (iPad, iPhone Duo inner screen).
|
||||
private var isIPadRegular: Bool {
|
||||
UIDevice.current.userInterfaceIdiom == .pad && horizontalSizeClass == .regular
|
||||
NavigationLayout.usesSidebar(horizontalSizeClass: horizontalSizeClass, verticalSizeClass: verticalSizeClass)
|
||||
}
|
||||
|
||||
private var clampedIPadSidebarWidth: CGFloat {
|
||||
|
||||
Reference in New Issue
Block a user