Files
yattee/Yattee/Views/Player/MiniPlayerView.swift
Arkadiusz Fal 8e48097a5a Prevent starting PiP from mini player bar while player window is open
Tapping the mini bar video thumbnail (with tap action set to PiP) or its
PiP button while the expanded player window was open started PiP playing
alongside the window. Bring the player window to the front instead when
it is open or opening.
2026-06-09 07:28:09 +02:00

684 lines
25 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() {
// Don't start PiP while the expanded player is open (or opening) PiP
// would play alongside the player window. Bring the window forward instead.
if let nav = navigationCoordinator, nav.isPlayerExpanded || nav.isPlayerExpanding {
nav.expandPlayer()
return
}
// 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()
}
}
/// Closes the current video: stops playback, clears the queue, and dismisses
/// the expanded player (so the separate macOS player window is hidden too).
private func closeVideo() {
playerState?.isClosingVideo = true
queueManager?.clearQueue()
// Stop the player before dismissing the window so the backend is torn down first.
playerService?.stop()
navigationCoordinator?.isPlayerExpanded = false
}
/// 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 os(macOS)
macOSCapsuleLayout
#else
if isTabAccessory {
accessoryLayout
} else {
overlayLayout
}
#endif
}
.accessibilityIdentifier("player.miniPlayer")
.accessibilityLabel("player.miniPlayer")
.modifier(MiniPlayerContextMenuModifier(video: currentVideo) {
closeVideo()
})
.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: - macOS Capsule Layout
#if os(macOS)
/// Compact, centered capsule shown in the main window on macOS (Music.app style).
/// The expanded player lives in a separate window, so this stays visible alongside it.
private var macOSCapsuleLayout: some View {
HStack(spacing: 10) {
// Video preview - tap for PiP (or expand if PiP unavailable)
videoPreviewView
.frame(width: 44, height: 26)
.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: 1) {
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()
}
// Dynamic buttons from settings (includes close by default - no separate close button)
buttonsView
}
.padding(.horizontal, 12)
.padding(.vertical, 6)
.frame(maxWidth: 420)
.glassBackground(.regular, in: .capsule, fallback: .regularMaterial)
.overlay(alignment: .bottom) {
GeometryReader { geo in
Rectangle()
.fill(.red)
.frame(width: geo.size.width * (playerState?.progress ?? 0), height: 2)
}
.frame(height: 2)
}
.clipShape(Capsule())
.shadow(color: .black.opacity(0.25), radius: 8, y: 4)
}
#endif
// 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 {
closeVideo()
} 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)
// Don't start PiP while the expanded player is open (or opening) PiP
// would play alongside the player window. Bring the window forward instead.
if let nav = navigationCoordinator, nav.isPlayerExpanded || nav.isPlayerExpanding {
nav.expandPlayer()
return
}
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