mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
670
Yattee/Services/Player/PlayerState.swift
Normal file
670
Yattee/Services/Player/PlayerState.swift
Normal file
@@ -0,0 +1,670 @@
|
||||
//
|
||||
// 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 String(format: "%.2gx", rawValue)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
String(format: "%.2gx", rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
||||
/// 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 formatTime(currentTime)
|
||||
}
|
||||
|
||||
/// Formatted duration string.
|
||||
var formattedDuration: String {
|
||||
// For live streams, show "LIVE" instead of duration
|
||||
if isLive {
|
||||
return "LIVE"
|
||||
}
|
||||
return formatTime(duration)
|
||||
}
|
||||
|
||||
/// Formatted remaining time string.
|
||||
var formattedRemainingTime: String {
|
||||
"-" + formatTime(max(0, duration - currentTime))
|
||||
}
|
||||
|
||||
// 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] = []
|
||||
|
||||
/// Current chapter based on playback time.
|
||||
var currentChapter: VideoChapter? {
|
||||
chapters.last { $0.startTime <= currentTime }
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 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 = []
|
||||
}
|
||||
currentVideo = video
|
||||
currentStream = stream
|
||||
currentAudioStream = audioStream
|
||||
if video == nil {
|
||||
reset()
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func formatTime(_ time: TimeInterval) -> String {
|
||||
let totalSeconds = Int(time)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user