mirror of
https://github.com/yattee/yattee.git
synced 2026-05-12 18:35:05 +00:00
Use two-column layout for tvOS video info view
Reworks VideoInfoView on tvOS into a persistent 30% left sidebar (thumbnail, title, channel, Play / Add to Playlist / Bookmark) with a scrollable right pane for description, stats, comments, related, and watch history. Reuses the player's TVScrollableDescription (refactored to self-manage focus) so the description supports click-to-lock scrolling, and the outer ScrollView is disabled while locked. Comments full-screen on tvOS, with commenter avatars no longer tappable and accent-colored link text replaced with the default foreground.
This commit is contained in:
@@ -67,9 +67,20 @@ struct CommentView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var authorAvatar: some View {
|
private var authorAvatar: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
authorAvatarImage
|
||||||
|
#else
|
||||||
Button {
|
Button {
|
||||||
navigateToChannel()
|
navigateToChannel()
|
||||||
} label: {
|
} label: {
|
||||||
|
authorAvatarImage
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var authorAvatarImage: some View {
|
||||||
LazyImage(url: comment.author.thumbnailURL) { state in
|
LazyImage(url: comment.author.thumbnailURL) { state in
|
||||||
if let image = state.image {
|
if let image = state.image {
|
||||||
image
|
image
|
||||||
@@ -89,8 +100,6 @@ struct CommentView: View {
|
|||||||
.frame(width: 32, height: 32)
|
.frame(width: 32, height: 32)
|
||||||
.clipShape(Circle())
|
.clipShape(Circle())
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
|
||||||
}
|
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var authorInfo: some View {
|
private var authorInfo: some View {
|
||||||
@@ -178,7 +187,9 @@ struct CommentView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
}
|
}
|
||||||
|
#if !os(tvOS)
|
||||||
.foregroundStyle(accentColor)
|
.foregroundStyle(accentColor)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 44) // Align with comment content (avatar width + spacing)
|
.padding(.leading, 44) // Align with comment content (avatar width + spacing)
|
||||||
@@ -213,7 +224,9 @@ struct CommentView: View {
|
|||||||
Text(String(localized: "comments.loadMoreReplies"))
|
Text(String(localized: "comments.loadMoreReplies"))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.fontWeight(.medium)
|
.fontWeight(.medium)
|
||||||
|
#if !os(tvOS)
|
||||||
.foregroundStyle(accentColor)
|
.foregroundStyle(accentColor)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.leading, 44)
|
.padding(.leading, 44)
|
||||||
|
|||||||
@@ -134,7 +134,6 @@ struct TVDetailsPanel: View {
|
|||||||
if let description = video?.description, !description.isEmpty {
|
if let description = video?.description, !description.isEmpty {
|
||||||
TVScrollableDescription(
|
TVScrollableDescription(
|
||||||
description: description,
|
description: description,
|
||||||
focusedItem: $focusedItem,
|
|
||||||
isScrollLocked: $isDescriptionScrollLocked
|
isScrollLocked: $isDescriptionScrollLocked
|
||||||
)
|
)
|
||||||
.padding(.top, isDescriptionScrollLocked ? 24 : 8)
|
.padding(.top, isDescriptionScrollLocked ? 24 : 8)
|
||||||
@@ -309,17 +308,13 @@ enum TVDetailsFocusItem: Hashable {
|
|||||||
/// When locked, expands to fill available space for easier reading.
|
/// When locked, expands to fill available space for easier reading.
|
||||||
struct TVScrollableDescription: View {
|
struct TVScrollableDescription: View {
|
||||||
let description: String
|
let description: String
|
||||||
@FocusState.Binding var focusedItem: TVDetailsFocusItem?
|
|
||||||
@Binding var isScrollLocked: Bool
|
@Binding var isScrollLocked: Bool
|
||||||
|
|
||||||
|
@FocusState private var isFocused: Bool
|
||||||
@State private var scrollOffset: CGFloat = 0
|
@State private var scrollOffset: CGFloat = 0
|
||||||
private let scrollStep: CGFloat = 80
|
private let scrollStep: CGFloat = 80
|
||||||
private let maxScroll: CGFloat = 5000
|
private let maxScroll: CGFloat = 5000
|
||||||
|
|
||||||
private var isFocused: Bool {
|
|
||||||
focusedItem == .description
|
|
||||||
}
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
// Toggle scroll lock on click/select
|
// Toggle scroll lock on click/select
|
||||||
@@ -333,7 +328,7 @@ struct TVScrollableDescription: View {
|
|||||||
descriptionContent
|
descriptionContent
|
||||||
}
|
}
|
||||||
.buttonStyle(TVDescriptionButtonStyle(isFocused: isFocused, isLocked: isScrollLocked))
|
.buttonStyle(TVDescriptionButtonStyle(isFocused: isFocused, isLocked: isScrollLocked))
|
||||||
.focused($focusedItem, equals: .description)
|
.focused($isFocused)
|
||||||
.onMoveCommand { direction in
|
.onMoveCommand { direction in
|
||||||
guard isScrollLocked else { return }
|
guard isScrollLocked else { return }
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ struct VideoInfoView: View {
|
|||||||
@State private var isEditingBookmarkNote = false
|
@State private var isEditingBookmarkNote = false
|
||||||
@State private var isEditingBookmarkTags = false
|
@State private var isEditingBookmarkTags = false
|
||||||
@FocusState private var isBookmarkNoteFocused: Bool
|
@FocusState private var isBookmarkNoteFocused: Bool
|
||||||
|
#if os(tvOS)
|
||||||
|
@FocusState private var isPlayFocused: Bool
|
||||||
|
@State private var isDescriptionScrollLocked = false
|
||||||
|
#endif
|
||||||
|
|
||||||
// Comments state (independent from PlayerState)
|
// Comments state (independent from PlayerState)
|
||||||
@State private var comments: [Comment] = []
|
@State private var comments: [Comment] = []
|
||||||
@@ -258,7 +262,11 @@ struct VideoInfoView: View {
|
|||||||
.task {
|
.task {
|
||||||
await loadInitialVideoIfNeeded()
|
await loadInitialVideoIfNeeded()
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.navigationTitle("")
|
||||||
|
#else
|
||||||
.navigationTitle(displayTitle)
|
.navigationTitle(displayTitle)
|
||||||
|
#endif
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
@@ -278,6 +286,9 @@ struct VideoInfoView: View {
|
|||||||
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
||||||
}
|
}
|
||||||
loadComments()
|
loadComments()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isPlayFocused = true
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Load full video details from API
|
// Load full video details from API
|
||||||
@@ -322,6 +333,9 @@ struct VideoInfoView: View {
|
|||||||
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
watchEntry = dataManager?.watchEntry(for: video.id.videoID)
|
||||||
}
|
}
|
||||||
loadComments()
|
loadComments()
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
isPlayFocused = true
|
||||||
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
// Load full video details from API
|
// Load full video details from API
|
||||||
@@ -334,9 +348,15 @@ struct VideoInfoView: View {
|
|||||||
PlaylistSelectorSheet(video: video)
|
PlaylistSelectorSheet(video: video)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.fullScreenCover(isPresented: $showingCommentsSheet) {
|
||||||
|
commentsSheetContent
|
||||||
|
}
|
||||||
|
#else
|
||||||
.sheet(isPresented: $showingCommentsSheet) {
|
.sheet(isPresented: $showingCommentsSheet) {
|
||||||
commentsSheetContent
|
commentsSheetContent
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
.sheet(item: $resumeSheetData) { data in
|
.sheet(item: $resumeSheetData) { data in
|
||||||
ResumeActionSheet(
|
ResumeActionSheet(
|
||||||
video: data.video,
|
video: data.video,
|
||||||
@@ -393,6 +413,16 @@ struct VideoInfoView: View {
|
|||||||
/// Main video content view (shown after video is loaded).
|
/// Main video content view (shown after video is loaded).
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var videoContent: some View {
|
private var videoContent: some View {
|
||||||
|
#if os(tvOS)
|
||||||
|
tvOSVideoContent
|
||||||
|
#else
|
||||||
|
iOSVideoContent
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
|
@ViewBuilder
|
||||||
|
private var iOSVideoContent: some View {
|
||||||
GeometryReader { geometry in
|
GeometryReader { geometry in
|
||||||
let safeAreaTop = geometry.safeAreaInsets.top
|
let safeAreaTop = geometry.safeAreaInsets.top
|
||||||
|
|
||||||
@@ -504,6 +534,204 @@ struct VideoInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(tvOS)
|
||||||
|
// MARK: - tvOS Two-Column Layout
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var tvOSVideoContent: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
let leftWidth = geometry.size.width * 0.30
|
||||||
|
HStack(alignment: .top, spacing: 40) {
|
||||||
|
tvOSLeftColumn
|
||||||
|
.frame(width: leftWidth, alignment: .leading)
|
||||||
|
.focusSection()
|
||||||
|
|
||||||
|
tvOSRightColumn
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
||||||
|
.focusSection()
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 60)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tvOSLeftColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 24) {
|
||||||
|
if let video = displayedVideo {
|
||||||
|
tvOSThumbnail(for: video)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.aspectRatio(16.0 / 9.0, contentMode: .fit)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||||
|
|
||||||
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
||||||
|
.font(.title3)
|
||||||
|
.fontWeight(.semibold)
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
tvOSChannelRow(for: video)
|
||||||
|
|
||||||
|
Button(action: playVideo) {
|
||||||
|
Label(playButtonLabel, systemImage: "play.fill")
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 60)
|
||||||
|
.font(.headline)
|
||||||
|
}
|
||||||
|
.buttonStyle(.borderedProminent)
|
||||||
|
.focused($isPlayFocused)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
showingPlaylistSheet = true
|
||||||
|
} label: {
|
||||||
|
Label(String(localized: "video.context.addToPlaylist"), systemImage: "text.badge.plus")
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
|
||||||
|
Button {
|
||||||
|
toggleBookmark()
|
||||||
|
} label: {
|
||||||
|
Label(
|
||||||
|
isBookmarked ? String(localized: "video.removeBookmark") : String(localized: "video.bookmark"),
|
||||||
|
systemImage: isBookmarked ? "bookmark.fill" : "bookmark"
|
||||||
|
)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 50)
|
||||||
|
}
|
||||||
|
.buttonStyle(.bordered)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tvOSThumbnail(for video: Video) -> some View {
|
||||||
|
let deArrowURL = appEnvironment?.deArrowBrandingProvider.thumbnailURL(for: video)
|
||||||
|
let thumbnailURL = deArrowURL ?? video.bestThumbnail?.url
|
||||||
|
|
||||||
|
LazyImage(url: thumbnailURL) { state in
|
||||||
|
if let image = state.image {
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(16.0 / 9.0, contentMode: .fill)
|
||||||
|
} else {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.quaternary)
|
||||||
|
.overlay {
|
||||||
|
if video.bestThumbnail?.url == nil, video.isFromMediaSource {
|
||||||
|
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
|
||||||
|
.font(.caption)
|
||||||
|
.fontWeight(.medium)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
.lineLimit(3)
|
||||||
|
.padding(8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func tvOSChannelRow(for video: Video) -> some View {
|
||||||
|
if video.author.hasRealChannelInfo {
|
||||||
|
Button {
|
||||||
|
navigationCoordinator?.navigateToChannel(for: video)
|
||||||
|
} label: {
|
||||||
|
channelRowContent(for: video)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
} else {
|
||||||
|
channelRowContent(for: video)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tvOSRightColumn: some View {
|
||||||
|
GeometryReader { geometry in
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
if !isDescriptionScrollLocked, isBookmarked, let bookmark = currentBookmark {
|
||||||
|
bookmarkDetailsSection(bookmark)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isLoadingVideoDetails {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView()
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.frame(height: 450)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
} else if let description = displayedVideo?.description, !description.isEmpty {
|
||||||
|
TVScrollableDescription(
|
||||||
|
description: description,
|
||||||
|
isScrollLocked: $isDescriptionScrollLocked
|
||||||
|
)
|
||||||
|
.frame(height: isDescriptionScrollLocked ? geometry.size.height : 450)
|
||||||
|
.padding(.horizontal)
|
||||||
|
|
||||||
|
if !isDescriptionScrollLocked {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isDescriptionScrollLocked {
|
||||||
|
if shouldShowOriginalTitleSection {
|
||||||
|
originalTitleSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldShowStatsSection {
|
||||||
|
statsSection
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
|
||||||
|
commentsSection
|
||||||
|
|
||||||
|
if let relatedVideos = displayedVideo?.relatedVideos, !relatedVideos.isEmpty {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
|
||||||
|
relatedVideosSection(relatedVideos)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let entry = watchEntry {
|
||||||
|
Divider()
|
||||||
|
.padding(.horizontal)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
|
||||||
|
watchHistorySection(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
.id(currentVideoIndex)
|
||||||
|
.animation(.easeInOut(duration: 0.25), value: isDescriptionScrollLocked)
|
||||||
|
}
|
||||||
|
.scrollClipDisabled()
|
||||||
|
.scrollDisabled(isDescriptionScrollLocked)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Error view shown when video fails to load (videoID init mode only).
|
/// Error view shown when video fails to load (videoID init mode only).
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -1426,8 +1654,13 @@ struct VideoInfoView: View {
|
|||||||
// MARK: - Related Videos Section
|
// MARK: - Related Videos Section
|
||||||
|
|
||||||
private func relatedVideosSection(_ videos: [Video]) -> some View {
|
private func relatedVideosSection(_ videos: [Video]) -> some View {
|
||||||
CollapsibleSection(title: String(localized: "videoInfo.section.relatedVideos"), isExpanded: $isRelatedExpanded) {
|
#if os(tvOS)
|
||||||
LazyVStack(spacing: 12) {
|
let rowSpacing: CGFloat = 32
|
||||||
|
#else
|
||||||
|
let rowSpacing: CGFloat = 12
|
||||||
|
#endif
|
||||||
|
return CollapsibleSection(title: String(localized: "videoInfo.section.relatedVideos"), isExpanded: $isRelatedExpanded) {
|
||||||
|
LazyVStack(spacing: rowSpacing) {
|
||||||
ForEach(Array(videos.enumerated()), id: \.element.id) { index, relatedVideo in
|
ForEach(Array(videos.enumerated()), id: \.element.id) { index, relatedVideo in
|
||||||
VideoRowView(video: relatedVideo, style: .regular)
|
VideoRowView(video: relatedVideo, style: .regular)
|
||||||
.tappableVideo(
|
.tappableVideo(
|
||||||
@@ -1442,10 +1675,12 @@ struct VideoInfoView: View {
|
|||||||
.videoSwipeActions(video: relatedVideo)
|
.videoSwipeActions(video: relatedVideo)
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if !os(tvOS)
|
||||||
if index < videos.count - 1 {
|
if index < videos.count - 1 {
|
||||||
Divider()
|
Divider()
|
||||||
.padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12)
|
.padding(.leading, VideoRowStyle.regular.thumbnailWidth + 12)
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1530,10 +1765,16 @@ struct VideoInfoView: View {
|
|||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
}
|
}
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
|
#if !os(tvOS)
|
||||||
.foregroundStyle(accentColor)
|
.foregroundStyle(accentColor)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
#if os(tvOS)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
#else
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1578,10 +1819,14 @@ struct VideoInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#if os(tvOS)
|
||||||
|
.background(Color.black.ignoresSafeArea())
|
||||||
|
#endif
|
||||||
.navigationTitle(String(localized: "videoInfo.section.comments"))
|
.navigationTitle(String(localized: "videoInfo.section.comments"))
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
#endif
|
#endif
|
||||||
|
#if !os(tvOS)
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(role: .cancel) {
|
Button(role: .cancel) {
|
||||||
@@ -1592,6 +1837,7 @@ struct VideoInfoView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user