mirror of
https://github.com/yattee/yattee.git
synced 2026-10-24 05:32:53 +00:00
Phase 0 of the panels redesign, extraction only, no behavior changes: - Unify comments loading on PlayerState: loadComments/loadMoreComments become the single loader, used by both the player sheet preload and the comments overlay (which previously had its own duplicate copy). - Extract PanelResizeGrabber from FloatingDetailsPanel into the new Views/Player/Panels/ directory. - Extract the shared queue list from QueueManagementSheet into QueueListView plus QueuePlaybackActions, reused by the tvOS panel. - Remove the no-op wideLayoutPanelAlignment accessor from the settings view model; the player reads the panel side from SettingsManager, so the preset field was dead. The Codable field stays so old presets decode.
232 lines
7.8 KiB
Swift
232 lines
7.8 KiB
Swift
//
|
|
// ExpandedPlayerSheet+VideoInfo.swift
|
|
// Yattee
|
|
//
|
|
// Video info, description, and comments functionality for the expanded player sheet.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
|
|
extension ExpandedPlayerSheet {
|
|
// MARK: - Scroll & Comments
|
|
|
|
/// Scrolls the content to the top.
|
|
func scrollToTop() {
|
|
withAnimation(.easeOut(duration: 0.25)) {
|
|
scrollPosition.scrollTo(y: 0)
|
|
}
|
|
}
|
|
|
|
/// Expands the comments overlay.
|
|
func expandComments() {
|
|
// Scroll player to top so video is fully visible
|
|
scrollPosition.scrollTo(y: 0)
|
|
// Use same animation as player sheet expand (0.3s, no bounce)
|
|
withAnimation(.smooth(duration: 0.3)) {
|
|
isCommentsExpanded = true
|
|
}
|
|
}
|
|
|
|
/// Collapses the comments overlay.
|
|
func collapseComments() {
|
|
navigationCoordinator?.commentsFrame = .zero
|
|
// Use same animation as player sheet dismiss (0.3s, no bounce)
|
|
withAnimation(.smooth(duration: 0.3)) {
|
|
isCommentsExpanded = false
|
|
commentsDismissOffset = 0
|
|
}
|
|
}
|
|
|
|
/// Starts preloading comments, cancelling any in-flight task.
|
|
func startPreloadingComments() {
|
|
commentsPreloadTask?.cancel()
|
|
commentsPreloadTask = Task { await preloadComments() }
|
|
}
|
|
|
|
/// Cancels any in-flight comments preload task.
|
|
func cancelCommentsPreload() {
|
|
commentsPreloadTask?.cancel()
|
|
commentsPreloadTask = nil
|
|
}
|
|
|
|
/// Handles comments dismiss offset during drag.
|
|
func handleCommentsDismissOffset(_ offset: CGFloat) {
|
|
// Don't apply real-time offset during drag - it causes feedback loop
|
|
// with scroll geometry. The scroll view bounces naturally with iOS rubber-banding.
|
|
}
|
|
|
|
/// Handles the end of comments dismiss gesture.
|
|
func handleCommentsDismissGestureEnded(_ finalOffset: CGFloat) {
|
|
let dismissThreshold: CGFloat = 30
|
|
if finalOffset >= dismissThreshold {
|
|
collapseComments()
|
|
}
|
|
// Below threshold - scroll view will rubber-band back naturally
|
|
}
|
|
|
|
/// Preloads comments for the current video.
|
|
func preloadComments() async {
|
|
guard let playerState, playerState.commentsState == .idle else { return }
|
|
|
|
// Check if comments pill is disabled in preset settings
|
|
let pillSettings = playerControlsLayout.effectivePlayerPillSettings
|
|
guard pillSettings.shouldLoadComments else {
|
|
playerState.commentsState = .disabled
|
|
return
|
|
}
|
|
|
|
guard let contentService = appEnvironment?.contentService,
|
|
let instancesManager = appEnvironment?.instancesManager else { return }
|
|
|
|
await playerState.loadComments(contentService: contentService, instancesManager: instancesManager)
|
|
}
|
|
|
|
// MARK: - DeArrow Title Helpers
|
|
|
|
/// Returns the DeArrow title if available and enabled.
|
|
func deArrowTitle(for video: Video) -> String? {
|
|
appEnvironment?.deArrowBrandingProvider.title(for: video)
|
|
}
|
|
|
|
/// Returns the display title based on toggle state.
|
|
/// Shows DeArrow title by default when available, original when toggled.
|
|
func displayTitle(for video: Video) -> String {
|
|
if let deArrow = deArrowTitle(for: video) {
|
|
return showOriginalTitle ? video.title : deArrow
|
|
}
|
|
return video.title
|
|
}
|
|
|
|
/// Whether the title can be toggled (DeArrow title is available).
|
|
func canToggleTitle(for video: Video) -> Bool {
|
|
deArrowTitle(for: video) != nil
|
|
}
|
|
|
|
// MARK: - Video Info Views
|
|
|
|
/// Video info section with title, stats, and channel.
|
|
@ViewBuilder
|
|
func videoInfo(_ video: Video) -> some View {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// Title - full width
|
|
Text(displayTitle(for: video))
|
|
.font(.title3)
|
|
.fontWeight(.semibold)
|
|
.lineLimit(3)
|
|
.onTapGesture {
|
|
if canToggleTitle(for: video) {
|
|
showOriginalTitle.toggle()
|
|
}
|
|
}
|
|
|
|
// Stats row - only show for non-media-source videos
|
|
if !video.isFromMediaSource {
|
|
VideoStatsRow(
|
|
playerState: playerState,
|
|
showFormattedDate: $showFormattedDate,
|
|
returnYouTubeDislikeEnabled: appEnvironment?.settingsManager.returnYouTubeDislikeEnabled ?? false
|
|
)
|
|
}
|
|
|
|
// Channel row with context menu
|
|
VideoChannelRow(
|
|
author: video.author,
|
|
source: video.authorSource,
|
|
yatteeServerURL: yatteeServerURL,
|
|
onChannelTap: video.author.hasRealChannelInfo ? {
|
|
navigationCoordinator?.navigateToChannel(for: video, collapsePlayer: true)
|
|
} : nil,
|
|
video: video,
|
|
accentColor: accentColor,
|
|
showSubscriberCount: !video.isFromMediaSource,
|
|
isLoadingDetails: playerState?.videoDetailsState == .loading
|
|
)
|
|
}
|
|
.padding()
|
|
}
|
|
|
|
/// Returns the first enabled Yattee Server instance URL, if any.
|
|
var yatteeServerURL: URL? {
|
|
appEnvironment?.instancesManager.yatteeServerInstances.first { $0.isEnabled }?.url
|
|
}
|
|
|
|
// MARK: - Info Tab Section
|
|
|
|
/// Info tab section with video description.
|
|
@ViewBuilder
|
|
func infoTabSection(_ video: Video) -> some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// Description only (no picker)
|
|
descriptionContent(video.description ?? "")
|
|
|
|
// Extra space at bottom so content can scroll above the comments pill
|
|
Spacer()
|
|
.frame(height: 80)
|
|
}
|
|
.padding(.vertical)
|
|
}
|
|
|
|
/// Description content view.
|
|
@ViewBuilder
|
|
func descriptionContent(_ description: String) -> some View {
|
|
let isLoadingDetails = playerState?.videoDetailsState == .loading
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if !description.isEmpty {
|
|
Text(DescriptionText.attributed(description, linkColor: accentColor))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.tint(accentColor)
|
|
.padding(.horizontal)
|
|
.handleTimestampLinks(using: playerService)
|
|
} else if isLoadingDetails {
|
|
HStack {
|
|
Spacer()
|
|
ProgressView()
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
} else {
|
|
Text(String(localized: "player.noDescription"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
/// Closes the current video and dismisses the player.
|
|
func closeVideo() {
|
|
// Mark as closing to hide tab accessory before dismissal
|
|
playerState?.isClosingVideo = true
|
|
|
|
// Clear the queue when closing video
|
|
appEnvironment?.queueManager.clearQueue()
|
|
|
|
// Reset panel state when closing player
|
|
appEnvironment?.settingsManager.landscapeDetailsPanelVisible = false
|
|
appEnvironment?.settingsManager.landscapeDetailsPanelPinned = false
|
|
|
|
// Stop player FIRST before dismissing window
|
|
// This ensures MPVRenderView and backend are properly cleaned up
|
|
// before the window's content view hierarchy is destroyed
|
|
playerService?.stop()
|
|
|
|
// Then dismiss player window (after backend is stopped)
|
|
navigationCoordinator?.isPlayerExpanded = false
|
|
}
|
|
|
|
/// Switches to a different stream.
|
|
func switchToStream(_ stream: Stream, audioStream: Stream? = nil) {
|
|
Task {
|
|
await playerService?.selectStreamManually(stream, audioStream: audioStream)
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|