Files
yattee/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift
2026-02-08 18:33:56 +01:00

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