From eb697b7bbc98cb7c429ca0f3ed247c80eb366366 Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Wed, 15 Apr 2026 17:54:18 +0200 Subject: [PATCH] Add List/Grid layout option for Home sections Introduces a "Display sections as" picker in Home settings with List and Grid modes. Grid renders each section as a horizontal shelf of video cards, defaulting to Grid on tvOS and List on iOS/macOS. Per-platform defaults are preserved via a platform-specific settings key. On tvOS the shelf is a focus section so swiping up/down between rows of different lengths works without getting stuck at the end of a row. --- Yattee/Core/Settings/SettingsKey.swift | 3 +- .../Core/Settings/SettingsManager+Home.swift | 15 ++ Yattee/Core/SettingsManager.swift | 2 + Yattee/Localizable.xcstrings | 33 +++++ Yattee/Views/Home/HomeHorizontalCards.swift | 53 +++++++ Yattee/Views/Home/HomeItem.swift | 30 ++++ Yattee/Views/Home/HomeSettingsView.swift | 13 ++ Yattee/Views/Home/HomeView.swift | 135 +++++++++++++----- 8 files changed, 245 insertions(+), 39 deletions(-) create mode 100644 Yattee/Views/Home/HomeHorizontalCards.swift diff --git a/Yattee/Core/Settings/SettingsKey.swift b/Yattee/Core/Settings/SettingsKey.swift index ca15e088..5a96fb12 100644 --- a/Yattee/Core/Settings/SettingsKey.swift +++ b/Yattee/Core/Settings/SettingsKey.swift @@ -63,6 +63,7 @@ enum SettingsKey: String, CaseIterable { case homeSectionOrder case homeSectionVisibility case homeSectionItemsLimit + case homeSectionLayout // Tab Bar (compact size class) case tabBarItemOrder @@ -124,7 +125,7 @@ enum SettingsKey: String, CaseIterable { case .preferredQuality, .cellularQuality, .macPlayerMode, .listStyle, // Home layout — different UI paradigms per platform .homeShortcutOrder, .homeShortcutVisibility, .homeShortcutLayout, - .homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, + .homeSectionOrder, .homeSectionVisibility, .homeSectionItemsLimit, .homeSectionLayout, // Tab bar (compact size class) layout .tabBarItemOrder, .tabBarItemVisibility, .tabBarStartupTab, // Sidebar layout/selection diff --git a/Yattee/Core/Settings/SettingsManager+Home.swift b/Yattee/Core/Settings/SettingsManager+Home.swift index a0101310..d8e3b606 100644 --- a/Yattee/Core/Settings/SettingsManager+Home.swift +++ b/Yattee/Core/Settings/SettingsManager+Home.swift @@ -95,6 +95,21 @@ extension SettingsManager { } } + /// Layout mode for home sections (list or grid). Default is list on iOS/macOS, grid on tvOS. + var homeSectionLayout: HomeSectionLayout { + get { + if let cached = _homeSectionLayout { return cached } + guard let rawValue = string(for: .homeSectionLayout) else { + return HomeSectionLayout.platformDefault + } + return HomeSectionLayout(rawValue: rawValue) ?? HomeSectionLayout.platformDefault + } + set { + _homeSectionLayout = newValue + set(newValue.rawValue, for: .homeSectionLayout) + } + } + // MARK: - Home Section Settings /// Ordered list of home sections. Default order is bookmarks, history, downloads. diff --git a/Yattee/Core/SettingsManager.swift b/Yattee/Core/SettingsManager.swift index ddea5809..93b22044 100644 --- a/Yattee/Core/SettingsManager.swift +++ b/Yattee/Core/SettingsManager.swift @@ -107,6 +107,7 @@ final class SettingsManager { var _homeSectionOrder: [HomeSectionItem]? var _homeSectionVisibility: [HomeSectionItem: Bool]? var _homeSectionItemsLimit: Int? + var _homeSectionLayout: HomeSectionLayout? // Tab bar settings (compact size class only - iOS) var _tabBarItemOrder: [TabBarItem]? @@ -446,6 +447,7 @@ final class SettingsManager { _homeSectionOrder = nil _homeSectionVisibility = nil _homeSectionItemsLimit = nil + _homeSectionLayout = nil _tabBarItemOrder = nil _tabBarItemVisibility = nil _sidebarMainItemOrder = nil diff --git a/Yattee/Localizable.xcstrings b/Yattee/Localizable.xcstrings index cb17f6ef..69e7f5e3 100644 --- a/Yattee/Localizable.xcstrings +++ b/Yattee/Localizable.xcstrings @@ -4025,6 +4025,17 @@ } } }, + "home.settings.sections.layout" : { + "comment" : "Label for sections layout picker in library settings", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Display sections as" + } + } + } + }, "home.settings.shortcuts.layout" : { "comment" : "Label for shortcuts layout picker in library settings", "localizations" : { @@ -4036,6 +4047,28 @@ } } }, + "home.sections.layout.grid" : { + "comment" : "Grid layout option for library sections", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Grid" + } + } + } + }, + "home.sections.layout.list" : { + "comment" : "List layout option for library sections", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "List" + } + } + } + }, "home.settings.sourceDisabled" : { }, diff --git a/Yattee/Views/Home/HomeHorizontalCards.swift b/Yattee/Views/Home/HomeHorizontalCards.swift new file mode 100644 index 00000000..e25cdf86 --- /dev/null +++ b/Yattee/Views/Home/HomeHorizontalCards.swift @@ -0,0 +1,53 @@ +// +// HomeHorizontalCards.swift +// Yattee +// +// Horizontal shelf of video cards for Home sections in grid layout mode. +// + +import SwiftUI + +/// A horizontally scrolling row of `VideoCardView` cards used by Home sections +/// when `HomeSectionLayout.grid` is selected. +struct HomeHorizontalCards: View { + let videos: [Video] + let queueSource: QueueSource + let sourceLabel: String + var loadMoreVideos: LoadMoreVideosCallback? = nil + + #if os(tvOS) + private let cardWidth: CGFloat = 320 + private let cardHeight: CGFloat = 340 + private let spacing: CGFloat = 60 + private let verticalPadding: CGFloat = 28 + #else + private let cardWidth: CGFloat = 180 + private let cardHeight: CGFloat = 210 + private let spacing: CGFloat = 28 + private let verticalPadding: CGFloat = 8 + #endif + + var body: some View { + ScrollView(.horizontal, showsIndicators: false) { + LazyHStack(alignment: .top, spacing: spacing) { + ForEach(Array(videos.enumerated()), id: \.element.id) { index, video in + VideoCardView(video: video, isCompact: true) + .frame(width: cardWidth, height: cardHeight, alignment: .top) + .tappableVideo( + video, + queueSource: queueSource, + sourceLabel: sourceLabel, + videoList: videos, + videoIndex: index, + loadMoreVideos: loadMoreVideos + ) + } + } + .padding(.horizontal) + .padding(.vertical, verticalPadding) + #if os(tvOS) + .focusSection() + #endif + } + } +} diff --git a/Yattee/Views/Home/HomeItem.swift b/Yattee/Views/Home/HomeItem.swift index 159ec7c9..d6a25e57 100644 --- a/Yattee/Views/Home/HomeItem.swift +++ b/Yattee/Views/Home/HomeItem.swift @@ -30,6 +30,36 @@ enum HomeShortcutLayout: String, CaseIterable, Sendable { } } +// MARK: - Home Section Layout + +/// Layout mode for the configurable home sections (Continue Watching, Feed, etc.). +enum HomeSectionLayout: String, CaseIterable, Sendable { + case list + case grid + + var displayName: LocalizedStringKey { + switch self { + case .list: return "home.sections.layout.list" + case .grid: return "home.sections.layout.grid" + } + } + + var systemImage: String { + switch self { + case .list: return "list.bullet" + case .grid: return "square.grid.2x2" + } + } + + static var platformDefault: HomeSectionLayout { + #if os(tvOS) + return .grid + #else + return .list + #endif + } +} + // MARK: - Instance Content Type /// Content type for instance home items. diff --git a/Yattee/Views/Home/HomeSettingsView.swift b/Yattee/Views/Home/HomeSettingsView.swift index 20e2f658..1d31a9e2 100644 --- a/Yattee/Views/Home/HomeSettingsView.swift +++ b/Yattee/Views/Home/HomeSettingsView.swift @@ -17,6 +17,7 @@ struct HomeSettingsView: View { @State private var sectionOrder: [HomeSectionItem] = [] @State private var sectionVisibility: [HomeSectionItem: Bool] = [:] @State private var sectionItemsLimit: Int = 5 + @State private var sectionLayout: HomeSectionLayout = HomeSectionLayout.platformDefault // Available items (not yet added to Home) @State private var availableShortcutsByInstance: [(instance: Instance, cards: [HomeShortcutItem])] = [] @@ -125,6 +126,16 @@ struct HomeSettingsView: View { private var sectionsSection: some View { Section { + Picker(String(localized: "home.settings.sections.layout"), selection: $sectionLayout) { + ForEach(HomeSectionLayout.allCases, id: \.self) { layout in + Label(layout.displayName, systemImage: layout.systemImage) + .tag(layout) + } + } + #if !os(tvOS) + .pickerStyle(.segmented) + #endif + #if os(tvOS) ForEach(Array(sectionOrder.enumerated()), id: \.element.id) { index, section in if section != .downloads { @@ -257,6 +268,7 @@ struct HomeSettingsView: View { sectionOrder = settings.homeSectionOrder sectionVisibility = settings.homeSectionVisibility sectionItemsLimit = settings.homeSectionItemsLimit + sectionLayout = settings.homeSectionLayout // Load available items let instances = env.instancesManager.instances @@ -276,6 +288,7 @@ struct HomeSettingsView: View { settings.homeSectionOrder = sectionOrder settings.homeSectionVisibility = sectionVisibility settings.homeSectionItemsLimit = sectionItemsLimit + settings.homeSectionLayout = sectionLayout } // MARK: - Available Item Management diff --git a/Yattee/Views/Home/HomeView.swift b/Yattee/Views/Home/HomeView.swift index 7c02c126..3f2ca9a6 100644 --- a/Yattee/Views/Home/HomeView.swift +++ b/Yattee/Views/Home/HomeView.swift @@ -56,6 +56,11 @@ struct HomeView: View { settingsManager?.homeShortcutLayout ?? .cards } + /// The current layout for home sections (list or grid) + private var sectionLayout: HomeSectionLayout { + settingsManager?.homeSectionLayout ?? HomeSectionLayout.platformDefault + } + /// List style from centralized settings. private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset @@ -914,6 +919,14 @@ struct HomeView: View { appEnvironment?.navigationCoordinator.navigate(to: .continueWatching) } + if sectionLayout == .grid { + HomeHorizontalCards( + videos: videoList, + queueSource: continueWatchingQueueSource, + sourceLabel: String(localized: "queue.source.continueWatching"), + loadMoreVideos: loadMoreContinueWatchingCallback + ) + } else { VideoListContent(listStyle: listStyle) { ForEach(Array(limitedEntries.enumerated()), id: \.element.videoIdentifier) { index, entry in VideoListRow( @@ -952,6 +965,7 @@ struct HomeView: View { #endif } } + } } } @@ -963,26 +977,35 @@ struct HomeView: View { appEnvironment?.navigationCoordinator.navigate(to: .subscriptionsFeed) } - VideoListContent(listStyle: listStyle) { - ForEach(Array(limitedVideos.enumerated()), id: \.element.id) { index, video in - VideoListRow( - isLast: index == limitedVideos.count - 1, - rowStyle: .regular, - listStyle: listStyle - ) { - VideoRowView(video: video, style: .regular) - .tappableVideo( - video, - queueSource: feedQueueSource, - sourceLabel: String(localized: "queue.source.subscriptions"), - videoList: limitedVideos, - videoIndex: index, - loadMoreVideos: loadMoreFeedCallback - ) + if sectionLayout == .grid { + HomeHorizontalCards( + videos: limitedVideos, + queueSource: feedQueueSource, + sourceLabel: String(localized: "queue.source.subscriptions"), + loadMoreVideos: loadMoreFeedCallback + ) + } else { + VideoListContent(listStyle: listStyle) { + ForEach(Array(limitedVideos.enumerated()), id: \.element.id) { index, video in + VideoListRow( + isLast: index == limitedVideos.count - 1, + rowStyle: .regular, + listStyle: listStyle + ) { + VideoRowView(video: video, style: .regular) + .tappableVideo( + video, + queueSource: feedQueueSource, + sourceLabel: String(localized: "queue.source.subscriptions"), + videoList: limitedVideos, + videoIndex: index, + loadMoreVideos: loadMoreFeedCallback + ) + } + #if !os(tvOS) + .videoSwipeActions(video: video) + #endif } - #if !os(tvOS) - .videoSwipeActions(video: video) - #endif } } } @@ -997,6 +1020,14 @@ struct HomeView: View { appEnvironment?.navigationCoordinator.navigate(to: .bookmarks) } + if sectionLayout == .grid { + HomeHorizontalCards( + videos: videoList, + queueSource: recentBookmarksQueueSource, + sourceLabel: String(localized: "queue.source.bookmarks"), + loadMoreVideos: loadMoreRecentBookmarksCallback + ) + } else { VideoListContent(listStyle: listStyle) { ForEach(Array(limitedBookmarks.enumerated()), id: \.element.videoID) { index, bookmark in VideoListRow( @@ -1035,6 +1066,7 @@ struct HomeView: View { #endif } } + } } } @@ -1047,6 +1079,14 @@ struct HomeView: View { appEnvironment?.navigationCoordinator.navigate(to: .history) } + if sectionLayout == .grid { + HomeHorizontalCards( + videos: videoList, + queueSource: recentHistoryQueueSource, + sourceLabel: String(localized: "queue.source.history"), + loadMoreVideos: loadMoreRecentHistoryCallback + ) + } else { VideoListContent(listStyle: listStyle) { ForEach(Array(limitedHistory.enumerated()), id: \.element.videoID) { index, entry in VideoListRow( @@ -1085,6 +1125,7 @@ struct HomeView: View { #endif } } + } } } @@ -1100,6 +1141,14 @@ struct HomeView: View { appEnvironment?.navigationCoordinator.navigate(to: .downloads) } + if sectionLayout == .grid { + HomeHorizontalCards( + videos: videoList, + queueSource: .manual, + sourceLabel: String(localized: "queue.source.downloads"), + loadMoreVideos: loadMoreRecentDownloadsCallback + ) + } else { VideoListContent(listStyle: listStyle) { ForEach(Array(limitedDownloads.enumerated()), id: \.element.id) { index, download in VideoListRow( @@ -1139,6 +1188,7 @@ struct HomeView: View { ) } } + } } } #endif @@ -1172,26 +1222,35 @@ struct HomeView: View { .padding(.bottom, 8) .frame(maxWidth: .infinity, alignment: .leading) - VideoListContent(listStyle: listStyle) { - ForEach(Array(limitedVideos.enumerated()), id: \.element.id) { index, video in - VideoListRow( - isLast: index == limitedVideos.count - 1, - rowStyle: .regular, - listStyle: listStyle - ) { - VideoRowView(video: video, style: .regular) - .tappableVideo( - video, - queueSource: instanceQueueSource(instanceID: instanceID, contentType: contentType), - sourceLabel: contentType.localizedTitle, - videoList: limitedVideos, - videoIndex: index, - loadMoreVideos: loadMoreInstanceContentCallback - ) + if sectionLayout == .grid { + HomeHorizontalCards( + videos: limitedVideos, + queueSource: instanceQueueSource(instanceID: instanceID, contentType: contentType), + sourceLabel: contentType.localizedTitle, + loadMoreVideos: loadMoreInstanceContentCallback + ) + } else { + VideoListContent(listStyle: listStyle) { + ForEach(Array(limitedVideos.enumerated()), id: \.element.id) { index, video in + VideoListRow( + isLast: index == limitedVideos.count - 1, + rowStyle: .regular, + listStyle: listStyle + ) { + VideoRowView(video: video, style: .regular) + .tappableVideo( + video, + queueSource: instanceQueueSource(instanceID: instanceID, contentType: contentType), + sourceLabel: contentType.localizedTitle, + videoList: limitedVideos, + videoIndex: index, + loadMoreVideos: loadMoreInstanceContentCallback + ) + } + #if !os(tvOS) + .videoSwipeActions(video: video) + #endif } - #if !os(tvOS) - .videoSwipeActions(video: video) - #endif } } }