From 8bb613741722a8794b84aa75377429341f5a7d0e Mon Sep 17 00:00:00 2001 From: Arkadiusz Fal Date: Mon, 21 Sep 2026 23:04:42 +0200 Subject: [PATCH] Lay groundwork for the player panels redesign 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. --- .../PlayerControls/PlayerControlsLayout.swift | 12 +- Yattee/Services/Player/PlayerState.swift | 69 +++++ .../PlayerControlsSettingsViewModel.swift | 23 -- .../Components/ExpandedCommentsView.swift | 65 +---- .../ExpandedPlayerSheet+VideoInfo.swift | 40 +-- .../Views/Player/FloatingDetailsPanel.swift | 100 ------- .../Player/Panels/PanelResizeGrabber.swift | 110 ++++++++ .../Views/Player/Panels/QueueListView.swift | 253 ++++++++++++++++++ .../Views/Player/QueueManagementSheet.swift | 235 +--------------- 9 files changed, 448 insertions(+), 459 deletions(-) create mode 100644 Yattee/Views/Player/Panels/PanelResizeGrabber.swift create mode 100644 Yattee/Views/Player/Panels/QueueListView.swift diff --git a/Yattee/Models/PlayerControls/PlayerControlsLayout.swift b/Yattee/Models/PlayerControls/PlayerControlsLayout.swift index f35ba253..1eebc296 100644 --- a/Yattee/Models/PlayerControls/PlayerControlsLayout.swift +++ b/Yattee/Models/PlayerControls/PlayerControlsLayout.swift @@ -33,8 +33,9 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { /// Mini player settings. Optional for backward compatibility. var miniPlayerSettings: MiniPlayerSettings? - /// Wide layout panel alignment. Optional for backward compatibility. - /// When nil, uses the global setting from SettingsManager. + /// Unused: the player reads the panel side from SettingsManager + /// (`floatingDetailsPanelSide`). Kept only so old presets that saved this + /// field still decode. var wideLayoutPanelAlignment: FloatingPanelSide? @@ -91,13 +92,6 @@ struct PlayerControlsLayout: Codable, Hashable, Sendable { miniPlayerSettings ?? .default } - // MARK: - Wide Layout Panel Settings - - /// Returns the effective wide layout panel alignment, using right as default if not set. - var effectiveWideLayoutPanelAlignment: FloatingPanelSide { - wideLayoutPanelAlignment ?? .left - } - // MARK: - Defaults /// Default layout matching the current hardcoded player controls. diff --git a/Yattee/Services/Player/PlayerState.swift b/Yattee/Services/Player/PlayerState.swift index 6fc1828a..b753d87f 100644 --- a/Yattee/Services/Player/PlayerState.swift +++ b/Yattee/Services/Player/PlayerState.swift @@ -540,6 +540,75 @@ final class PlayerState { commentsContinuation = nil } + // MARK: - Comments Loading + + /// Loads the first page of comments for the current video. + /// Single loader shared by the player sheet preload and the comments overlay retry. + func loadComments(contentService: ContentService, instancesManager: InstancesManager) async { + guard let video = currentVideo else { return } + + // Don't load comments for non-YouTube videos + guard video.supportsComments else { + commentsState = .disabled + return + } + + guard let instance = instancesManager.instance(for: video) else { return } + + // Capture video ID at start for validation after async call + let requestedVideoID = video.id + commentsState = .loading + + do { + let page = try await contentService.comments( + videoID: video.id.videoID, + instance: instance, + continuation: nil + ) + + // Validate video hasn't changed and task wasn't cancelled + guard !Task.isCancelled, currentVideo?.id == requestedVideoID else { return } + + comments = page.comments + commentsContinuation = page.continuation + commentsState = .loaded + } catch let error as APIError where error == .commentsDisabled { + guard !Task.isCancelled, currentVideo?.id == requestedVideoID else { return } + commentsState = .disabled + } catch { + guard !Task.isCancelled, currentVideo?.id == requestedVideoID else { return } + commentsState = .error + } + } + + /// Loads the next page of comments using the stored continuation token. + func loadMoreComments(contentService: ContentService, instancesManager: InstancesManager) async { + guard let continuation = commentsContinuation else { return } + guard let video = currentVideo, video.supportsComments else { return } + guard let instance = instancesManager.instance(for: video) else { return } + + let requestedVideoID = video.id + commentsState = .loadingMore + + do { + let page = try await contentService.comments( + videoID: video.id.videoID, + instance: instance, + continuation: continuation + ) + + guard !Task.isCancelled, currentVideo?.id == requestedVideoID else { return } + + comments.append(contentsOf: page.comments) + commentsContinuation = page.continuation + commentsState = .loaded + } catch { + guard !Task.isCancelled, currentVideo?.id == requestedVideoID else { return } + // Don't change state to error on load more failure + commentsState = .loaded + } + } + /// Adds a video to the end of the queue. func addToQueue(_ video: Video, stream: Stream? = nil, audioStream: Stream? = nil, captions: [Caption] = [], queueSource: QueueSource? = nil) { queue.append(QueuedVideo(video: video, stream: stream, audioStream: audioStream, captions: captions, queueSource: queueSource)) diff --git a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift index a1e8d55b..4797e672 100644 --- a/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift +++ b/Yattee/ViewModels/PlayerControls/PlayerControlsSettingsViewModel.swift @@ -638,29 +638,6 @@ final class PlayerControlsSettingsViewModel { } } - // MARK: - Wide Layout Panel Settings - - /// Current wide layout panel alignment from active preset, or default. - var wideLayoutPanelAlignment: FloatingPanelSide { - activePreset?.layout.effectiveWideLayoutPanelAlignment ?? .right - } - - /// Synchronously updates the wide layout panel alignment with immediate UI feedback. - /// - Parameter alignment: The new panel alignment. - func syncWideLayoutPanelAlignment(_ alignment: FloatingPanelSide) { - guard var preset = activePreset, !preset.isBuiltIn else { - error = "Cannot edit built-in presets. Duplicate it first." - return - } - - var layout = preset.layout - layout.wideLayoutPanelAlignment = alignment - preset = preset.withUpdatedLayout(layout) - self.activePreset = preset - - queueDebouncedSave(preset: preset) - } - /// Synchronously updates the pill visibility with immediate UI feedback. /// - Parameter visibility: The new visibility mode. func syncPillVisibility(_ visibility: PillVisibility) { diff --git a/Yattee/Views/Components/ExpandedCommentsView.swift b/Yattee/Views/Components/ExpandedCommentsView.swift index b21ef6fb..c6cad167 100644 --- a/Yattee/Views/Components/ExpandedCommentsView.swift +++ b/Yattee/Views/Components/ExpandedCommentsView.swift @@ -329,73 +329,12 @@ struct ExpandedCommentsView: View { private func loadComments() async { guard let playerState, let contentService, let instancesManager else { return } - - // Don't load comments for non-YouTube videos - guard let video = playerState.currentVideo, video.supportsComments else { - playerState.commentsState = .disabled - return - } - - // Capture the video ID we're loading comments for - let requestedVideoID = videoID - - guard let instance = instancesManager.instance(for: video) else { return } - - playerState.commentsState = .loading - - do { - let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: nil) - - // Validate player is still on the same video - guard !Task.isCancelled, - playerState.currentVideo?.id.videoID == requestedVideoID else { return } - - playerState.comments = page.comments - playerState.commentsContinuation = page.continuation - playerState.commentsState = .loaded - } catch let error as APIError where error == .commentsDisabled { - guard !Task.isCancelled, - playerState.currentVideo?.id.videoID == requestedVideoID else { return } - playerState.commentsState = .disabled - } catch { - guard !Task.isCancelled, - playerState.currentVideo?.id.videoID == requestedVideoID else { return } - playerState.commentsState = .error - } + await playerState.loadComments(contentService: contentService, instancesManager: instancesManager) } private func loadMoreComments() async { guard let playerState, let contentService, let instancesManager else { return } - guard let continuation = playerState.commentsContinuation else { return } - - // Don't load more comments for non-YouTube videos - guard let video = playerState.currentVideo, video.supportsComments else { - return - } - - // Capture the video ID we're loading comments for - let requestedVideoID = videoID - - guard let instance = instancesManager.instance(for: video) else { return } - - playerState.commentsState = .loadingMore - - do { - let page = try await contentService.comments(videoID: videoID, instance: instance, continuation: continuation) - - // Validate player is still on the same video - guard !Task.isCancelled, - playerState.currentVideo?.id.videoID == requestedVideoID else { return } - - playerState.comments.append(contentsOf: page.comments) - playerState.commentsContinuation = page.continuation - playerState.commentsState = .loaded - } catch { - guard !Task.isCancelled, - playerState.currentVideo?.id.videoID == requestedVideoID else { return } - // Don't change state to error on load more failure - playerState.commentsState = .loaded - } + await playerState.loadMoreComments(contentService: contentService, instancesManager: instancesManager) } } diff --git a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift index e23296af..dc85cfdd 100644 --- a/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift +++ b/Yattee/Views/Player/ExpandedPlayerSheet+VideoInfo.swift @@ -77,46 +77,10 @@ extension ExpandedPlayerSheet { return } - guard let video = playerState.currentVideo, - let contentService = appEnvironment?.contentService, + guard let contentService = appEnvironment?.contentService, let instancesManager = appEnvironment?.instancesManager else { return } - // Capture video ID at start for validation after async call - let requestedVideoID = video.id - - // Don't load comments for non-YouTube videos - guard video.supportsComments else { - playerState.commentsState = .disabled - return - } - - guard let instance = instancesManager.instance(for: video) else { return } - - playerState.commentsState = .loading - - do { - let page = try await contentService.comments( - videoID: video.id.videoID, - instance: instance, - continuation: nil - ) - - // Validate video hasn't changed and task wasn't cancelled - guard !Task.isCancelled, - playerState.currentVideo?.id == requestedVideoID else { return } - - playerState.comments = page.comments - playerState.commentsContinuation = page.continuation - playerState.commentsState = .loaded - } catch let error as APIError where error == .commentsDisabled { - guard !Task.isCancelled, - playerState.currentVideo?.id == requestedVideoID else { return } - playerState.commentsState = .disabled - } catch { - guard !Task.isCancelled, - playerState.currentVideo?.id == requestedVideoID else { return } - playerState.commentsState = .error - } + await playerState.loadComments(contentService: contentService, instancesManager: instancesManager) } // MARK: - DeArrow Title Helpers diff --git a/Yattee/Views/Player/FloatingDetailsPanel.swift b/Yattee/Views/Player/FloatingDetailsPanel.swift index c1b071c6..26ec612d 100644 --- a/Yattee/Views/Player/FloatingDetailsPanel.swift +++ b/Yattee/Views/Player/FloatingDetailsPanel.swift @@ -616,104 +616,4 @@ struct FloatingDetailsPanel: View { } } -// MARK: - Panel Resize Grabber - -/// A draggable handle for resizing the panel width. -private struct PanelResizeGrabber: View { - @Binding var panelWidth: CGFloat - let minWidth: CGFloat - let maxWidth: CGFloat - let panelSide: FloatingPanelSide - let isPinned: Bool - @Binding var isDragActive: Bool - - @State private var isDragging = false - @State private var dragStartWidth: CGFloat? = nil - @State private var dragStartLocation: CGFloat? = nil - #if os(macOS) - @State private var isHovering = false - #endif - - /// Width of the grabber hit area - private static let grabberHitWidth: CGFloat = 20 - - /// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds - private var grabberColor: Color { - // When pinned (light background), use a darker gray for visibility - // When floating (dark background), use lighter color - let baseColor = isPinned ? Color.gray : Color.secondary - let isActive = isDragging - return baseColor.opacity(isActive ? 1.0 : (isPinned ? 0.5 : 0.6)) - } - - var body: some View { - // Full-height container for expanded hit area - Rectangle() - .fill(Color.clear) - .frame(width: Self.grabberHitWidth) - .frame(maxHeight: .infinity) - .contentShape(Rectangle()) - .overlay { - // Visual pill indicator (centered) - RoundedRectangle(cornerRadius: 3) - .fill(grabberColor) - .frame(width: 6, height: 40) - } - .gesture(dragGesture) - #if os(macOS) - .onHover { hovering in - isHovering = hovering - isDragActive = hovering || isDragging - if hovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - #endif - .animation(.easeInOut(duration: 0.15), value: isDragging) - } - - private var dragGesture: some Gesture { - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - // Capture initial state on first drag event - if dragStartWidth == nil { - dragStartWidth = panelWidth - dragStartLocation = value.location.x - } - - isDragging = true - isDragActive = true - - guard let startWidth = dragStartWidth, - let startLocation = dragStartLocation else { return } - - // Calculate delta from absolute finger position (not translation) - let locationDelta = value.location.x - startLocation - - // Apply delta based on panel side - let delta: CGFloat - if panelSide == .right { - delta = -locationDelta - } else { - delta = locationDelta - } - - let newWidth = startWidth + delta - panelWidth = min(max(newWidth, minWidth), maxWidth) - } - .onEnded { _ in - dragStartWidth = nil - dragStartLocation = nil - isDragging = false - #if os(macOS) - isDragActive = isHovering - #else - isDragActive = false - #endif - } - } -} - #endif diff --git a/Yattee/Views/Player/Panels/PanelResizeGrabber.swift b/Yattee/Views/Player/Panels/PanelResizeGrabber.swift new file mode 100644 index 00000000..897c3793 --- /dev/null +++ b/Yattee/Views/Player/Panels/PanelResizeGrabber.swift @@ -0,0 +1,110 @@ +// +// PanelResizeGrabber.swift +// Yattee +// +// A draggable handle for resizing a details panel's width. +// + +import SwiftUI + +#if os(iOS) || os(macOS) + +/// A draggable handle for resizing the panel width. +struct PanelResizeGrabber: View { + @Binding var panelWidth: CGFloat + let minWidth: CGFloat + let maxWidth: CGFloat + let panelSide: FloatingPanelSide + let isPinned: Bool + @Binding var isDragActive: Bool + + @State private var isDragging = false + @State private var dragStartWidth: CGFloat? = nil + @State private var dragStartLocation: CGFloat? = nil + #if os(macOS) + @State private var isHovering = false + #endif + + /// Width of the grabber hit area + private static let grabberHitWidth: CGFloat = 20 + + /// Grabber fill color - needs to be visible against both light (pinned) and dark (floating) backgrounds + private var grabberColor: Color { + // When pinned (light background), use a darker gray for visibility + // When floating (dark background), use lighter color + let baseColor = isPinned ? Color.gray : Color.secondary + let isActive = isDragging + return baseColor.opacity(isActive ? 1.0 : (isPinned ? 0.5 : 0.6)) + } + + var body: some View { + // Full-height container for expanded hit area + Rectangle() + .fill(Color.clear) + .frame(width: Self.grabberHitWidth) + .frame(maxHeight: .infinity) + .contentShape(Rectangle()) + .overlay { + // Visual pill indicator (centered) + RoundedRectangle(cornerRadius: 3) + .fill(grabberColor) + .frame(width: 6, height: 40) + } + .gesture(dragGesture) + #if os(macOS) + .onHover { hovering in + isHovering = hovering + isDragActive = hovering || isDragging + if hovering { + NSCursor.resizeLeftRight.push() + } else { + NSCursor.pop() + } + } + #endif + .animation(.easeInOut(duration: 0.15), value: isDragging) + } + + private var dragGesture: some Gesture { + DragGesture(minimumDistance: 0, coordinateSpace: .global) + .onChanged { value in + // Capture initial state on first drag event + if dragStartWidth == nil { + dragStartWidth = panelWidth + dragStartLocation = value.location.x + } + + isDragging = true + isDragActive = true + + guard let startWidth = dragStartWidth, + let startLocation = dragStartLocation else { return } + + // Calculate delta from absolute finger position (not translation) + let locationDelta = value.location.x - startLocation + + // Apply delta based on panel side + let delta: CGFloat + if panelSide == .right { + delta = -locationDelta + } else { + delta = locationDelta + } + + let newWidth = startWidth + delta + panelWidth = min(max(newWidth, minWidth), maxWidth) + } + .onEnded { _ in + dragStartWidth = nil + dragStartLocation = nil + isDragging = false + #if os(macOS) + isDragActive = isHovering + #else + isDragActive = false + #endif + } + } +} + +#endif diff --git a/Yattee/Views/Player/Panels/QueueListView.swift b/Yattee/Views/Player/Panels/QueueListView.swift new file mode 100644 index 00000000..a77fa169 --- /dev/null +++ b/Yattee/Views/Player/Panels/QueueListView.swift @@ -0,0 +1,253 @@ +// +// QueueListView.swift +// Yattee +// +// Shared queue list (history / now playing / up next) used by the queue +// management sheet, with playback actions reusable by the tvOS queue panel. +// + +import SwiftUI + +// MARK: - Playback Actions + +/// Shared queue playback actions used by `QueueListView` and the tvOS queue panel. +@MainActor +enum QueuePlaybackActions { + /// Plays the queue item at `index`, pushing the current video and any + /// skipped items to history. + static func playVideo(at index: Int, appEnvironment: AppEnvironment?) { + guard let appEnvironment else { return } + let playerService = appEnvironment.playerService + let playerState = playerService.state + let queueManager = appEnvironment.queueManager + let settingsManager = appEnvironment.settingsManager + + let queue = playerState.queue + guard index < queue.count else { return } + let queuedVideo = queue[index] + + // Push current video to history before switching (skip if incognito or history disabled) + if let currentVideo = playerState.currentVideo, + !settingsManager.incognitoModeEnabled, + settingsManager.saveWatchHistory { + let currentItem = QueuedVideo( + video: currentVideo, + stream: playerState.currentStream, + audioStream: playerState.currentAudioStream, + startTime: playerState.currentTime + ) + playerState.pushToHistory(currentItem) + } + + // Remove played video and all before it from queue, pushing them to history + for _ in 0.. Void)? = nil + + private var queueManager: QueueManager? { appEnvironment?.queueManager } + private var playerState: PlayerState? { appEnvironment?.playerService.state } + + private var queue: [QueuedVideo] { playerState?.queue ?? [] } + private var history: [QueuedVideo] { playerState?.history ?? [] } + private var isLoadingMore: Bool { queueManager?.isLoadingMore ?? false } + private var hasMoreItems: Bool { queueManager?.hasMoreItems() ?? false } + private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset } + + var body: some View { + ScrollViewReader { proxy in + List { + // History section - show previously played videos + if !history.isEmpty { + Section { + ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in + QueueItemRow( + queuedVideo: historyItem, + index: nil, // History items don't show numbers + isCurrentlyPlaying: false, + onRemove: { }, + onTap: { + QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment) + onPlay?() + } + ) + } + } header: { + HStack { + Text(String(localized: "queue.section.previously")) + Spacer() + Text(String(localized: "queue.section.count \(history.count)")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + // Now Playing section - show the currently playing video from playerState + if let currentVideo = playerState?.currentVideo { + Section { + // Create a temporary QueuedVideo for display + let nowPlayingItem = QueuedVideo( + video: currentVideo, + stream: playerState?.currentStream, + queueSource: nil + ) + QueueItemRow( + queuedVideo: nowPlayingItem, + index: nil, // Now playing shows waveform, not a number + isCurrentlyPlaying: true, + onRemove: { }, + onTap: { } + ) + } header: { + Text(String(localized: "queue.section.nowPlaying")) + } + .id("now-playing") + } + + // Up Next section - show all queued videos + if !queue.isEmpty { + Section { + ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in + QueueItemRow( + queuedVideo: queuedVideo, + index: index + 1, // +1 because "Now Playing" is shown as position 1 + isCurrentlyPlaying: false, + onRemove: { + withAnimation { + queueManager?.removeFromQueue(id: queuedVideo.id) + } + }, + onTap: { + QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment) + onPlay?() + } + ) + } + .onMove { source, destination in + guard let fromIndex = source.first else { return } + queueManager?.moveQueueItem(from: fromIndex, to: destination) + } + + // Load more indicator + if hasMoreItems { + HStack { + Spacer() + if isLoadingMore { + ProgressView() + .controlSize(.small) + } else { + Button { + Task { + try? await queueManager?.loadMoreQueueItems() + } + } label: { + Text(String(localized: "queue.sheet.loadMore")) + .font(.subheadline) + .foregroundStyle(.tint) + } + } + Spacer() + } + .listRowBackground(Color.clear) + } + } header: { + HStack { + Text(String(localized: "queue.section.upNext")) + Spacer() + Text(String(localized: "queue.section.count \(queue.count)")) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + #if os(iOS) + .if(listStyle == .plain, then: { view in + view.listStyle(.plain) + }, else: { view in + view.listStyle(.insetGrouped) + }) + #endif + .onAppear { + // Scroll to now playing section (no animation for instant positioning) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + proxy.scrollTo("now-playing", anchor: .center) + } + } + } + } +} + +#endif diff --git a/Yattee/Views/Player/QueueManagementSheet.swift b/Yattee/Views/Player/QueueManagementSheet.swift index b751f0a0..822332ab 100644 --- a/Yattee/Views/Player/QueueManagementSheet.swift +++ b/Yattee/Views/Player/QueueManagementSheet.swift @@ -45,8 +45,7 @@ struct QueueManagementSheet: View { } private var queueManager: QueueManager? { appEnvironment?.queueManager } - private var playerService: PlayerService? { appEnvironment?.playerService } - private var playerState: PlayerState? { playerService?.state } + private var playerState: PlayerState? { appEnvironment?.playerService.state } private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator } private var queue: [QueuedVideo] { playerState?.queue ?? [] } @@ -54,7 +53,6 @@ struct QueueManagementSheet: View { private var isLoadingMore: Bool { queueManager?.isLoadingMore ?? false } private var hasMoreItems: Bool { queueManager?.hasMoreItems() ?? false } private var queueSourceLabel: String? { queueManager?.currentQueueSourceLabel } - private var listStyle: VideoListStyle { appEnvironment?.settingsManager.listStyle ?? .inset } var body: some View { #if os(tvOS) @@ -192,7 +190,10 @@ struct QueueManagementSheet: View { // padding inside the half-screen panel. tvOSQueueListView #else - iosQueueListView + QueueListView(onPlay: { + navigationCoordinator?.isMiniPlayerQueueSheetPresented = false + performDismiss() + }) #endif } @@ -308,158 +309,6 @@ struct QueueManagementSheet: View { } #endif - private var iosQueueListView: some View { - ScrollViewReader { proxy in - List { - // History section - show previously played videos - if !history.isEmpty { - Section { - ForEach(Array(history.enumerated()), id: \.element.id) { index, historyItem in - QueueItemRow( - queuedVideo: historyItem, - index: nil, // History items don't show numbers - isCurrentlyPlaying: false, - onRemove: { }, - onTap: { - playFromHistory(at: index) - } - ) - #if os(tvOS) - .focused($inlinePanelFocus, equals: .history(historyItem.id)) - // Zero the grouped style's built-in row insets - // (which are asymmetric in a half-screen panel) and - // apply our own symmetric padding directly to the - // row content. - .padding(.horizontal, 40) - .listRowInsets(EdgeInsets()) - #endif - } - } header: { - HStack { - Text(String(localized: "queue.section.previously")) - Spacer() - Text(String(localized: "queue.section.count \(history.count)")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - - // Now Playing section - show the currently playing video from playerState - if let currentVideo = playerState?.currentVideo { - Section { - // Create a temporary QueuedVideo for display - let nowPlayingItem = QueuedVideo( - video: currentVideo, - stream: playerState?.currentStream, - queueSource: nil - ) - QueueItemRow( - queuedVideo: nowPlayingItem, - index: nil, // Now playing shows waveform, not a number - isCurrentlyPlaying: true, - onRemove: { }, - onTap: { } - ) - #if os(tvOS) - .focused($inlinePanelFocus, equals: .nowPlaying) - .listRowInsets(EdgeInsets(top: 6, leading: 40, bottom: 6, trailing: 40)) - #endif - } header: { - nowPlayingHeader - } - .id("now-playing") - } - - // Up Next section - show all queued videos - if !queue.isEmpty { - Section { - ForEach(Array(queue.enumerated()), id: \.element.id) { index, queuedVideo in - QueueItemRow( - queuedVideo: queuedVideo, - index: index + 1, // +1 because "Now Playing" is shown as position 1 - isCurrentlyPlaying: false, - onRemove: { - withAnimation { - queueManager?.removeFromQueue(id: queuedVideo.id) - } - }, - onTap: { - playVideo(at: index) - } - ) - #if os(tvOS) - .focused($inlinePanelFocus, equals: .upNext(queuedVideo.id)) - // Zero the grouped style's built-in row insets - // (which are asymmetric in a half-screen panel) and - // apply our own symmetric padding directly to the - // row content. - .padding(.horizontal, 40) - .listRowInsets(EdgeInsets()) - #endif - } - .onMove { source, destination in - guard let fromIndex = source.first else { return } - queueManager?.moveQueueItem(from: fromIndex, to: destination) - } - - // Load more indicator - if hasMoreItems { - HStack { - Spacer() - if isLoadingMore { - ProgressView() - .controlSize(.small) - } else { - Button { - Task { - try? await queueManager?.loadMoreQueueItems() - } - } label: { - Text(String(localized: "queue.sheet.loadMore")) - .font(.subheadline) - .foregroundStyle(.tint) - } - } - Spacer() - } - .listRowBackground(Color.clear) - } - } header: { - HStack { - Text(String(localized: "queue.section.upNext")) - Spacer() - Text(String(localized: "queue.section.count \(queue.count)")) - .font(.caption) - .foregroundStyle(.secondary) - } - } - } - } - #if os(tvOS) - .listStyle(.grouped) - #elseif os(iOS) - .if(listStyle == .plain, then: { view in - view.listStyle(.plain) - }, else: { view in - view.listStyle(.insetGrouped) - }) - #endif - .onAppear { - // Scroll to now playing section (no animation for instant positioning) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - proxy.scrollTo("now-playing", anchor: .center) - } - } - } - } - - /// Header for Now Playing section - @ViewBuilder - private var nowPlayingHeader: some View { - Text(String(localized: "queue.section.nowPlaying")) - } - /// Menu for selecting queue mode (shuffle, repeat, etc.) @ViewBuilder private var queueModeMenu: some View { @@ -510,85 +359,19 @@ struct QueueManagementSheet: View { // MARK: - Actions + #if os(tvOS) private func playVideo(at index: Int) { - guard let playerService, let playerState else { return } - guard index < queue.count else { return } - let queuedVideo = queue[index] - - // Push current video to history before switching (skip if incognito or history disabled) - if let currentVideo = playerState.currentVideo, - appEnvironment?.settingsManager.incognitoModeEnabled != true, - appEnvironment?.settingsManager.saveWatchHistory != false { - let currentItem = QueuedVideo( - video: currentVideo, - stream: playerState.currentStream, - audioStream: playerState.currentAudioStream, - startTime: playerState.currentTime - ) - playerState.pushToHistory(currentItem) - } - - // Remove played video and all before it from queue, pushing them to history - for _ in 0..