mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" : {
|
||||
|
||||
},
|
||||
|
||||
53
Yattee/Views/Home/HomeHorizontalCards.swift
Normal file
53
Yattee/Views/Home/HomeHorizontalCards.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user