Files
yattee/Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift
Arkadiusz Fal 3e561cc3b5 Present error details and Open Link through the shared sheet host
Error details were declared in the video info view and the expanded
player, and Open Link three times: at the app root for deep links and
for the File menu command, and on Home. All of them are now AppSheets;
the deep link and menu requests go through the global request, so a
deep link that arrives while the player is expanded no longer opens the
sheet behind it.

The four sign-in modals that were a full-screen cover on tvOS and a
sheet elsewhere, each written out twice, use a platformSheet helper.
2026-10-04 21:04:19 +02:00

226 lines
8.5 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.
///
/// The video this offers has finished, so playing it again is a new watch —
/// which is the player's to know about, not the overlay's.
func restartPlayback() {
guard let playerService else { return }
Task {
await playerService.replayCurrentVideo()
}
}
/// 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 {
presentSheet(.errorDetails(playerState?.errorMessage ?? "Unknown error"))
} 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