mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
110
Yattee/Views/Player/Panels/PanelResizeGrabber.swift
Normal file
110
Yattee/Views/Player/Panels/PanelResizeGrabber.swift
Normal 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
|
||||
253
Yattee/Views/Player/Panels/QueueListView.swift
Normal file
253
Yattee/Views/Player/Panels/QueueListView.swift
Normal 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
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user