mirror of
https://github.com/yattee/yattee.git
synced 2026-10-23 21:22:53 +00:00
Wide videos showed letterbox bars in the tiny iOS mini player preview right after the dismiss morph, which lands the video filling the preview. The container that takes the shared player view now sets mpv's keepaspect, so the mini player fills its frame and every other surface keeps the aspect ratio.
746 lines
28 KiB
Swift
746 lines
28 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
|
|
@Environment(\.presentAppSheet) private var presentAppSheet
|
|
|
|
/// 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 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 the thumbnail is hidden: live video shows instead, or the
|
|
/// expanded player is morphing its video into this preview.
|
|
private var isThumbnailHidden: Bool {
|
|
if navigationCoordinator?.isPlayerMorphingToMini == true { return true }
|
|
return shouldShowVideoPreview && !isPiPActive
|
|
}
|
|
|
|
/// 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
|
|
// (not while still expanded: the mini player stays mounted under the
|
|
// player window, and a slide-off dismiss collapses before un-expanding)
|
|
return !nav.isPlayerExpanding && ((nav.isPlayerCollapsing && !nav.isPlayerExpanded) || 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")
|
|
}
|
|
|
|
/// Thumbnail URLs to try in order, preferring DeArrow, then the quality chain.
|
|
private var displayThumbnailURLs: [URL] {
|
|
guard let video = currentVideo else { return [] }
|
|
let deArrowThumbnail = deArrowProvider?.thumbnailURL(for: video)
|
|
return [deArrowThumbnail].compactMap { $0 } + video.thumbnailURLsByQuality
|
|
}
|
|
|
|
// 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)
|
|
capsuleLayout
|
|
#else
|
|
if isTabAccessory {
|
|
accessoryLayout
|
|
} else {
|
|
capsuleLayout
|
|
}
|
|
#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))
|
|
.reportsMiniPlayerVideoFrame(cornerRadius: 6, navigationCoordinator: navigationCoordinator)
|
|
.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: - Capsule Layout (macOS + iOS 18 overlay)
|
|
|
|
/// Preview size for the capsule: slightly larger on iOS for touch.
|
|
private var capsulePreviewSize: CGSize {
|
|
#if os(macOS)
|
|
CGSize(width: 44, height: 26)
|
|
#else
|
|
CGSize(width: 56, height: 32)
|
|
#endif
|
|
}
|
|
|
|
/// Compact, centered capsule (Music.app style). On macOS it floats in the main
|
|
/// window alongside the separate player window; on iOS 18 (no tab accessory)
|
|
/// it floats above the tab bar with the same width limit.
|
|
private var capsuleLayout: some View {
|
|
HStack(spacing: 10) {
|
|
// Video preview - tap for PiP (or expand if PiP unavailable)
|
|
videoPreviewView
|
|
.frame(width: capsulePreviewSize.width, height: capsulePreviewSize.height)
|
|
.clipShape(RoundedRectangle(cornerRadius: 4))
|
|
.reportsMiniPlayerVideoFrame(cornerRadius: 4, navigationCoordinator: navigationCoordinator)
|
|
.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
|
|
)
|
|
|
|
authorLine(font: .caption)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.contentShape(Rectangle())
|
|
.onTapGesture {
|
|
expandPlayerWithPiPRestore()
|
|
}
|
|
|
|
// Dynamic buttons from settings (close is part of the preset)
|
|
buttonsView
|
|
|
|
// Safety net: keep a way to close when the preset has no close button
|
|
if !miniPlayerSettings.buttons.contains(where: { $0.buttonType == .close }) {
|
|
closeButton
|
|
}
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.frame(maxWidth: 420)
|
|
.glassBackground(.regular, in: .capsule, fallback: .regularMaterial)
|
|
.overlay(alignment: .bottom) {
|
|
progressLine
|
|
}
|
|
.clipShape(Capsule())
|
|
.shadow(color: .black.opacity(0.25), radius: 8, y: 4)
|
|
}
|
|
|
|
// MARK: - Shared Components
|
|
|
|
/// Author line with a red LIVE indicator for live streams.
|
|
/// MarqueeText draws its text at the top of a fixed-height frame, so the badge
|
|
/// is top-aligned with it (dot stays centered against the LIVE text only).
|
|
@ViewBuilder
|
|
private func authorLine(font: Font) -> some View {
|
|
let authorName = currentVideo?.author.name ?? ""
|
|
if playerState?.isLive == true {
|
|
HStack(alignment: .top, spacing: 4) {
|
|
HStack(spacing: 4) {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 6, height: 6)
|
|
Text(String(localized: "player.live"))
|
|
.font(font.weight(.semibold))
|
|
.foregroundStyle(.red)
|
|
}
|
|
.fixedSize()
|
|
|
|
if !authorName.isEmpty {
|
|
MarqueeText(
|
|
text: authorName,
|
|
font: font,
|
|
foregroundStyle: .secondary
|
|
)
|
|
}
|
|
}
|
|
} else if !authorName.isEmpty {
|
|
MarqueeText(
|
|
text: authorName,
|
|
font: font,
|
|
foregroundStyle: .secondary
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Bottom progress line; hidden for live streams where progress is meaningless.
|
|
@ViewBuilder
|
|
private var progressLine: some View {
|
|
if playerState?.isLive != true {
|
|
GeometryReader { geo in
|
|
Rectangle()
|
|
.fill(.red)
|
|
.frame(width: geo.size.width * (playerState?.progress ?? 0), height: 2)
|
|
}
|
|
.frame(height: 2)
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
#if os(iOS)
|
|
MPVRenderViewRepresentable(backend: backend, playerState: playerState, fillsFrame: true)
|
|
.allowsHitTesting(false)
|
|
#else
|
|
MPVRenderViewRepresentable(backend: backend, playerState: playerState)
|
|
.allowsHitTesting(false)
|
|
#endif
|
|
}
|
|
|
|
// 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(isThumbnailHidden ? 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 {
|
|
FallbackLazyImage(urls: displayThumbnailURLs) { 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)
|
|
case .audioMode:
|
|
audioModeButton(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)
|
|
presentAppSheet(.queue(halfHeight: true))
|
|
} label: {
|
|
ZStack(alignment: .bottom) {
|
|
Image(systemName: "list.bullet")
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
|
|
// Badge showing queue count
|
|
let queueCount = playerState?.queue.count ?? 0
|
|
if queueCount > 0 {
|
|
Text("\(queueCount)")
|
|
.font(.system(size: 9, weight: .bold))
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 4)
|
|
.padding(.vertical, 1)
|
|
.background(Color.accentColor, in: Capsule())
|
|
.offset(y: 4)
|
|
.allowsHitTesting(false)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func shareButton(config: ControlButtonConfiguration) -> some View {
|
|
if let video = currentVideo {
|
|
ShareLink(item: appEnvironment?.shareURL(for: video) ?? 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)
|
|
if let video = appEnvironment?.playerService.state.currentVideo {
|
|
presentAppSheet(.addToPlaylist(video, halfHeight: 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)
|
|
}
|
|
|
|
private func audioModeButton(config: ControlButtonConfiguration) -> some View {
|
|
let isAudioMode = appEnvironment?.settingsManager.audioOnlyModeEnabled ?? false
|
|
return Button {
|
|
incrementTapCount(for: config)
|
|
Task { await playerService?.setAudioMode(!isAudioMode) }
|
|
} label: {
|
|
Image(systemName: "music.note")
|
|
.font(isTabAccessory ? .title3 : .title2)
|
|
.frame(width: 32, height: 32)
|
|
.contentShape(Rectangle())
|
|
.foregroundStyle(isAudioMode ? AnyShapeStyle(Color.red) : AnyShapeStyle(.primary))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
|
|
// 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: - Video Frame Reporting
|
|
|
|
private extension View {
|
|
/// Publishes the video preview's frame as the expanded player's dismiss
|
|
/// morph target (iOS only; macOS's player is a separate window or overlay).
|
|
@ViewBuilder
|
|
func reportsMiniPlayerVideoFrame(cornerRadius: CGFloat, navigationCoordinator: NavigationCoordinator?) -> some View {
|
|
#if os(iOS)
|
|
onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame in
|
|
guard let navigationCoordinator, navigationCoordinator.miniPlayerVideoFrame != frame else { return }
|
|
navigationCoordinator.miniPlayerVideoFrame = frame
|
|
navigationCoordinator.miniPlayerVideoCornerRadius = cornerRadius
|
|
}
|
|
.onDisappear {
|
|
navigationCoordinator?.miniPlayerVideoFrame = .zero
|
|
}
|
|
#else
|
|
self
|
|
#endif
|
|
}
|
|
}
|
|
|
|
// 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
|