mirror of
https://github.com/yattee/yattee.git
synced 2026-08-06 07:11:28 +00:00
Adds a "Load subtitle from file…" row to the Subtitles section on iOS (document picker) and macOS (open panel), available when playing files from local folder, SMB, or WebDAV sources. The picked file is copied into the per-video temp subtitle directory, registered as a selectable Caption (displayed by filename), and activated through the existing loadCaption flow. The Subtitles tab and captions control button now also appear for media-source files without any subtitles.
348 lines
10 KiB
Swift
348 lines
10 KiB
Swift
//
|
|
// PlayerControlsActions.swift
|
|
// Yattee
|
|
//
|
|
// Consolidated actions and state for player control buttons.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
#if os(iOS) || os(macOS)
|
|
|
|
/// Consolidated actions and state for player control buttons.
|
|
/// Used by `ControlsSectionRenderer` (iOS) and `MacOSControlsSectionRenderer`
|
|
/// (macOS) to render buttons dynamically.
|
|
@MainActor
|
|
struct PlayerControlsActions {
|
|
// MARK: - State
|
|
|
|
/// The current player state
|
|
let playerState: PlayerState
|
|
|
|
/// Whether the layout is widescreen (landscape fullscreen)
|
|
let isWideScreenLayout: Bool
|
|
|
|
/// Whether fullscreen mode is active
|
|
let isFullscreen: Bool
|
|
|
|
/// Whether the video is widescreen aspect ratio
|
|
let isWidescreenVideo: Bool
|
|
|
|
#if os(iOS)
|
|
/// Whether orientation is locked
|
|
let isOrientationLocked: Bool
|
|
#endif
|
|
|
|
/// Whether the side panel is visible
|
|
let isPanelVisible: Bool
|
|
|
|
#if os(iOS)
|
|
/// Whether the side panel is pinned (iPad landscape)
|
|
let isPanelPinned: Bool
|
|
#endif
|
|
|
|
/// Which side the panel is on
|
|
let panelSide: FloatingPanelSide
|
|
|
|
#if os(iOS)
|
|
/// Whether running on iPad
|
|
let isIPad: Bool
|
|
#endif
|
|
|
|
/// Whether volume controls should show (mpv mode)
|
|
let showVolumeControls: Bool
|
|
|
|
/// Whether debug button should show
|
|
let showDebugButton: Bool
|
|
|
|
/// Whether close button should show
|
|
let showCloseButton: Bool
|
|
|
|
/// The currently playing video (for share, playlist, context menu)
|
|
let currentVideo: Video?
|
|
|
|
/// Available captions for the current video
|
|
let availableCaptions: [Caption]
|
|
|
|
/// Currently selected caption
|
|
let currentCaption: Caption?
|
|
|
|
/// Available streams for the current video
|
|
let availableStreams: [Stream]
|
|
|
|
/// Current video stream
|
|
let currentStream: Stream?
|
|
|
|
/// Current audio stream
|
|
let currentAudioStream: Stream?
|
|
|
|
/// Embedded (in-container) tracks reported by mpv for the loaded file
|
|
var embeddedAudioTracks: [MPVTrack] = []
|
|
var embeddedSubtitleTracks: [MPVTrack] = []
|
|
var currentEmbeddedAudioTrackID: Int? = nil
|
|
var currentEmbeddedSubtitleTrackID: Int? = nil
|
|
|
|
#if os(iOS)
|
|
/// Current panscan value (0.0 = fit, 1.0 = fill)
|
|
let panscanValue: Double
|
|
|
|
/// Whether panscan change is currently allowed
|
|
let isPanscanAllowed: Bool
|
|
#endif
|
|
|
|
/// Whether auto-play next is enabled
|
|
let isAutoPlayNextEnabled: Bool
|
|
|
|
/// Whether audio-only (music) mode is enabled
|
|
var isAudioModeEnabled: Bool = false
|
|
|
|
/// Yattee Server URL for channel avatar fallback
|
|
let yatteeServerURL: URL?
|
|
|
|
/// DeArrow branding provider for title replacement
|
|
let deArrowBrandingProvider: DeArrowBrandingProvider?
|
|
|
|
// MARK: - Actions
|
|
|
|
/// Close the player
|
|
var onClose: (() -> Void)?
|
|
|
|
/// Toggle debug overlay
|
|
var onToggleDebug: (() -> Void)?
|
|
|
|
/// Toggle Picture-in-Picture
|
|
var onTogglePiP: (() -> Void)?
|
|
|
|
/// Toggle fullscreen mode
|
|
var onToggleFullscreen: (() -> Void)?
|
|
|
|
/// Toggle details visibility (portrait fullscreen)
|
|
var onToggleDetailsVisibility: (() -> Void)?
|
|
|
|
#if os(iOS)
|
|
/// Toggle orientation lock
|
|
var onToggleOrientationLock: (() -> Void)?
|
|
#endif
|
|
|
|
/// Toggle side panel
|
|
var onTogglePanel: (() -> Void)?
|
|
|
|
#if os(iOS)
|
|
/// Toggle panscan between 0 and 1
|
|
var onTogglePanscan: (() -> Void)?
|
|
#endif
|
|
|
|
/// Toggle auto-play next in queue
|
|
var onToggleAutoPlayNext: (() -> Void)?
|
|
|
|
/// Toggle audio-only (music) mode
|
|
var onToggleAudioMode: (() -> Void)?
|
|
|
|
/// Show settings sheet
|
|
var onShowSettings: (() -> Void)?
|
|
|
|
/// Play next video in queue
|
|
var onPlayNext: (() async -> Void)?
|
|
|
|
/// Play previous video in queue
|
|
var onPlayPrevious: (() async -> Void)?
|
|
|
|
/// Toggle play/pause
|
|
var onPlayPause: (() -> Void)?
|
|
|
|
/// Seek forward by specified seconds
|
|
var onSeekForward: ((TimeInterval) async -> Void)?
|
|
|
|
/// Seek backward by specified seconds
|
|
var onSeekBackward: ((TimeInterval) async -> Void)?
|
|
|
|
/// Volume changed (0.0-1.0)
|
|
var onVolumeChanged: ((Float) -> Void)?
|
|
|
|
/// Toggle mute
|
|
var onMuteToggled: (() -> Void)?
|
|
|
|
/// Timer control callbacks for slider interactions
|
|
var onCancelHideTimer: (() -> Void)?
|
|
var onResetHideTimer: (() -> Void)?
|
|
|
|
/// Called when slider adjustment state changes (volume or brightness)
|
|
var onSliderAdjustmentChanged: ((Bool) -> Void)?
|
|
|
|
/// Change playback rate
|
|
var onRateChanged: ((PlaybackRate) -> Void)?
|
|
|
|
/// Select caption
|
|
var onCaptionSelected: ((Caption?) -> Void)?
|
|
|
|
/// Show playlist selector sheet
|
|
var onShowPlaylistSelector: (() -> Void)?
|
|
|
|
/// Show queue management sheet
|
|
var onShowQueue: (() -> Void)?
|
|
|
|
/// Show captions selector sheet
|
|
var onShowCaptionsSelector: (() -> Void)?
|
|
|
|
/// Show chapters selector sheet
|
|
var onShowChaptersSelector: (() -> Void)?
|
|
|
|
/// Show video track selector sheet
|
|
var onShowVideoTrackSelector: (() -> Void)?
|
|
|
|
/// Show audio track selector sheet
|
|
var onShowAudioTrackSelector: (() -> Void)?
|
|
|
|
/// Toggle controls lock state
|
|
var onControlsLockToggled: ((Bool) -> Void)?
|
|
|
|
// MARK: - Computed Properties
|
|
|
|
/// Whether controls are locked (all buttons disabled except settings)
|
|
var isControlsLocked: Bool {
|
|
playerState.isControlsLocked
|
|
}
|
|
|
|
/// Current playback rate display text (shows rate when not 1x)
|
|
var playbackRateDisplay: String? {
|
|
let rate = playerState.rate
|
|
if rate == .x1 {
|
|
return nil
|
|
}
|
|
return String(format: "%.2gx", rate.rawValue)
|
|
}
|
|
|
|
/// Whether share is available (has video with share URL)
|
|
var canShare: Bool {
|
|
currentVideo != nil
|
|
}
|
|
|
|
/// Whether add to playlist is available
|
|
var canAddToPlaylist: Bool {
|
|
guard let video = currentVideo else { return false }
|
|
return !video.isFromLocalFolder
|
|
}
|
|
|
|
/// Whether captions are available (external files or embedded tracks)
|
|
var hasCaptions: Bool {
|
|
!availableCaptions.isEmpty || !embeddedSubtitleTracks.isEmpty
|
|
}
|
|
|
|
/// Whether an external subtitle file can be loaded: media-source playback
|
|
/// (local folder/SMB/WebDAV) on platforms with a file picker.
|
|
var canLoadExternalSubtitles: Bool {
|
|
#if os(tvOS)
|
|
return false
|
|
#else
|
|
return currentVideo?.isFromMediaSource == true
|
|
#endif
|
|
}
|
|
|
|
/// Whether chapters are available
|
|
var hasChapters: Bool {
|
|
!playerState.chapters.isEmpty
|
|
}
|
|
|
|
/// PiP icon based on current state
|
|
var pipIcon: String {
|
|
playerState.pipState == .active ? "pip.exit" : "pip.enter"
|
|
}
|
|
|
|
#if os(macOS)
|
|
/// Fullscreen icon based on current state (no rotation semantics on macOS).
|
|
var fullscreenIcon: String {
|
|
isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
|
}
|
|
|
|
/// Whether the fullscreen button should be shown
|
|
var shouldShowFullscreenButton: Bool {
|
|
onToggleFullscreen != nil
|
|
}
|
|
#else
|
|
/// Fullscreen icon based on current state.
|
|
/// Uses rotation icons when tapping will cause device rotation,
|
|
/// otherwise uses standard fullscreen arrows.
|
|
var fullscreenIcon: String {
|
|
if willRotateOnFullscreenToggle {
|
|
if isFullscreen {
|
|
// Currently landscape fullscreen, will rotate to portrait
|
|
return "rectangle.portrait.rotate"
|
|
} else {
|
|
// Currently portrait, will rotate to landscape
|
|
return "rectangle.landscape.rotate"
|
|
}
|
|
} else {
|
|
// Standard fullscreen icons (iPad or portrait video details toggle)
|
|
return isFullscreen ? "arrow.down.right.and.arrow.up.left" : "arrow.up.left.and.arrow.down.right"
|
|
}
|
|
}
|
|
|
|
/// Whether tapping fullscreen will cause device rotation.
|
|
/// Mirrors the logic in ControlsSectionRenderer.handleFullscreenTap()
|
|
var willRotateOnFullscreenToggle: Bool {
|
|
// iPad never rotates via fullscreen button
|
|
guard !isIPad else { return false }
|
|
|
|
let isActualWidescreenLayout = isWideScreenLayout && onTogglePanel != nil
|
|
|
|
if isActualWidescreenLayout && isFullscreen && !isWidescreenVideo {
|
|
// iPhone in landscape with portrait video: rotates to portrait
|
|
return true
|
|
} else if !isWidescreenVideo {
|
|
// iPhone portrait video in portrait: no rotation (toggles details)
|
|
return false
|
|
} else {
|
|
// iPhone widescreen video: rotates
|
|
return true
|
|
}
|
|
}
|
|
|
|
/// Orientation lock icon based on current state
|
|
var orientationLockIcon: String {
|
|
isOrientationLocked ? "lock.rotation" : "lock.rotation.open"
|
|
}
|
|
|
|
/// Panel toggle icon (flipped based on panel side)
|
|
var panelToggleIcon: String {
|
|
"sidebar.trailing"
|
|
}
|
|
|
|
/// Panscan icon based on current state
|
|
var panscanIcon: String {
|
|
panscanValue > 0.5 ? "arrow.left.and.right.square.fill" : "arrow.left.and.right.square"
|
|
}
|
|
|
|
/// Whether the fullscreen button should be shown
|
|
var shouldShowFullscreenButton: Bool {
|
|
// iPad widescreen (true landscape): hidden (can't force rotation)
|
|
let isActualWidescreen = isWideScreenLayout && onTogglePanel != nil
|
|
let showFullscreenButton = !(isIPad && isActualWidescreen)
|
|
let hasFullscreenAction = onToggleFullscreen != nil || onToggleDetailsVisibility != nil
|
|
|
|
// Hide on iPad when panel is pinned and visible (tapping does nothing)
|
|
let isPinnedPanelActive = isIPad && isPanelPinned && isPanelVisible
|
|
|
|
return showFullscreenButton && hasFullscreenAction && !isPinnedPanelActive
|
|
}
|
|
#endif
|
|
|
|
/// Whether PiP button should be enabled.
|
|
/// No video track in audio-only playback - `isPiPPossible` may lag behind
|
|
/// a video-to-audio reload on a reused backend, so gate explicitly.
|
|
var isPiPAvailable: Bool {
|
|
playerState.isPiPPossible && playerState.currentStream?.isAudioOnly != true
|
|
}
|
|
|
|
/// Whether play next button should be enabled
|
|
var hasNextInQueue: Bool {
|
|
playerState.hasNext
|
|
}
|
|
|
|
/// Whether play previous button should be enabled
|
|
var hasPreviousInQueue: Bool {
|
|
playerState.hasPrevious
|
|
}
|
|
}
|
|
|
|
#endif
|