mirror of
https://github.com/yattee/yattee.git
synced 2026-06-05 06:14:18 +00:00
Throttle SELECT-based scrubbing to seek the underlying frame ~every 150ms instead of waiting 1s after pan-end, so the visible frame keeps up with the scrub handle. Hide the redundant storyboard panel during live scrub (the frame itself is now the preview) but keep the chapter capsule visible. Storyboard panel still shown for D-pad arrow-seek where the frame doesn't move until commit. Auto-commit scrub mode after 3s of inactivity, matching AVPlayerViewController behavior — playback resumes via the existing scrub-pause wiring instead of staying paused indefinitely.
615 lines
22 KiB
Swift
615 lines
22 KiB
Swift
//
|
|
// TVPlayerProgressBar.swift
|
|
// Yattee
|
|
//
|
|
// Focusable progress bar for tvOS with smooth touchpad scrubbing support.
|
|
//
|
|
|
|
#if os(tvOS)
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
/// Progress bar with smooth scrubbing support for tvOS Siri Remote touchpad.
|
|
struct TVPlayerProgressBar: View {
|
|
let currentTime: TimeInterval
|
|
let duration: TimeInterval
|
|
let bufferedTime: TimeInterval
|
|
let storyboard: Storyboard?
|
|
let chapters: [VideoChapter]
|
|
let onSeek: (TimeInterval) -> Void
|
|
/// Called when scrubbing state changes - parent should stop auto-hide timer when true
|
|
var onScrubbingChanged: ((Bool) -> Void)?
|
|
/// Whether the current stream is live
|
|
let isLive: Bool
|
|
/// Whether to show chapter markers on the progress bar (default: true)
|
|
var showChapters: Bool = true
|
|
/// SponsorBlock segments to display on the progress bar.
|
|
var sponsorSegments: [SponsorBlockSegment] = []
|
|
/// Settings for SponsorBlock segment display.
|
|
var sponsorBlockSettings: SponsorBlockSegmentSettings = .default
|
|
/// Color for the played portion of the progress bar.
|
|
var playedColor: Color = .red
|
|
/// Pending target time from the parent's accumulating remote-seek flow
|
|
/// (arrow presses while the bar is focused but not in SELECT scrub mode).
|
|
/// When set, the handle and played portion reflect this value.
|
|
var remoteSeekTime: TimeInterval? = nil
|
|
/// Called when the bar is focused (not scrubbing) and user presses left/right.
|
|
/// Parameter: `forward` — true for right, false for left.
|
|
var onRemoteSeek: ((Bool) -> Void)? = nil
|
|
/// Parent bumps this to request the bar to cancel any in-progress scrub
|
|
/// without performing a seek (used for the Menu button).
|
|
var cancelScrubTrigger: UUID? = nil
|
|
|
|
/// Track focus state internally.
|
|
@FocusState private var isFocused: Bool
|
|
|
|
/// Time during active scrubbing (nil when not scrubbing).
|
|
@State private var scrubTime: TimeInterval?
|
|
|
|
/// Whether user is actively scrubbing.
|
|
@State private var isScrubbing = false
|
|
|
|
/// Accumulated pan translation for scrubbing.
|
|
@State private var panAccumulator: CGFloat = 0
|
|
|
|
/// Consecutive-event streak for rapid D-pad/touchpad scrubbing.
|
|
/// tvOS routes touchpad swipes as rapid `onMoveCommand` events, so we
|
|
/// amplify step size when events arrive in quick succession.
|
|
@State private var dpadStreakCount: Int = 0
|
|
@State private var lastDPadTime: Date?
|
|
@State private var lastDPadDirection: MoveCommandDirection?
|
|
|
|
/// Whether the arrow-seek storyboard/chapter preview is currently shown.
|
|
@State private var showArrowSeekPreview = false
|
|
/// Delayed-hide task for the arrow-seek preview after the user stops pressing arrows.
|
|
@State private var arrowSeekPreviewHideTask: Task<Void, Never>?
|
|
|
|
/// The time to display. SELECT-based scrub takes priority, then the
|
|
/// parent's pending remote-seek target, then the actual playback time.
|
|
private var displayTime: TimeInterval {
|
|
scrubTime ?? remoteSeekTime ?? currentTime
|
|
}
|
|
|
|
/// Progress as a fraction (0-1).
|
|
private var progress: Double {
|
|
guard duration > 0 else { return 0 }
|
|
return min(max(displayTime / duration, 0), 1)
|
|
}
|
|
|
|
/// Buffered progress as a fraction (0-1).
|
|
private var bufferedProgress: Double {
|
|
guard duration > 0 else { return 0 }
|
|
return min(bufferedTime / duration, 1)
|
|
}
|
|
|
|
var body: some View {
|
|
Button {
|
|
if isScrubbing {
|
|
commitScrub()
|
|
} else if !isLive {
|
|
enterScrubMode()
|
|
}
|
|
} label: {
|
|
progressContent
|
|
}
|
|
.buttonStyle(TVProgressBarButtonStyle(isFocused: isFocused))
|
|
.disabled(isLive)
|
|
.overlay {
|
|
// Gesture capture layer (only when scrubbing). Siri Remote pan
|
|
// gestures are indirect touches, so matching the button's size
|
|
// is sufficient — no need to expand and disturb parent layout.
|
|
if isScrubbing {
|
|
TVPanGestureView(
|
|
onPanChanged: { translation, velocity in
|
|
handlePan(translation: translation, velocity: velocity)
|
|
},
|
|
onPanEnded: {
|
|
handlePanEnded()
|
|
}
|
|
)
|
|
}
|
|
}
|
|
.focused($isFocused)
|
|
.onMoveCommand { direction in
|
|
if isScrubbing {
|
|
// D-pad fallback while in SELECT-based scrub mode.
|
|
handleDPad(direction: direction)
|
|
} else if !isLive, direction == .left || direction == .right {
|
|
// Focused but not scrubbing: delegate accumulating remote seek
|
|
// to parent. Up/down falls through to normal focus navigation.
|
|
onRemoteSeek?(direction == .right)
|
|
}
|
|
}
|
|
.onChange(of: isFocused) { _, focused in
|
|
if !focused {
|
|
commitScrub()
|
|
}
|
|
}
|
|
.onChange(of: cancelScrubTrigger) { _, newValue in
|
|
guard newValue != nil, isScrubbing else { return }
|
|
cancelScrub()
|
|
}
|
|
.onChange(of: remoteSeekTime) { _, newValue in
|
|
if newValue != nil {
|
|
arrowSeekPreviewHideTask?.cancel()
|
|
arrowSeekPreviewHideTask = nil
|
|
if !showArrowSeekPreview {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
showArrowSeekPreview = true
|
|
}
|
|
}
|
|
} else {
|
|
scheduleArrowSeekPreviewHide()
|
|
}
|
|
}
|
|
.onChange(of: isScrubbing) { _, scrubbing in
|
|
if scrubbing {
|
|
arrowSeekPreviewHideTask?.cancel()
|
|
arrowSeekPreviewHideTask = nil
|
|
if showArrowSeekPreview {
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
showArrowSeekPreview = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.2), value: isFocused)
|
|
.animation(.easeInOut(duration: 0.1), value: isScrubbing)
|
|
}
|
|
|
|
private func enterScrubMode() {
|
|
scrubTime = currentTime
|
|
panAccumulator = 0
|
|
lastLiveSeekTime = nil
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
isScrubbing = true
|
|
}
|
|
onScrubbingChanged?(true)
|
|
scheduleIdleAutoCommit()
|
|
}
|
|
|
|
private var progressContent: some View {
|
|
VStack(spacing: 12) {
|
|
// Progress bar (hide for live streams)
|
|
if !isLive {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
// Progress bar with chapter segments (4pt gaps for tvOS visibility)
|
|
SegmentedProgressBar(
|
|
chapters: showChapters ? chapters : [],
|
|
duration: duration,
|
|
currentTime: displayTime,
|
|
bufferedTime: bufferedTime,
|
|
height: isFocused ? (isScrubbing ? 16 : 12) : 6,
|
|
gapWidth: 4,
|
|
playedColor: isFocused ? playedColor : .white,
|
|
bufferedColor: .white.opacity(0.4),
|
|
backgroundColor: .white.opacity(0.2),
|
|
sponsorSegments: sponsorSegments,
|
|
sponsorBlockSettings: sponsorBlockSettings
|
|
)
|
|
|
|
// Scrub handle (visible when focused)
|
|
if isFocused {
|
|
Circle()
|
|
.fill(.white)
|
|
.frame(width: isScrubbing ? 32 : 24, height: isScrubbing ? 32 : 24)
|
|
.shadow(color: .black.opacity(0.3), radius: 4)
|
|
.offset(x: (geometry.size.width * progress) - (isScrubbing ? 16 : 12))
|
|
.animation(.easeOut(duration: 0.1), value: progress)
|
|
}
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .center)
|
|
.overlay(alignment: .top) {
|
|
scrubPreviewOverlay(geometry: geometry)
|
|
}
|
|
}
|
|
.frame(height: 20)
|
|
}
|
|
|
|
// Time labels
|
|
HStack {
|
|
// Current time or LIVE indicator
|
|
if isLive {
|
|
HStack(spacing: 6) {
|
|
Circle()
|
|
.fill(.red)
|
|
.frame(width: 8, height: 8)
|
|
Text(String(localized: "player.live"))
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundStyle(.red)
|
|
}
|
|
} else {
|
|
Text(displayTime.formattedAsTimestamp)
|
|
.monospacedDigit()
|
|
.font(.subheadline)
|
|
.fontWeight(isScrubbing ? .semibold : .regular)
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
// Remaining time (only for non-live)
|
|
if !isLive {
|
|
Text("-\(max(0, duration - displayTime).formattedAsTimestamp)")
|
|
.monospacedDigit()
|
|
.font(.subheadline)
|
|
.foregroundStyle(.white.opacity(0.7))
|
|
}
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func scrubPreviewOverlay(geometry: GeometryProxy) -> some View {
|
|
// Storyboard panel: only for arrow-seek (accumulator mode). During
|
|
// SELECT-based scrub we live-seek the underlying frame, so the
|
|
// storyboard would just duplicate what's already on screen.
|
|
// Chapter capsule: shown for either scrub mode — it conveys info the
|
|
// raw frame doesn't (chapter title) and stays useful during live seek.
|
|
let seekTime = displayTime
|
|
let currentChapter = showChapters ? chapters.last(where: { $0.startTime <= seekTime }) : nil
|
|
// Storyboard panel is 320 thumbnail + 4pt horizontal padding * 2 = 328, plus shadow.
|
|
// Use a slightly larger clamp width so the shadow stays on screen.
|
|
let panelWidth: CGFloat = 344
|
|
// Panel height: thumbnail 180 + 4pt vertical padding * 2 = 188 (round up for shadow).
|
|
let panelHeight: CGFloat = 200
|
|
let capsuleSpacing: CGFloat = 8
|
|
// Approximate capsule height (24pt text + 6pt padding * 2 + shadow) — used only
|
|
// for vertical positioning, not for layout sizing.
|
|
let capsuleApproxHeight: CGFloat = 44
|
|
|
|
let xTarget = geometry.size.width * progress
|
|
let halfPanel = panelWidth / 2
|
|
let clampedPanelX = max(halfPanel, min(geometry.size.width - halfPanel, xTarget))
|
|
let panelCenterY = -panelHeight / 2 - 16
|
|
// When the storyboard panel is hidden, place the capsule where the
|
|
// panel would have been so it doesn't float far above the bar.
|
|
let capsuleCenterY = showArrowSeekPreview
|
|
? -panelHeight - 16 - capsuleSpacing - capsuleApproxHeight / 2
|
|
: -16 - capsuleApproxHeight / 2
|
|
|
|
ZStack {
|
|
if showArrowSeekPreview {
|
|
Group {
|
|
if let storyboard {
|
|
TVSeekPreviewView(
|
|
storyboard: storyboard,
|
|
seekTime: seekTime
|
|
)
|
|
} else {
|
|
Text(seekTime.formattedAsTimestamp)
|
|
.font(.system(size: 48, weight: .medium))
|
|
.monospacedDigit()
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 32)
|
|
.padding(.vertical, 16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16)
|
|
.fill(.ultraThinMaterial)
|
|
)
|
|
}
|
|
}
|
|
.fixedSize()
|
|
.position(x: clampedPanelX, y: panelCenterY)
|
|
.transition(.scale.combined(with: .opacity))
|
|
}
|
|
|
|
if (isScrubbing || showArrowSeekPreview), let currentChapter {
|
|
TVChapterCapsuleView(title: currentChapter.title)
|
|
.positioned(xTarget: xTarget, availableWidth: geometry.size.width)
|
|
.position(x: geometry.size.width / 2, y: capsuleCenterY)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
.allowsHitTesting(false)
|
|
}
|
|
|
|
// MARK: - Pan Gesture Handling
|
|
|
|
private func handlePan(translation: CGFloat, velocity: CGFloat) {
|
|
guard duration > 0, isScrubbing else { return }
|
|
|
|
scheduleIdleAutoCommit()
|
|
|
|
// Calculate scrub sensitivity based on duration
|
|
// Lower values = slower/finer scrubbing
|
|
let baseSensitivity: CGFloat
|
|
if duration > 3600 {
|
|
baseSensitivity = duration / 1500
|
|
} else if duration > 600 {
|
|
baseSensitivity = duration / 2000
|
|
} else {
|
|
baseSensitivity = duration / 3000
|
|
}
|
|
|
|
// Non-linear velocity response: slow swipes stay precise, fast flicks accelerate.
|
|
let normalizedVelocity = abs(velocity) / 500
|
|
let velocityMultiplier = min(max(pow(normalizedVelocity, 1.4), 0.3), 6.0)
|
|
let adjustedSensitivity = baseSensitivity * velocityMultiplier
|
|
|
|
// Update scrub time based on translation delta
|
|
let delta = translation - panAccumulator
|
|
panAccumulator = translation
|
|
|
|
let timeChange = TimeInterval(delta * adjustedSensitivity)
|
|
let currentScrubTime = scrubTime ?? currentTime
|
|
scrubTime = min(max(0, currentScrubTime + timeChange), duration)
|
|
|
|
scheduleLiveSeek()
|
|
}
|
|
|
|
private func handlePanEnded() {
|
|
// Reset accumulator for next swipe
|
|
panAccumulator = 0
|
|
// Flush any pending throttled seek so the underlying frame catches up
|
|
// immediately when the user lifts their finger.
|
|
flushLiveSeek()
|
|
scheduleIdleAutoCommit()
|
|
}
|
|
|
|
@State private var seekTask: Task<Void, Never>?
|
|
@State private var lastLiveSeekTime: Date?
|
|
|
|
/// Live-seek throttle: ~150ms leading + trailing.
|
|
/// Keeps the underlying frame in sync with the storyboard preview while
|
|
/// scrubbing without flooding the backend with seek calls.
|
|
private func scheduleLiveSeek() {
|
|
let interval: TimeInterval = 0.15
|
|
let now = Date()
|
|
let elapsed = lastLiveSeekTime.map { now.timeIntervalSince($0) } ?? .infinity
|
|
|
|
if elapsed >= interval {
|
|
seekTask?.cancel()
|
|
seekTask = nil
|
|
lastLiveSeekTime = now
|
|
if let time = scrubTime {
|
|
onSeek(time)
|
|
}
|
|
} else if seekTask == nil {
|
|
let delay = interval - elapsed
|
|
seekTask = Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(delay))
|
|
guard !Task.isCancelled else { return }
|
|
seekTask = nil
|
|
lastLiveSeekTime = Date()
|
|
if let time = scrubTime {
|
|
onSeek(time)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func flushLiveSeek() {
|
|
seekTask?.cancel()
|
|
seekTask = nil
|
|
lastLiveSeekTime = Date()
|
|
if let time = scrubTime {
|
|
onSeek(time)
|
|
}
|
|
}
|
|
|
|
// MARK: - Idle auto-commit
|
|
|
|
/// Auto-commit timer: AVPlayerViewController commits scrub mode after a
|
|
/// few seconds of inactivity so playback can resume.
|
|
@State private var idleAutoCommitTask: Task<Void, Never>?
|
|
|
|
private func scheduleIdleAutoCommit() {
|
|
idleAutoCommitTask?.cancel()
|
|
idleAutoCommitTask = Task { @MainActor in
|
|
try? await Task.sleep(for: .seconds(3))
|
|
guard !Task.isCancelled, isScrubbing else { return }
|
|
commitScrub()
|
|
}
|
|
}
|
|
|
|
private func cancelIdleAutoCommit() {
|
|
idleAutoCommitTask?.cancel()
|
|
idleAutoCommitTask = nil
|
|
}
|
|
|
|
// MARK: - D-Pad Fallback
|
|
|
|
private func handleDPad(direction: MoveCommandDirection) {
|
|
guard duration > 0, isScrubbing else { return }
|
|
|
|
switch direction {
|
|
case .left, .right:
|
|
// Track event rate. When events arrive quickly in the same
|
|
// direction (i.e. a touchpad swipe being rasterized into move
|
|
// commands), grow the streak and scale the step size up.
|
|
let now = Date()
|
|
let gap = lastDPadTime.map { now.timeIntervalSince($0) } ?? .infinity
|
|
|
|
// tvOS throttles onMoveCommand at ~300-400ms even during a fast
|
|
// swipe, so we need a generous window to still recognize a burst.
|
|
if gap < 0.5, lastDPadDirection == direction {
|
|
dpadStreakCount = min(dpadStreakCount + 1, 30)
|
|
} else {
|
|
dpadStreakCount = 1
|
|
}
|
|
lastDPadTime = now
|
|
lastDPadDirection = direction
|
|
|
|
// Base step based on video length.
|
|
let baseStep: TimeInterval
|
|
if duration > 3600 {
|
|
baseStep = 15
|
|
} else if duration > 600 {
|
|
baseStep = 8
|
|
} else {
|
|
baseStep = 5
|
|
}
|
|
|
|
// Steeper curve so a swipe (few events) actually covers ground.
|
|
let streakMultiplier = pow(Double(dpadStreakCount), 1.6)
|
|
let scrubAmount = baseStep * streakMultiplier
|
|
|
|
let currentScrubTime = scrubTime ?? currentTime
|
|
if direction == .left {
|
|
scrubTime = max(0, currentScrubTime - scrubAmount)
|
|
} else {
|
|
scrubTime = min(duration, currentScrubTime + scrubAmount)
|
|
}
|
|
scheduleLiveSeek()
|
|
scheduleIdleAutoCommit()
|
|
|
|
case .up, .down:
|
|
// Exit scrub mode and let navigation happen
|
|
commitScrub()
|
|
dpadStreakCount = 0
|
|
lastDPadTime = nil
|
|
lastDPadDirection = nil
|
|
|
|
@unknown default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// MARK: - Commit
|
|
|
|
private func commitScrub() {
|
|
seekTask?.cancel()
|
|
seekTask = nil
|
|
cancelIdleAutoCommit()
|
|
|
|
let wasScrubbing = isScrubbing
|
|
|
|
if let time = scrubTime {
|
|
onSeek(time)
|
|
}
|
|
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
scrubTime = nil
|
|
isScrubbing = false
|
|
showArrowSeekPreview = false
|
|
}
|
|
panAccumulator = 0
|
|
dpadStreakCount = 0
|
|
lastDPadTime = nil
|
|
lastDPadDirection = nil
|
|
lastLiveSeekTime = nil
|
|
arrowSeekPreviewHideTask?.cancel()
|
|
arrowSeekPreviewHideTask = nil
|
|
|
|
if wasScrubbing {
|
|
onScrubbingChanged?(false)
|
|
}
|
|
}
|
|
|
|
private func cancelScrub() {
|
|
seekTask?.cancel()
|
|
seekTask = nil
|
|
cancelIdleAutoCommit()
|
|
|
|
let wasScrubbing = isScrubbing
|
|
|
|
withAnimation(.easeOut(duration: 0.15)) {
|
|
scrubTime = nil
|
|
isScrubbing = false
|
|
showArrowSeekPreview = false
|
|
}
|
|
panAccumulator = 0
|
|
dpadStreakCount = 0
|
|
lastDPadTime = nil
|
|
lastDPadDirection = nil
|
|
lastLiveSeekTime = nil
|
|
arrowSeekPreviewHideTask?.cancel()
|
|
arrowSeekPreviewHideTask = nil
|
|
|
|
if wasScrubbing {
|
|
onScrubbingChanged?(false)
|
|
}
|
|
}
|
|
|
|
private func scheduleArrowSeekPreviewHide() {
|
|
arrowSeekPreviewHideTask?.cancel()
|
|
arrowSeekPreviewHideTask = Task { @MainActor in
|
|
try? await Task.sleep(for: .milliseconds(2000))
|
|
guard !Task.isCancelled else { return }
|
|
withAnimation(.easeOut(duration: 0.2)) {
|
|
showArrowSeekPreview = false
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: - Pan Gesture View
|
|
|
|
/// UIKit view that captures pan gestures on the Siri Remote touchpad.
|
|
struct TVPanGestureView: UIViewRepresentable {
|
|
let onPanChanged: (CGFloat, CGFloat) -> Void // (translation, velocity)
|
|
let onPanEnded: () -> Void
|
|
|
|
func makeUIView(context: Context) -> TVPanGestureUIView {
|
|
let view = TVPanGestureUIView()
|
|
view.onPanChanged = onPanChanged
|
|
view.onPanEnded = onPanEnded
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: TVPanGestureUIView, context: Context) {
|
|
uiView.onPanChanged = onPanChanged
|
|
uiView.onPanEnded = onPanEnded
|
|
}
|
|
}
|
|
|
|
class TVPanGestureUIView: UIView {
|
|
var onPanChanged: ((CGFloat, CGFloat) -> Void)?
|
|
var onPanEnded: (() -> Void)?
|
|
|
|
private var panRecognizer: UIPanGestureRecognizer!
|
|
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
setupGesture()
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
super.init(coder: coder)
|
|
setupGesture()
|
|
}
|
|
|
|
private func setupGesture() {
|
|
panRecognizer = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
|
|
panRecognizer.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirect.rawValue)]
|
|
addGestureRecognizer(panRecognizer)
|
|
|
|
// Make view focusable
|
|
isUserInteractionEnabled = true
|
|
}
|
|
|
|
@objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
|
|
let translation = gesture.translation(in: self).x
|
|
let velocity = gesture.velocity(in: self).x
|
|
|
|
switch gesture.state {
|
|
case .began, .changed:
|
|
onPanChanged?(translation, velocity)
|
|
case .ended, .cancelled:
|
|
onPanEnded?()
|
|
gesture.setTranslation(.zero, in: self)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Button Style
|
|
|
|
/// Button style for the progress bar.
|
|
struct TVProgressBarButtonStyle: ButtonStyle {
|
|
let isFocused: Bool
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
|
.opacity(configuration.isPressed ? 0.9 : 1.0)
|
|
}
|
|
}
|
|
|
|
#endif
|