mirror of
https://github.com/yattee/yattee.git
synced 2026-04-10 09:36:58 +00:00
Yattee v2 rewrite
This commit is contained in:
223
Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift
Normal file
223
Yattee/Views/Player/ExpandedPlayerSheet+Overlays.swift
Normal file
@@ -0,0 +1,223 @@
|
||||
//
|
||||
// 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
|
||||
Reference in New Issue
Block a user