mirror of
https://github.com/yattee/yattee.git
synced 2026-02-19 17:29:45 +00:00
224 lines
8.3 KiB
Swift
224 lines
8.3 KiB
Swift
//
|
|
// ExpandedPlayerSheet+Overlays.swift
|
|
// Yattee
|
|
//
|
|
// Overlay views and playback actions for the expanded player sheet.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS) || os(tvOS)
|
|
|
|
extension ExpandedPlayerSheet {
|
|
// MARK: - Playback Actions
|
|
|
|
/// Starts playback of the current video.
|
|
func startPlayback() {
|
|
guard let playerService, let video = playerState?.currentVideo else { return }
|
|
|
|
Task {
|
|
await playerService.playPreferringDownloaded(video: video)
|
|
}
|
|
}
|
|
|
|
/// Restarts playback from the beginning.
|
|
func restartPlayback() {
|
|
guard let playerService else { return }
|
|
|
|
Task {
|
|
await playerService.seek(to: 0)
|
|
playerService.resume()
|
|
}
|
|
}
|
|
|
|
/// Retries playback after a failure.
|
|
func retryPlayback() {
|
|
guard let playerService, let video = playerState?.currentVideo else { return }
|
|
|
|
Task {
|
|
await playerService.play(video: video)
|
|
}
|
|
}
|
|
|
|
/// Tries to play a fallback stream.
|
|
func tryFallbackStream(_ stream: Stream) {
|
|
guard let playerService else { return }
|
|
|
|
Task {
|
|
await playerService.switchToOnlineStream(stream, audioStream: nil)
|
|
}
|
|
}
|
|
|
|
/// Returns the first muxed stream available.
|
|
var firstMuxedStream: Stream? {
|
|
playerService?.availableStreams.first { $0.isMuxed }
|
|
}
|
|
|
|
// MARK: - Thumbnail Overlay Content
|
|
|
|
/// Main overlay content that switches based on playback state.
|
|
@ViewBuilder
|
|
func thumbnailOverlayContent(
|
|
isIdle: Bool,
|
|
isEnded: Bool,
|
|
isFailed: Bool,
|
|
isLoading: Bool
|
|
) -> some View {
|
|
ZStack {
|
|
if isIdle {
|
|
PlayerOverlayButton(icon: "play.fill", action: startPlayback)
|
|
.transition(.opacity)
|
|
} else if isEnded {
|
|
endedOverlay
|
|
.transition(.opacity)
|
|
} else if isFailed {
|
|
loadFailedOverlay
|
|
.transition(.opacity)
|
|
} else if playerState?.retryState.exhausted == true {
|
|
retryExhaustedOverlay
|
|
.transition(.opacity)
|
|
} else if isLoading {
|
|
loadingOverlay
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.3), value: isIdle)
|
|
.animation(.easeInOut(duration: 0.3), value: isEnded)
|
|
.animation(.easeInOut(duration: 0.3), value: isFailed)
|
|
.animation(.easeInOut(duration: 0.3), value: isLoading)
|
|
}
|
|
|
|
// MARK: - State Overlays
|
|
|
|
/// Overlay shown while video is loading.
|
|
@ViewBuilder
|
|
var loadingOverlay: some View {
|
|
// Don't show buffer progress for downloaded videos - local files load quickly
|
|
let showBufferProgress = playerService?.currentDownload == nil
|
|
LoadingOverlayView(
|
|
bufferProgress: showBufferProgress ? playerState?.bufferProgress : nil
|
|
)
|
|
}
|
|
|
|
/// Overlay shown when video fails to load.
|
|
@ViewBuilder
|
|
var loadFailedOverlay: some View {
|
|
Color.black.opacity(0.4)
|
|
VStack(spacing: 16) {
|
|
// Error details button
|
|
Button {
|
|
showingErrorSheet = true
|
|
} label: {
|
|
Label(String(localized: "player.error.button"), systemImage: "info.circle")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.glassBackground(.regular, in: .capsule, fallback: .ultraThinMaterial)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.environment(\.colorScheme, .dark)
|
|
.accessibilityLabel(String(localized: "player.error.showDetails.accessibilityLabel"))
|
|
|
|
// Retry and Close buttons side by side
|
|
HStack(spacing: 12) {
|
|
// Retry button
|
|
Button(action: retryPlayback) {
|
|
Label(String(localized: "player.error.retry"), systemImage: "arrow.clockwise")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.glassBackground(.regular, in: .capsule, fallback: .ultraThinMaterial)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.environment(\.colorScheme, .dark)
|
|
.accessibilityLabel(String(localized: "player.error.retry.accessibilityLabel"))
|
|
|
|
// Play Next button (when queue has next video) or Close button (when queue is empty)
|
|
if nextQueuedVideo != nil {
|
|
Button {
|
|
playNextInQueue()
|
|
} label: {
|
|
Label(String(localized: "player.autoplay.playNext"), systemImage: "forward.fill")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.glassBackground(.regular, in: .capsule, fallback: .ultraThinMaterial)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.environment(\.colorScheme, .dark)
|
|
.accessibilityLabel(String(localized: "player.autoplay.playNext"))
|
|
} else {
|
|
Button {
|
|
closeVideo()
|
|
} label: {
|
|
Label(String(localized: "player.close"), systemImage: "xmark")
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.white.opacity(0.8))
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.glassBackground(.regular, in: .capsule, fallback: .ultraThinMaterial)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.environment(\.colorScheme, .dark)
|
|
.accessibilityLabel(String(localized: "player.close.accessibilityLabel"))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Overlay shown when retry attempts are exhausted.
|
|
@ViewBuilder
|
|
var retryExhaustedOverlay: some View {
|
|
Color.black.opacity(0.4)
|
|
VStack(spacing: 16) {
|
|
PlayerOverlayButton(icon: "arrow.clockwise", action: retryPlayback)
|
|
Text(String(localized: "player.retry.button"))
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.9))
|
|
|
|
// Fallback muxed stream button (if available and different from current)
|
|
if let fallbackStream = firstMuxedStream,
|
|
fallbackStream.id != playerState?.currentStream?.id {
|
|
Button(action: { tryFallbackStream(fallbackStream) }) {
|
|
Text(String(localized: "player.retry.tryResolution \(fallbackStream.resolution?.description ?? "")"))
|
|
.font(.subheadline.weight(.medium))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 8)
|
|
.background(.ultraThinMaterial)
|
|
.clipShape(Capsule())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Overlay shown when video playback ends.
|
|
@ViewBuilder
|
|
var endedOverlay: some View {
|
|
let showCountdown = isAutoPlayEnabled && nextQueuedVideo != nil && !isAutoplayCancelled && autoplayCountdown > 0
|
|
let relatedVideos = playerState?.currentVideo?.relatedVideos
|
|
|
|
Color.black.opacity(0.4)
|
|
|
|
if showCountdown, let nextVideo = nextQueuedVideo {
|
|
// Autoplay countdown UI
|
|
autoplayCountdownOverlay(nextVideo: nextVideo)
|
|
} else if let videos = relatedVideos, !videos.isEmpty {
|
|
// Show recommended videos carousel with replay/close buttons
|
|
recommendedVideosOverlay(videos: videos)
|
|
} else if nextQueuedVideo == nil && isQueueEnabled && hasQueueItems == false {
|
|
// End of queue - show message and replay button
|
|
endOfQueueOverlay
|
|
} else {
|
|
// No autoplay or cancelled - show replay button and close button
|
|
replayWithCloseButtons
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|