mirror of
https://github.com/yattee/yattee.git
synced 2026-05-13 10:55:03 +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:
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