mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 09:49:46 +00:00
When PiP is active, the MPV render view shows a black frame since rendering goes to the PiP sample buffer layer. Overlay the video thumbnail (preferring DeArrow) on top to cover the black area, fading it in/out smoothly when PiP starts/stops.
601 lines
22 KiB
Swift
601 lines
22 KiB
Swift
//
|
|
// MiniPlayerView.swift
|
|
// Yattee
|
|
//
|
|
// Mini player bar shown at bottom of screen during playback.
|
|
//
|
|
|
|
import SwiftUI
|
|
import NukeUI
|
|
|
|
#if os(iOS) || os(macOS)
|
|
struct MiniPlayerView: View {
|
|
@Environment(\.appEnvironment) private var appEnvironment
|
|
|
|
/// Whether this is displayed as a tab bottom accessory (affects styling)
|
|
var isTabAccessory: Bool = false
|
|
|
|
@State private var buttonTapCounts: [UUID: Int] = [:]
|
|
@State private var miniPlayerSettings: MiniPlayerSettings = MiniPlayerSettings.cached
|
|
|
|
private var playerService: PlayerService? { appEnvironment?.playerService }
|
|
private var playerState: PlayerState? { playerService?.state }
|
|
private var navigationCoordinator: NavigationCoordinator? { appEnvironment?.navigationCoordinator }
|
|
private var deArrowProvider: DeArrowBrandingProvider? { appEnvironment?.deArrowBrandingProvider }
|
|
private var queueManager: QueueManager? { appEnvironment?.queueManager }
|
|
private var playerControlsLayoutService: PlayerControlsLayoutService? { appEnvironment?.playerControlsLayoutService }
|
|
private var settingsManager: SettingsManager? { appEnvironment?.settingsManager }
|
|
|
|
private var currentVideo: Video? { playerState?.currentVideo }
|
|
|
|
/// Whether PiP is currently active
|
|
private var isPiPActive: Bool { playerState?.pipState == .active }
|
|
|
|
/// Whether video preview should be shown (video ready and player not expanded/expanding)
|
|
private var shouldShowVideoPreview: Bool {
|
|
guard let state = playerState else { return false }
|
|
guard let nav = navigationCoordinator else { return false }
|
|
// Check if video preview is enabled in settings
|
|
guard miniPlayerSettings.showVideo else { return false }
|
|
// Don't show video preview for audio-only streams - keep thumbnail visible
|
|
let isAudioOnly = state.currentStream?.isAudioOnly == true
|
|
guard !isAudioOnly else { return false }
|
|
let isVideoReady = state.isFirstFrameReady && state.isBufferReady
|
|
// Don't show video preview when player is expanded or expanding (but OK during collapse)
|
|
// This prevents stealing the MPV player view from the expanded player during expand
|
|
let isPlayerActive = nav.isPlayerExpanded || nav.isPlayerExpanding
|
|
return isVideoReady && !isPlayerActive
|
|
}
|
|
|
|
/// Whether we should include the MPVRenderView in the hierarchy (even if hidden)
|
|
/// During collapse, we want the view mounted so it can receive the player view immediately
|
|
private var shouldMountVideoView: Bool {
|
|
guard playerService?.currentBackend is MPVBackend else { return false }
|
|
guard let nav = navigationCoordinator else { return false }
|
|
// Don't mount video view if setting is disabled
|
|
guard miniPlayerSettings.showVideo else { return false }
|
|
// Mount when: not expanding AND (collapsing OR should show preview)
|
|
// This ensures the container exists during collapse to receive the player view
|
|
return !nav.isPlayerExpanding && (nav.isPlayerCollapsing || shouldShowVideoPreview)
|
|
}
|
|
|
|
/// The title to display, preferring DeArrow title if available.
|
|
private var displayTitle: String {
|
|
if let video = currentVideo, let deArrowTitle = deArrowProvider?.title(for: video) {
|
|
return deArrowTitle
|
|
}
|
|
return currentVideo?.title ?? String(localized: "player.notPlaying")
|
|
}
|
|
|
|
/// The thumbnail URL to display, preferring DeArrow thumbnail if available.
|
|
private var displayThumbnailURL: URL? {
|
|
if let video = currentVideo, let deArrowThumbnail = deArrowProvider?.thumbnailURL(for: video) {
|
|
return deArrowThumbnail
|
|
}
|
|
return currentVideo?.bestThumbnail?.url
|
|
}
|
|
|
|
// MARK: - Actions
|
|
|
|
/// Handle tap on video preview - action based on settings (PiP or expand)
|
|
/// When video is disabled, always expand player since PiP needs the video view mounted
|
|
private func handleVideoPreviewTap() {
|
|
// If video is disabled in mini player, always expand (can't start PiP without video view)
|
|
guard miniPlayerSettings.showVideo else {
|
|
navigationCoordinator?.expandPlayer()
|
|
return
|
|
}
|
|
|
|
switch miniPlayerSettings.videoTapAction {
|
|
case .startPiP:
|
|
if let backend = playerService?.currentBackend as? MPVBackend {
|
|
backend.startPiP()
|
|
} else {
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
case .expandPlayer:
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
}
|
|
|
|
/// Expand player, restoring from PiP first if needed
|
|
/// If PiP is active, stops PiP and waits for it to close before expanding
|
|
private func expandPlayerWithPiPRestore() {
|
|
if let backend = playerService?.currentBackend as? MPVBackend, backend.isPiPActive {
|
|
// Stop PiP first, wait for it to close, then expand
|
|
backend.stopPiP()
|
|
Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(400))
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
} else {
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if isTabAccessory {
|
|
accessoryLayout
|
|
} else {
|
|
overlayLayout
|
|
}
|
|
}
|
|
.accessibilityIdentifier("player.miniPlayer")
|
|
.accessibilityLabel("player.miniPlayer")
|
|
.modifier(MiniPlayerContextMenuModifier(video: currentVideo) {
|
|
queueManager?.clearQueue()
|
|
playerService?.stop()
|
|
})
|
|
.task {
|
|
await loadMiniPlayerSettings()
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playerControlsActivePresetDidChange)) { _ in
|
|
Task { await loadMiniPlayerSettings() }
|
|
}
|
|
.onReceive(NotificationCenter.default.publisher(for: .playerControlsPresetsDidChange)) { _ in
|
|
Task { await loadMiniPlayerSettings() }
|
|
}
|
|
}
|
|
|
|
/// Loads mini player settings from the active preset.
|
|
private func loadMiniPlayerSettings() async {
|
|
guard let layoutService = playerControlsLayoutService else { return }
|
|
let layout = await layoutService.activeLayout()
|
|
await MainActor.run {
|
|
miniPlayerSettings = layout.effectiveMiniPlayerSettings
|
|
MiniPlayerSettings.cached = layout.effectiveMiniPlayerSettings
|
|
GlobalLayoutSettings.cached = layout.globalSettings
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Accessory Layout (iOS 26+)
|
|
|
|
private var accessoryLayout: some View {
|
|
HStack(spacing: 8) {
|
|
// Video preview - tap for PiP (or expand if PiP unavailable)
|
|
videoPreviewView
|
|
.frame(width: 64, height: 36)
|
|
.clipShape(RoundedRectangle(cornerRadius: 6))
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
handleVideoPreviewTap()
|
|
}
|
|
|
|
// Title/author - tap to expand player (restores from PiP first if needed)
|
|
Button {
|
|
expandPlayerWithPiPRestore()
|
|
} label: {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
MarqueeText(
|
|
text: displayTitle,
|
|
font: .subheadline,
|
|
fontWeight: .medium,
|
|
foregroundStyle: .primary
|
|
)
|
|
|
|
if let authorName = currentVideo?.author.name, !authorName.isEmpty {
|
|
MarqueeText(
|
|
text: authorName,
|
|
font: .caption,
|
|
foregroundStyle: .secondary
|
|
)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
// Dynamic buttons from settings
|
|
buttonsView
|
|
}
|
|
.padding(.leading, 12)
|
|
.padding(.trailing, 8)
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
// MARK: - Overlay Layout
|
|
|
|
private var overlayLayout: some View {
|
|
HStack(spacing: 12) {
|
|
// Video preview - tap for PiP (or expand if PiP unavailable)
|
|
videoPreviewView
|
|
.frame(width: 60, height: 34)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
handleVideoPreviewTap()
|
|
}
|
|
|
|
// Title/author area - tap to expand player (restores from PiP first if needed)
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
MarqueeText(
|
|
text: displayTitle,
|
|
font: .subheadline,
|
|
fontWeight: .medium,
|
|
foregroundStyle: .primary
|
|
)
|
|
|
|
if let authorName = currentVideo?.author.name, !authorName.isEmpty {
|
|
MarqueeText(
|
|
text: authorName,
|
|
font: .caption,
|
|
foregroundStyle: .secondary
|
|
)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
expandPlayerWithPiPRestore()
|
|
}
|
|
|
|
Spacer()
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
expandPlayerWithPiPRestore()
|
|
}
|
|
|
|
// Dynamic buttons from settings
|
|
buttonsView
|
|
|
|
// Always show close button in overlay layout
|
|
closeButton
|
|
}
|
|
.padding(.horizontal)
|
|
.padding(.vertical, 8)
|
|
.background(.ultraThinMaterial)
|
|
.overlay(alignment: .bottom) {
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(.red)
|
|
.frame(width: geo.size.width * (playerState?.progress ?? 0), height: 2)
|
|
}
|
|
.frame(height: 2)
|
|
}
|
|
}
|
|
|
|
// MARK: - Shared Components
|
|
|
|
/// Video preview that shows live video when available, or thumbnail as fallback.
|
|
@ViewBuilder
|
|
private var videoPreviewView: some View {
|
|
ZStack {
|
|
// Video layer - mounted during collapse or when ready to show
|
|
// Keep it in hierarchy during collapse so the container can receive the player view
|
|
if let backend = playerService?.currentBackend as? MPVBackend,
|
|
let playerState,
|
|
shouldMountVideoView {
|
|
MPVRenderViewRepresentable(backend: backend, playerState: playerState)
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
// Thumbnail layer - on top so it can cover the black MPV view during PiP
|
|
// Shown when video not ready, during expand animation, or during PiP
|
|
thumbnailView
|
|
.opacity(shouldShowVideoPreview && !isPiPActive ? 0 : 1)
|
|
.animation(.easeInOut(duration: 0.15), value: shouldShowVideoPreview)
|
|
.animation(.easeInOut(duration: 0.25), value: isPiPActive)
|
|
.allowsHitTesting(false)
|
|
}
|
|
.onChange(of: navigationCoordinator?.isPlayerCollapsing) { _, isCollapsing in
|
|
// When collapse animation finishes, resume rendering if video preview should be shown
|
|
// This must happen AFTER playerSheetDidDisappear pauses rendering
|
|
if isCollapsing == false, shouldShowVideoPreview,
|
|
let backend = playerService?.currentBackend as? MPVBackend {
|
|
backend.resumeRendering()
|
|
}
|
|
}
|
|
.onChange(of: shouldShowVideoPreview) { _, showPreview in
|
|
// Resume/pause rendering based on whether mini player video is visible
|
|
// Only act when not in the middle of collapse animation
|
|
if navigationCoordinator?.isPlayerCollapsing == false {
|
|
if let backend = playerService?.currentBackend as? MPVBackend {
|
|
if showPreview {
|
|
backend.resumeRendering()
|
|
} else {
|
|
backend.pauseRendering()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var thumbnailView: some View {
|
|
LazyImage(url: displayThumbnailURL) { state in
|
|
if let image = state.image {
|
|
image
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fill)
|
|
} else {
|
|
Rectangle()
|
|
.fill(.quaternary)
|
|
.overlay {
|
|
Image(systemName: "play.rectangle")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Dynamic Buttons
|
|
|
|
/// Renders buttons from miniPlayerSettings.buttons
|
|
@ViewBuilder
|
|
private var buttonsView: some View {
|
|
ForEach(miniPlayerSettings.buttons) { config in
|
|
miniPlayerButton(for: config)
|
|
}
|
|
}
|
|
|
|
/// Renders a single button based on its configuration
|
|
@ViewBuilder
|
|
private func miniPlayerButton(for config: ControlButtonConfiguration) -> some View {
|
|
switch config.buttonType {
|
|
case .playPause:
|
|
playPauseButton(config: config)
|
|
case .playNext:
|
|
playNextButton(config: config)
|
|
case .playPrevious:
|
|
playPreviousButton(config: config)
|
|
case .seek:
|
|
seekButton(config: config)
|
|
case .close:
|
|
closeButton
|
|
case .queue:
|
|
queueButton(config: config)
|
|
case .share:
|
|
shareButton(config: config)
|
|
case .addToPlaylist:
|
|
addToPlaylistButton(config: config)
|
|
case .airplay:
|
|
airplayButton
|
|
case .pictureInPicture:
|
|
pipButton(config: config)
|
|
case .playbackSpeed:
|
|
playbackSpeedButton(config: config)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
// MARK: - Individual Button Views
|
|
|
|
private func playPauseButton(config: ControlButtonConfiguration) -> some View {
|
|
Button {
|
|
incrementTapCount(for: config)
|
|
playerService?.togglePlayPause()
|
|
} label: {
|
|
Image(systemName: playPauseIcon)
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
.contentTransition(.symbolEffect(.replace, options: .speed(2)))
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(isTransportDisabled)
|
|
}
|
|
|
|
private func playNextButton(config: ControlButtonConfiguration) -> some View {
|
|
Button {
|
|
incrementTapCount(for: config)
|
|
Task { await playerService?.playNext() }
|
|
} label: {
|
|
Image(systemName: "forward.fill")
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: buttonTapCounts[config.id] ?? 0)
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
.foregroundStyle(playerState?.hasNext == true ? .primary : .tertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(playerState?.hasNext != true)
|
|
}
|
|
|
|
private func playPreviousButton(config: ControlButtonConfiguration) -> some View {
|
|
Button {
|
|
incrementTapCount(for: config)
|
|
Task { await playerService?.playPrevious() }
|
|
} label: {
|
|
Image(systemName: "backward.fill")
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: buttonTapCounts[config.id] ?? 0)
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
.foregroundStyle(playerState?.hasPrevious == true ? .primary : .tertiary)
|
|
}
|
|
.buttonStyle(.plain)
|
|
.disabled(playerState?.hasPrevious != true)
|
|
}
|
|
|
|
private func seekButton(config: ControlButtonConfiguration) -> some View {
|
|
let settings = config.seekSettings ?? SeekSettings()
|
|
return Button {
|
|
incrementTapCount(for: config)
|
|
playerService?.seek(seconds: Double(settings.seconds), direction: settings.direction)
|
|
} label: {
|
|
Image(systemName: settings.systemImage)
|
|
.symbolEffect(.bounce.down.byLayer, options: .nonRepeating, value: buttonTapCounts[config.id] ?? 0)
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private var closeButton: some View {
|
|
Button {
|
|
queueManager?.clearQueue()
|
|
playerService?.stop()
|
|
} label: {
|
|
Image(systemName: "xmark")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func queueButton(config: ControlButtonConfiguration) -> some View {
|
|
Button {
|
|
incrementTapCount(for: config)
|
|
navigationCoordinator?.isMiniPlayerQueueSheetPresented = true
|
|
} label: {
|
|
Image(systemName: "list.bullet")
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func shareButton(config: ControlButtonConfiguration) -> some View {
|
|
if let video = currentVideo {
|
|
ShareLink(item: video.shareURL) {
|
|
Image(systemName: "square.and.arrow.up")
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private func addToPlaylistButton(config: ControlButtonConfiguration) -> some View {
|
|
Button {
|
|
incrementTapCount(for: config)
|
|
navigationCoordinator?.isMiniPlayerPlaylistSheetPresented = true
|
|
} label: {
|
|
Image(systemName: "text.badge.plus")
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var airplayButton: some View {
|
|
#if os(iOS)
|
|
AirPlayButton(tintColor: .label)
|
|
.frame(width: 32, height: 32)
|
|
#elseif os(macOS)
|
|
AirPlayButton()
|
|
.frame(width: 32, height: 32)
|
|
#endif
|
|
}
|
|
|
|
private func pipButton(config: ControlButtonConfiguration) -> some View {
|
|
Button {
|
|
incrementTapCount(for: config)
|
|
if let backend = playerService?.currentBackend as? MPVBackend {
|
|
backend.startPiP()
|
|
}
|
|
} label: {
|
|
Image(systemName: "pip.enter")
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
private func playbackSpeedButton(config: ControlButtonConfiguration) -> some View {
|
|
let currentRate = playerState?.rate ?? .x1
|
|
return Menu {
|
|
ForEach(PlaybackRate.allCases) { rate in
|
|
Button {
|
|
playerState?.rate = rate
|
|
playerService?.currentBackend?.rate = Float(rate.rawValue)
|
|
} label: {
|
|
HStack {
|
|
Text(rate.displayText)
|
|
if currentRate == rate {
|
|
Spacer()
|
|
Image(systemName: "checkmark")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Text(currentRate.compactDisplayText)
|
|
.font(.system(size: 12, weight: .semibold))
|
|
.foregroundStyle(.primary)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.menuIndicator(.hidden)
|
|
.tint(.primary)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
/// Whether transport controls should be disabled (during loading/buffering or buffer not ready)
|
|
private var isTransportDisabled: Bool {
|
|
guard let state = playerState else { return true }
|
|
return state.playbackState == .loading ||
|
|
state.playbackState == .buffering ||
|
|
!state.isFirstFrameReady ||
|
|
!state.isBufferReady
|
|
}
|
|
|
|
private var playPauseIcon: String {
|
|
switch playerState?.playbackState {
|
|
case .playing:
|
|
return "pause.fill"
|
|
default:
|
|
return "play.fill"
|
|
}
|
|
}
|
|
|
|
private func incrementTapCount(for config: ControlButtonConfiguration) {
|
|
buttonTapCounts[config.id, default: 0] += 1
|
|
}
|
|
}
|
|
|
|
// MARK: - Context Menu Modifier
|
|
|
|
/// A view modifier that conditionally applies the video context menu.
|
|
/// Only applies the context menu when a video is available.
|
|
private struct MiniPlayerContextMenuModifier: ViewModifier {
|
|
let video: Video?
|
|
let closeAction: () -> Void
|
|
|
|
func body(content: Content) -> some View {
|
|
if let video {
|
|
content.videoContextMenu(
|
|
video: video,
|
|
customActions: [
|
|
VideoContextAction(
|
|
String(localized: "player.close"),
|
|
systemImage: "xmark",
|
|
role: .destructive,
|
|
action: closeAction
|
|
)
|
|
],
|
|
context: .player
|
|
)
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
#Preview("Overlay Mode") {
|
|
VStack {
|
|
Spacer()
|
|
MiniPlayerView(isTabAccessory: false)
|
|
}
|
|
.appEnvironment(.preview)
|
|
}
|
|
|
|
#Preview("Accessory Mode") {
|
|
MiniPlayerView(isTabAccessory: true)
|
|
.appEnvironment(.preview)
|
|
}
|
|
#endif
|