mirror of
https://github.com/yattee/yattee.git
synced 2026-02-20 01:39:46 +00:00
Yattee v2 rewrite
This commit is contained in:
595
Yattee/Views/Player/MiniPlayerView.swift
Normal file
595
Yattee/Views/Player/MiniPlayerView.swift
Normal file
@@ -0,0 +1,595 @@
|
||||
//
|
||||
// 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 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 {
|
||||
// Thumbnail layer - shown when video not ready or during expand animation
|
||||
thumbnailView
|
||||
.opacity(shouldShowVideoPreview ? 0 : 1)
|
||||
.animation(.easeInOut(duration: 0.15), value: shouldShowVideoPreview)
|
||||
|
||||
// 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)
|
||||
// No animation on video opacity - show immediately when ready
|
||||
}
|
||||
}
|
||||
.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
|
||||
Reference in New Issue
Block a user