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.
This commit is contained in:
Arkadiusz Fal
2026-04-15 17:54:18 +02:00
parent 758f4a678d
commit eb697b7bbc
8 changed files with 245 additions and 39 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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