Files
yattee/Yattee/Views/Components/VideoRowView.swift
Arkadiusz Fal c289e7576e Add per-page element customization and panel settings polish
Phase 5 of the player panels redesign:
- Per-page element toggles now rendered and editable for every page:
  comments (author avatars), related (thumbnails), chapters (thumbnails),
  queue (previously played section), tracks (video/audio/subtitles/speed
  sections)
- Page bar labels toggle (icons-only bar shows the selected page's name)
- Layout mode schematic diagram in the details panel settings
- NEW badge machinery for future page types (versionAdded on PanelPageType)
- Typed per-page settings accessors on PanelPageConfiguration and VM update
  helpers for each page kind
- Unit tests for per-page settings round-trip and accessor fallbacks

The standalone quality selector sheet is unaffected: section gating defaults
to everything visible and only the embedded tracks page passes preset
settings through.
2026-09-22 09:37:10 +02:00

218 lines
7.1 KiB
Swift

//
// VideoRowView.swift
// Yattee
//
// A video row for list layouts.
//
import SwiftUI
/// A video row for list layouts.
///
/// Automatically handles DeArrow integration for titles and thumbnails.
/// Download status is automatically shown from the download manager.
/// On iOS/macOS, supports configurable tap zones for thumbnail and text area.
struct VideoRowView: View {
@Environment(\.appEnvironment) private var appEnvironment
@Environment(\.videoQueueContext) private var videoQueueContext
let video: Video
var style: VideoRowStyle = .regular
var watchProgress: Double? = nil
var showSourceBadge: Bool = false
/// Custom metadata text to show instead of views/date (e.g., remaining time).
var customMetadata: String? = nil
/// Optional index number to display before thumbnail (for playlists).
var index: Int? = nil
/// When true, disables internal tap handling so parent view can handle all taps.
var disableInternalTapHandling: Bool = false
/// When false, hides the thumbnail (e.g. details panel related page setting).
var showThumbnail: Bool = true
// Platform-specific fonts
#if os(tvOS)
private var titleFont: Font {
style == .compact ? .caption : .body
}
private var authorFont: Font {
style == .compact ? .caption2 : .subheadline
}
private var metadataFont: Font {
style == .compact ? .caption2 : .caption
}
#elseif os(macOS)
private var titleFont: Font {
style == .compact ? .subheadline : .body
}
private var authorFont: Font {
style == .compact ? .caption : .subheadline
}
private var metadataFont: Font {
style == .compact ? .caption2 : .caption
}
#else
private var titleFont: Font {
.subheadline
}
private var authorFont: Font {
style == .compact ? .caption2 : .caption
}
private var metadataFont: Font {
style == .compact ? .caption2 : .caption2
}
#endif
#if !os(tvOS)
private var thumbnailTapAction: VideoTapAction {
appEnvironment?.settingsManager.thumbnailTapAction ?? .playVideo
}
private var textAreaTapAction: VideoTapAction {
appEnvironment?.settingsManager.textAreaTapAction ?? .openInfo
}
#endif
private var accessibilityLabelText: String {
var parts = [video.displayTitle(using: appEnvironment?.deArrowBrandingProvider), video.author.name]
let duration = video.formattedDuration
if !duration.isEmpty {
parts.append(duration)
}
return parts.joined(separator: ", ")
}
var body: some View {
HStack(alignment: .top, spacing: 12) {
// Index number (for playlists)
if let index {
Text("\(index)")
.font(.subheadline)
.foregroundStyle(.secondary)
#if os(tvOS)
.lineLimit(1)
.frame(width: 60, alignment: .trailing)
#else
.frame(width: 32)
#endif
}
// Thumbnail
if showThumbnail {
thumbnailView
.zoomTransitionSource(id: video.id)
}
// Info
infoView
}
.contentShape(Rectangle())
.accessibilityIdentifier("video.row.\(video.id.videoID)")
.accessibilityLabel(accessibilityLabelText)
}
@ViewBuilder
private var thumbnailView: some View {
DeArrowVideoThumbnail(
video: video,
cornerRadius: style == .compact ? 4 : style == .regular ? 6 : 8,
watchProgress: watchProgress,
duration: style == .compact ? nil : video.formattedDuration
)
.frame(width: style.thumbnailWidth, height: style.thumbnailHeight)
#if !os(tvOS)
.contentShape(Rectangle())
.if(thumbnailTapAction != .playVideo && !disableInternalTapHandling) { view in
view.highPriorityGesture(
TapGesture().onEnded {
handleTapAction(thumbnailTapAction)
}
)
}
#endif
}
@ViewBuilder
private var infoView: some View {
VStack(alignment: .leading, spacing: 2) {
Text(video.displayTitle(using: appEnvironment?.deArrowBrandingProvider))
.font(titleFont)
.lineLimit(style == .large ? 3 : style == .regular ? 2 : 1)
HStack(spacing: 4) {
if style == .compact {
// In compact mode, show author and duration inline with dot separator
let duration = video.formattedDuration
if !duration.isEmpty {
Text("\(video.author.name) · \(duration)")
.font(authorFont)
.foregroundStyle(.secondary)
} else {
Text(video.author.name)
.font(authorFont)
.foregroundStyle(.secondary)
}
} else {
Text(video.author.name)
.font(authorFont)
.foregroundStyle(.secondary)
}
if showSourceBadge {
SourceBadge(source: video.id.source)
}
}
if style != .compact {
if let custom = customMetadata {
Text(custom)
.font(metadataFont)
.foregroundStyle(.tertiary)
} else if video.viewCount != nil || video.formattedPublishedDate != nil {
VideoMetadataLine(viewCount: video.formattedViewCount, publishedText: video.formattedPublishedDate)
.font(metadataFont)
.foregroundStyle(.tertiary)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
#if !os(tvOS)
.contentShape(Rectangle())
.if(textAreaTapAction != .playVideo && !disableInternalTapHandling) { view in
view.highPriorityGesture(
TapGesture().onEnded {
handleTapAction(textAreaTapAction)
}
)
}
#endif
}
#if !os(tvOS)
private func handleTapAction(_ action: VideoTapAction) {
guard let env = appEnvironment else { return }
switch action {
case .playVideo:
// This should not be called since we only add gesture for non-playVideo actions
// The .tappableVideo() modifier will handle playVideo actions
break
case .openInfo:
env.navigationCoordinator.navigate(to: .video(.loaded(video), queueContext: videoQueueContext))
case .none:
break
}
}
#endif
}
// MARK: - Preview
#Preview("Row") {
VideoRowView(
video: .preview,
watchProgress: 0.6,
showSourceBadge: true
)
.padding()
}