mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Tapping a related video kept the panel scrolled where it was, so the new title was out of view, and the chapters section kept showing the previous video's chapters until the new ones resolved. The scroll config now carries the video identity and the shared page scroll view scrolls to the top (without animation, resetting the reported offset) whenever it changes. PlayerState.setCurrentVideo clears chapters on a video id change, alongside comments and storyboards.
785 lines
24 KiB
Swift
785 lines
24 KiB
Swift
//
|
|
// PlayerState.swift
|
|
// Yattee
|
|
//
|
|
// Observable state for the video player.
|
|
//
|
|
|
|
import Foundation
|
|
import AVFoundation
|
|
|
|
/// The current state of video playback.
|
|
enum PlaybackState: Equatable, Sendable {
|
|
case idle
|
|
case loading
|
|
case ready
|
|
case playing
|
|
case paused
|
|
case buffering
|
|
case ended
|
|
case failed(Error)
|
|
|
|
static func == (lhs: PlaybackState, rhs: PlaybackState) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.idle, .idle),
|
|
(.loading, .loading),
|
|
(.ready, .ready),
|
|
(.playing, .playing),
|
|
(.paused, .paused),
|
|
(.buffering, .buffering),
|
|
(.ended, .ended):
|
|
return true
|
|
case (.failed, .failed):
|
|
return true // Compare errors by existence, not content
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Retry state for stream loading.
|
|
struct RetryState: Equatable, Sendable {
|
|
/// Current retry number (1-5), not counting initial attempt.
|
|
let currentRetry: Int
|
|
/// Maximum number of retries (5).
|
|
let maxRetries: Int
|
|
/// Whether a retry is currently in progress.
|
|
let isRetrying: Bool
|
|
/// Whether all retries have been exhausted.
|
|
let exhausted: Bool
|
|
|
|
static let idle = RetryState(currentRetry: 0, maxRetries: 5, isRetrying: false, exhausted: false)
|
|
|
|
/// Text to display during retries (e.g., "Retrying... (1/5)").
|
|
var displayText: String? {
|
|
guard isRetrying, currentRetry > 0 else { return nil }
|
|
return String(localized: "player.retry.status \(currentRetry) \(maxRetries)")
|
|
}
|
|
}
|
|
|
|
/// Represents a queued video for playback.
|
|
struct QueuedVideo: Identifiable, Equatable, Sendable, Codable {
|
|
let id: String
|
|
let video: Video
|
|
let stream: Stream?
|
|
let audioStream: Stream?
|
|
let captions: [Caption]
|
|
let startTime: TimeInterval?
|
|
let addedAt: Date
|
|
let queueSource: QueueSource?
|
|
|
|
init(
|
|
video: Video,
|
|
stream: Stream? = nil,
|
|
audioStream: Stream? = nil,
|
|
captions: [Caption] = [],
|
|
startTime: TimeInterval? = nil,
|
|
queueSource: QueueSource? = nil
|
|
) {
|
|
self.id = UUID().uuidString
|
|
self.video = video
|
|
self.stream = stream
|
|
self.audioStream = audioStream
|
|
self.captions = captions
|
|
self.startTime = startTime
|
|
self.addedAt = Date()
|
|
self.queueSource = queueSource
|
|
}
|
|
|
|
/// Internal init that preserves existing ID and addedAt (for updating streams).
|
|
init(
|
|
id: String,
|
|
video: Video,
|
|
stream: Stream?,
|
|
audioStream: Stream?,
|
|
captions: [Caption],
|
|
startTime: TimeInterval?,
|
|
addedAt: Date,
|
|
queueSource: QueueSource?
|
|
) {
|
|
self.id = id
|
|
self.video = video
|
|
self.stream = stream
|
|
self.audioStream = audioStream
|
|
self.captions = captions
|
|
self.startTime = startTime
|
|
self.addedAt = addedAt
|
|
self.queueSource = queueSource
|
|
}
|
|
|
|
static func == (lhs: QueuedVideo, rhs: QueuedVideo) -> Bool {
|
|
lhs.id == rhs.id
|
|
}
|
|
}
|
|
|
|
/// Playback rate options.
|
|
enum PlaybackRate: Double, CaseIterable, Identifiable, Sendable {
|
|
case x025 = 0.25
|
|
case x05 = 0.5
|
|
case x075 = 0.75
|
|
case x1 = 1.0
|
|
case x125 = 1.25
|
|
case x15 = 1.5
|
|
case x175 = 1.75
|
|
case x2 = 2.0
|
|
case x25 = 2.5
|
|
case x3 = 3.0
|
|
|
|
var id: Double { rawValue }
|
|
|
|
var displayText: String {
|
|
if rawValue == 1.0 {
|
|
return String(localized: "player.playbackRate.normal")
|
|
}
|
|
return formattedRate
|
|
}
|
|
|
|
/// Compact display text that always shows numeric value (e.g., "1x", "1.5x").
|
|
/// Use this in space-constrained UI like the player pill.
|
|
var compactDisplayText: String {
|
|
formattedRate
|
|
}
|
|
|
|
/// The rate with an `x` suffix rendered with a fixed two-decimal format and
|
|
/// the trailing `.0` stripped, so whole rates have no decimal
|
|
/// (2 → "2x", 1.5 → "1.5x", 1.25 → "1.25x"). Replaces `%.2g`, whose
|
|
/// significant-figure notation dropped meaningful digits (1.25 → "1.2") and
|
|
/// rounded others (1.75 → "1.8"). `String(format:)` keeps the period decimal
|
|
/// separator, matching the player's other speed labels.
|
|
private var formattedRate: String {
|
|
var value = String(format: "%.2f", rawValue)
|
|
while value.hasSuffix("0") { value.removeLast() }
|
|
if value.hasSuffix(".") { value.removeLast() }
|
|
return "\(value)x"
|
|
}
|
|
}
|
|
|
|
/// Picture-in-Picture state.
|
|
enum PiPState: Equatable, Sendable {
|
|
case inactive
|
|
case active
|
|
}
|
|
|
|
/// Queue playback mode.
|
|
enum QueueMode: String, CaseIterable, Codable, Sendable {
|
|
case normal
|
|
case repeatAll
|
|
case repeatOne
|
|
case shuffle
|
|
|
|
/// SF Symbol icon for this mode.
|
|
var icon: String {
|
|
switch self {
|
|
case .normal: "list.bullet"
|
|
case .repeatAll: "repeat"
|
|
case .repeatOne: "repeat.1"
|
|
case .shuffle: "shuffle"
|
|
}
|
|
}
|
|
|
|
/// Localized display name.
|
|
var displayName: String {
|
|
switch self {
|
|
case .normal: String(localized: "queue.mode.normal")
|
|
case .repeatAll: String(localized: "queue.mode.repeatAll")
|
|
case .repeatOne: String(localized: "queue.mode.repeatOne")
|
|
case .shuffle: String(localized: "queue.mode.shuffle")
|
|
}
|
|
}
|
|
|
|
/// Loads saved queue mode from UserDefaults.
|
|
static func loadSaved() -> QueueMode {
|
|
guard let saved = UserDefaults.standard.string(forKey: "queueMode"),
|
|
let mode = QueueMode(rawValue: saved) else {
|
|
return .normal
|
|
}
|
|
return mode
|
|
}
|
|
|
|
/// Saves this mode to UserDefaults.
|
|
func save() {
|
|
UserDefaults.standard.set(rawValue, forKey: "queueMode")
|
|
}
|
|
}
|
|
|
|
/// Observable player state.
|
|
@MainActor
|
|
@Observable
|
|
final class PlayerState {
|
|
// MARK: - Current Video
|
|
|
|
/// The currently playing video.
|
|
private(set) var currentVideo: Video?
|
|
|
|
/// The stream being used for playback.
|
|
private(set) var currentStream: Stream?
|
|
|
|
/// The separate audio stream (for video-only streams).
|
|
private(set) var currentAudioStream: Stream?
|
|
|
|
/// Current playback state.
|
|
private(set) var playbackState: PlaybackState = .idle
|
|
|
|
/// Current retry state for stream loading.
|
|
private(set) var retryState: RetryState = .idle
|
|
|
|
// MARK: - Time & Progress
|
|
|
|
/// Current playback time in seconds.
|
|
var currentTime: TimeInterval = 0 {
|
|
didSet { updateActiveChapter() }
|
|
}
|
|
|
|
/// Total duration in seconds.
|
|
var duration: TimeInterval = 0
|
|
|
|
/// Buffered time in seconds.
|
|
var bufferedTime: TimeInterval = 0
|
|
|
|
/// Whether duration updates from the player backend should be ignored.
|
|
/// Set to true when playing through fast endpoint where duration is known from API
|
|
/// but file size is unknown (progressive download).
|
|
private(set) var isDurationLockedFromAPI: Bool = false
|
|
|
|
/// Whether the current video/stream is live.
|
|
var isLive: Bool {
|
|
currentVideo?.isLive == true || currentStream?.isLive == true
|
|
}
|
|
|
|
/// Whether SMB media playback is currently active.
|
|
/// Used to prevent SMB directory browsing while SMB streaming is in progress,
|
|
/// as libsmbclient has internal state conflicts when used concurrently.
|
|
var isSMBPlaybackActive: Bool {
|
|
guard let video = currentVideo else { return false }
|
|
guard playbackState == .playing || playbackState == .paused || playbackState == .buffering else {
|
|
return false
|
|
}
|
|
return video.id.isSMBSource
|
|
}
|
|
|
|
/// Progress as a fraction (0-1).
|
|
var progress: Double {
|
|
guard duration > 0 else { return 0 }
|
|
return min(currentTime / duration, 1.0)
|
|
}
|
|
|
|
/// Formatted current time string.
|
|
var formattedCurrentTime: String {
|
|
// For live streams, show "LIVE" instead of time
|
|
if isLive {
|
|
return "LIVE"
|
|
}
|
|
return currentTime.formattedAsTimestamp
|
|
}
|
|
|
|
/// Formatted duration string.
|
|
var formattedDuration: String {
|
|
// For live streams, show "LIVE" instead of duration
|
|
if isLive {
|
|
return "LIVE"
|
|
}
|
|
return duration.formattedAsTimestamp
|
|
}
|
|
|
|
/// Formatted remaining time string.
|
|
var formattedRemainingTime: String {
|
|
"-" + max(0, duration - currentTime).formattedAsTimestamp
|
|
}
|
|
|
|
// MARK: - Playback Settings
|
|
|
|
/// Current playback rate.
|
|
var rate: PlaybackRate = .x1
|
|
|
|
/// Whether playback is muted.
|
|
var isMuted: Bool = false
|
|
|
|
/// Volume level (0-1).
|
|
var volume: Float = 1.0
|
|
|
|
// MARK: - Queue
|
|
|
|
/// Videos queued for playback (upcoming videos only, not including current).
|
|
private(set) var queue: [QueuedVideo] = []
|
|
|
|
/// History of previously played videos (for going back).
|
|
private(set) var history: [QueuedVideo] = []
|
|
|
|
/// Maximum number of videos to keep in history.
|
|
private let maxHistorySize = 20
|
|
|
|
/// Whether there's a previous video in history.
|
|
var hasPrevious: Bool {
|
|
!history.isEmpty
|
|
}
|
|
|
|
/// Whether there's a next video in queue.
|
|
/// Since queue only contains upcoming videos, we just check if it's not empty.
|
|
var hasNext: Bool {
|
|
!queue.isEmpty
|
|
}
|
|
|
|
/// Current queue playback mode.
|
|
var queueMode: QueueMode = .loadSaved() {
|
|
didSet {
|
|
queueMode.save()
|
|
}
|
|
}
|
|
|
|
// MARK: - SponsorBlock
|
|
|
|
/// SponsorBlock segments for current video.
|
|
var sponsorSegments: [SponsorBlockSegment] = []
|
|
|
|
/// Categories to auto-skip.
|
|
var autoSkipCategories: Set<SponsorBlockCategory> = Set(
|
|
SponsorBlockCategory.allCases.filter { $0.defaultAutoSkip }
|
|
)
|
|
|
|
/// Whether SponsorBlock is enabled.
|
|
var sponsorBlockEnabled: Bool = true
|
|
|
|
/// Current segment being shown (for skip notification).
|
|
var currentSegment: SponsorBlockSegment?
|
|
|
|
// MARK: - Return YouTube Dislike
|
|
|
|
/// Dislike count from Return YouTube Dislike API.
|
|
var dislikeCount: Int?
|
|
|
|
// MARK: - Picture-in-Picture
|
|
|
|
/// Current PiP state.
|
|
var pipState: PiPState = .inactive
|
|
|
|
/// Whether PiP is possible.
|
|
var isPiPPossible: Bool = false
|
|
|
|
// MARK: - Chapters
|
|
|
|
/// Video chapters if available.
|
|
var chapters: [VideoChapter] = [] {
|
|
didSet { updateActiveChapter() }
|
|
}
|
|
|
|
/// ID of the chapter containing the current playback time. Stored and
|
|
/// change-guarded so views highlighting the active chapter observe only
|
|
/// chapter transitions, not every time-pos tick — reading `currentTime`
|
|
/// from a panel body re-renders it many times per second during playback.
|
|
private(set) var activeChapterID: UUID?
|
|
|
|
/// Current chapter based on playback time.
|
|
var currentChapter: VideoChapter? {
|
|
guard let activeChapterID else { return nil }
|
|
return chapters.first { $0.id == activeChapterID }
|
|
}
|
|
|
|
private func updateActiveChapter() {
|
|
let id = chapters.last { $0.startTime <= currentTime }?.id
|
|
if activeChapterID != id {
|
|
activeChapterID = id
|
|
}
|
|
}
|
|
|
|
// MARK: - Storyboards
|
|
|
|
/// Available storyboards for seek preview thumbnails.
|
|
var storyboards: [Storyboard] = []
|
|
|
|
/// Preferred storyboard for preview (highest quality available).
|
|
var preferredStoryboard: Storyboard? {
|
|
storyboards.highest()
|
|
}
|
|
|
|
// MARK: - Video Details
|
|
|
|
/// Video details loading state.
|
|
var videoDetailsState: VideoDetailsLoadState = .idle
|
|
|
|
// MARK: - Comments
|
|
|
|
/// Preloaded comments for the current video.
|
|
var comments: [Comment] = []
|
|
|
|
/// Comments loading state.
|
|
var commentsState: CommentsLoadState = .idle
|
|
|
|
/// Continuation token for loading more comments.
|
|
var commentsContinuation: String?
|
|
|
|
// MARK: - UI State
|
|
|
|
/// Whether controls are visible.
|
|
var controlsVisible: Bool = true
|
|
|
|
/// Whether seeking is in progress.
|
|
var isSeeking: Bool = false
|
|
|
|
/// Whether the video is being closed (used to hide UI before dismissal).
|
|
var isClosingVideo: Bool = false
|
|
|
|
/// Whether the MPV debug overlay is visible.
|
|
var showDebugOverlay: Bool = false
|
|
|
|
/// Whether player controls are locked (buttons/gestures disabled except settings and dismiss).
|
|
var isControlsLocked: Bool = false
|
|
|
|
/// Actual video track aspect ratio (width/height).
|
|
/// nil means unknown, default to 16:9.
|
|
var videoAspectRatio: Double?
|
|
|
|
/// Whether the first frame of the current video has been rendered.
|
|
/// Reset when a new video loads, set when backendDidBecomeReady is called.
|
|
var isFirstFrameReady: Bool = false
|
|
|
|
/// Whether the buffer is ready and playback can start smoothly.
|
|
/// This is set after waiting for sufficient buffer before calling play().
|
|
/// Used to keep thumbnail visible until video is truly ready to play.
|
|
var isBufferReady: Bool = false
|
|
|
|
/// Current buffer progress as a percentage (0-100).
|
|
/// Updated by MPV's cache-buffering-state property during initial buffering.
|
|
/// nil when not buffering or when using AVPlayer backend.
|
|
var bufferProgress: Int?
|
|
|
|
/// Whether the video is vertical (portrait orientation).
|
|
var isVerticalVideo: Bool {
|
|
guard let ratio = videoAspectRatio else { return false }
|
|
return ratio < 1.0
|
|
}
|
|
|
|
/// Display aspect ratio for UI - falls back to 16:9 if unknown.
|
|
var displayAspectRatio: Double {
|
|
videoAspectRatio ?? (16.0 / 9.0)
|
|
}
|
|
|
|
/// Whether transport controls (play/pause, seek) should be blocked because
|
|
/// the player isn't ready yet — loading, buffering, or waiting for the
|
|
/// first frame / initial buffer. Shared by iOS and tvOS control overlays.
|
|
var isTransportDisabled: Bool {
|
|
playbackState == .loading
|
|
|| playbackState == .buffering
|
|
|| !isFirstFrameReady
|
|
|| !isBufferReady
|
|
}
|
|
|
|
// MARK: - Methods
|
|
|
|
/// Updates the current video and stream.
|
|
func setCurrentVideo(_ video: Video?, stream: Stream?, audioStream: Stream? = nil) {
|
|
// When switching to a different video, reset time-related state to prevent
|
|
// the old video's progress from being saved to the new video
|
|
if video?.id != currentVideo?.id {
|
|
dislikeCount = nil
|
|
currentTime = 0
|
|
duration = 0
|
|
bufferedTime = 0
|
|
isDurationLockedFromAPI = false
|
|
// Reset video details state for new video
|
|
videoDetailsState = .idle
|
|
// Reset comments for new video
|
|
comments = []
|
|
commentsState = .idle
|
|
commentsContinuation = nil
|
|
// Reset storyboards to prevent previous video's storyboards from appearing
|
|
storyboards = []
|
|
// Reset chapters so the panel doesn't show the previous video's
|
|
// chapters while the new one loads
|
|
chapters = []
|
|
}
|
|
currentVideo = video
|
|
currentStream = stream
|
|
currentAudioStream = audioStream
|
|
if video == nil {
|
|
reset()
|
|
}
|
|
}
|
|
|
|
/// Updates the current stream without changing the video.
|
|
func updateCurrentStream(_ stream: Stream) {
|
|
currentStream = stream
|
|
}
|
|
|
|
/// Updates the current audio stream without changing the video.
|
|
func updateCurrentAudioStream(_ stream: Stream) {
|
|
currentAudioStream = stream
|
|
}
|
|
|
|
/// Whether the playback has failed.
|
|
var isFailed: Bool {
|
|
if case .failed = playbackState { return true }
|
|
return false
|
|
}
|
|
|
|
/// Error message if playback failed, nil otherwise.
|
|
var errorMessage: String? {
|
|
if case .failed(let error) = playbackState {
|
|
return error.localizedDescription
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Updates the playback state.
|
|
func setPlaybackState(_ state: PlaybackState) {
|
|
playbackState = state
|
|
}
|
|
|
|
/// Updates the retry state.
|
|
func setRetryState(_ state: RetryState) {
|
|
retryState = state
|
|
}
|
|
|
|
/// Locks duration from API metadata, preventing backend updates.
|
|
/// Used for fast endpoint streams where MPV can't determine accurate duration
|
|
/// because the file is being progressively downloaded.
|
|
func lockDuration(_ duration: TimeInterval) {
|
|
self.duration = duration
|
|
self.isDurationLockedFromAPI = true
|
|
}
|
|
|
|
/// Resets player state.
|
|
func reset() {
|
|
currentTime = 0
|
|
duration = 0
|
|
bufferedTime = 0
|
|
isDurationLockedFromAPI = false
|
|
sponsorSegments = []
|
|
currentSegment = nil
|
|
dislikeCount = nil
|
|
chapters = []
|
|
storyboards = []
|
|
playbackState = .idle
|
|
retryState = .idle
|
|
isClosingVideo = false
|
|
videoAspectRatio = nil
|
|
isFirstFrameReady = false
|
|
isBufferReady = false
|
|
bufferProgress = nil
|
|
videoDetailsState = .idle
|
|
comments = []
|
|
commentsState = .idle
|
|
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))
|
|
}
|
|
|
|
/// Adds multiple videos to the end of the queue.
|
|
func addToQueue(_ videos: [Video], queueSource: QueueSource? = nil) {
|
|
let queuedVideos = videos.map { QueuedVideo(video: $0, queueSource: queueSource) }
|
|
queue.append(contentsOf: queuedVideos)
|
|
}
|
|
|
|
/// Inserts a video at the front of the queue (to play next).
|
|
func insertNext(_ video: Video, stream: Stream? = nil, audioStream: Stream? = nil, captions: [Caption] = [], queueSource: QueueSource? = nil) {
|
|
queue.insert(QueuedVideo(video: video, stream: stream, audioStream: audioStream, captions: captions, queueSource: queueSource), at: 0)
|
|
}
|
|
|
|
/// Removes a video from the queue at the specified index.
|
|
func removeFromQueue(at index: Int) {
|
|
guard index >= 0, index < queue.count else { return }
|
|
queue.remove(at: index)
|
|
}
|
|
|
|
/// Removes a video from the queue by its ID.
|
|
func removeFromQueue(id: QueuedVideo.ID) {
|
|
queue.removeAll { $0.id == id }
|
|
}
|
|
|
|
/// Updates a queue item with preloaded video details and streams.
|
|
/// Preserves the item's ID for stable SwiftUI identity.
|
|
func updateQueueItemWithPreload(at index: Int, video: Video, stream: Stream?, audioStream: Stream?) {
|
|
guard index >= 0 && index < queue.count else { return }
|
|
let item = queue[index]
|
|
queue[index] = QueuedVideo(
|
|
id: item.id,
|
|
video: video,
|
|
stream: stream,
|
|
audioStream: audioStream,
|
|
captions: item.captions,
|
|
startTime: item.startTime,
|
|
addedAt: item.addedAt,
|
|
queueSource: item.queueSource
|
|
)
|
|
}
|
|
|
|
/// Moves a queue item from one position to another.
|
|
func moveQueueItem(from sourceIndex: Int, to destinationIndex: Int) {
|
|
guard sourceIndex >= 0, sourceIndex < queue.count,
|
|
destinationIndex >= 0, destinationIndex <= queue.count,
|
|
sourceIndex != destinationIndex else { return }
|
|
|
|
let item = queue.remove(at: sourceIndex)
|
|
let adjustedDestination = destinationIndex > sourceIndex ? destinationIndex - 1 : destinationIndex
|
|
queue.insert(item, at: adjustedDestination)
|
|
}
|
|
|
|
/// Clears the queue.
|
|
func clearQueue() {
|
|
queue.removeAll()
|
|
}
|
|
|
|
/// Removes and returns the next video in queue.
|
|
/// Since queue only contains upcoming videos, this removes the first item.
|
|
func advanceQueue() -> QueuedVideo? {
|
|
guard !queue.isEmpty else { return nil }
|
|
return queue.removeFirst()
|
|
}
|
|
|
|
/// Removes and returns the previous video from history.
|
|
func retreatQueue() -> QueuedVideo? {
|
|
guard !history.isEmpty else { return nil }
|
|
return history.removeLast()
|
|
}
|
|
|
|
/// Pushes a video to history (for going back later).
|
|
/// Keeps history limited to maxHistorySize.
|
|
func pushToHistory(_ video: QueuedVideo) {
|
|
history.append(video)
|
|
if history.count > maxHistorySize {
|
|
history.removeFirst()
|
|
}
|
|
}
|
|
|
|
/// Clears the playback history.
|
|
func clearHistory() {
|
|
history.removeAll()
|
|
}
|
|
|
|
/// Removes and returns a random video from queue (for shuffle mode).
|
|
func advanceQueueShuffle() -> QueuedVideo? {
|
|
guard !queue.isEmpty else { return nil }
|
|
let randomIndex = Int.random(in: 0..<queue.count)
|
|
return queue.remove(at: randomIndex)
|
|
}
|
|
|
|
/// Moves all history items back to queue for repeat all mode.
|
|
/// History is cleared after moving.
|
|
func recycleHistoryToQueue() {
|
|
// History is ordered oldest-first, so we insert in that order
|
|
// This puts the first played video at front of queue
|
|
queue.insert(contentsOf: history, at: 0)
|
|
history.removeAll()
|
|
}
|
|
|
|
/// Returns the next video that will be played (first in queue).
|
|
var nextQueuedVideo: QueuedVideo? {
|
|
queue.first
|
|
}
|
|
|
|
}
|
|
|
|
/// Represents a chapter in a video.
|
|
struct VideoChapter: Identifiable, Sendable {
|
|
let id: UUID
|
|
let title: String
|
|
let startTime: TimeInterval
|
|
let endTime: TimeInterval?
|
|
let thumbnailURL: URL?
|
|
|
|
init(
|
|
id: UUID = UUID(),
|
|
title: String,
|
|
startTime: TimeInterval,
|
|
endTime: TimeInterval? = nil,
|
|
thumbnailURL: URL? = nil
|
|
) {
|
|
self.id = id
|
|
self.title = title
|
|
self.startTime = startTime
|
|
self.endTime = endTime
|
|
self.thumbnailURL = thumbnailURL
|
|
}
|
|
|
|
/// Duration of the chapter.
|
|
var duration: TimeInterval? {
|
|
guard let endTime else { return nil }
|
|
return endTime - startTime
|
|
}
|
|
|
|
/// Formatted start time.
|
|
var formattedStartTime: String {
|
|
let totalSeconds = Int(startTime)
|
|
let hours = totalSeconds / 3600
|
|
let minutes = (totalSeconds % 3600) / 60
|
|
let seconds = totalSeconds % 60
|
|
|
|
if hours > 0 {
|
|
return String(format: "%d:%02d:%02d", hours, minutes, seconds)
|
|
} else {
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
}
|
|
}
|