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.
This commit is contained in:
Arkadiusz Fal
2026-09-21 23:04:42 +02:00
parent 6efd60a18e
commit 8bb6137417
9 changed files with 448 additions and 459 deletions

View File

@@ -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.

View File

@@ -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))

View File

@@ -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) {

View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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..<index {
if let item = playerState.queue.first,
!settingsManager.incognitoModeEnabled,
settingsManager.saveWatchHistory {
playerState.pushToHistory(item)
}
queueManager.removeFromQueue(at: 0)
}
// Remove the one we're about to play
queueManager.removeFromQueue(at: 0)
// Play the selected video (always from beginning for queue items)
// Prefer downloaded content over pre-loaded network streams
Task {
await playerService.playPreferringDownloaded(
video: queuedVideo.video,
fallbackStream: queuedVideo.stream,
fallbackAudioStream: queuedVideo.audioStream,
startTime: 0
)
}
}
/// Plays the history item at `index`, moving it and the items after it
/// back into the queue.
static func playFromHistory(at index: Int, appEnvironment: AppEnvironment?) {
guard let appEnvironment else { return }
let playerService = appEnvironment.playerService
let playerState = playerService.state
let history = playerState.history
guard index < history.count else { return }
let historyItem = history[index]
// Push current video to front of queue
if let currentVideo = playerState.currentVideo {
playerState.insertNext(currentVideo, stream: playerState.currentStream, audioStream: playerState.currentAudioStream)
}
// Push all items after this in history to front of queue (in reverse order)
// so they become "up next" in correct order
let itemsAfter = Array(history[(index + 1)...])
for item in itemsAfter.reversed() {
playerState.insertNext(item.video, stream: item.stream, audioStream: item.audioStream, captions: item.captions)
}
// Remove this item and all after it from history
for _ in index..<history.count {
_ = playerState.retreatQueue()
}
// Play the selected history item
// Prefer downloaded content over stored network streams
Task {
await playerService.playPreferringDownloaded(
video: historyItem.video,
fallbackStream: historyItem.stream,
fallbackAudioStream: historyItem.audioStream,
startTime: historyItem.startTime
)
}
}
}
// MARK: - Queue List
#if os(iOS) || os(macOS)
/// List of previously played, now playing, and queued videos with reorder,
/// remove, and load-more support.
struct QueueListView: View {
@Environment(\.appEnvironment) private var appEnvironment
/// Called after a row tap starts playback (the host dismisses itself).
var onPlay: (() -> 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

View File

@@ -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..<index {
if let item = playerState.queue.first,
appEnvironment?.settingsManager.incognitoModeEnabled != true,
appEnvironment?.settingsManager.saveWatchHistory != false {
playerState.pushToHistory(item)
}
queueManager?.removeFromQueue(at: 0)
}
// Remove the one we're about to play
queueManager?.removeFromQueue(at: 0)
// Play the selected video (always from beginning for queue items)
// Prefer downloaded content over pre-loaded network streams
Task {
await playerService.playPreferringDownloaded(
video: queuedVideo.video,
fallbackStream: queuedVideo.stream,
fallbackAudioStream: queuedVideo.audioStream,
startTime: 0
)
}
QueuePlaybackActions.playVideo(at: index, appEnvironment: appEnvironment)
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
}
private func playFromHistory(at index: Int) {
guard let playerService, let playerState else { return }
guard index < history.count else { return }
let historyItem = history[index]
// Push current video to front of queue
if let currentVideo = playerState.currentVideo {
playerState.insertNext(currentVideo, stream: playerState.currentStream, audioStream: playerState.currentAudioStream)
}
// Push all items after this in history to front of queue (in reverse order)
// so they become "up next" in correct order
let itemsAfter = Array(history[(index + 1)...])
for item in itemsAfter.reversed() {
playerState.insertNext(item.video, stream: item.stream, audioStream: item.audioStream, captions: item.captions)
}
// Remove this item and all after it from history
for _ in index..<history.count {
_ = playerState.retreatQueue()
}
// Play the selected history item
// Prefer downloaded content over stored network streams
Task {
await playerService.playPreferringDownloaded(
video: historyItem.video,
fallbackStream: historyItem.stream,
fallbackAudioStream: historyItem.audioStream,
startTime: historyItem.startTime
)
}
QueuePlaybackActions.playFromHistory(at: index, appEnvironment: appEnvironment)
navigationCoordinator?.isMiniPlayerQueueSheetPresented = false
performDismiss()
}
#endif
}
#Preview {