Skip to content

Stop the player backdrop wiping off screen - #89

Merged
Mag1cByt3s merged 2 commits into
Evil-Project:mainfrom
Mag1cByt3s:fix/player-ambient-background-wipe
Jul 31, 2026
Merged

Stop the player backdrop wiping off screen#89
Mag1cByt3s merged 2 commits into
Evil-Project:mainfrom
Mag1cByt3s:fix/player-ambient-background-wipe

Conversation

@Mag1cByt3s

@Mag1cByt3s Mag1cByt3s commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Problem

With the full-screen player open, the ambient backdrop wiped away and exposed the black system background beneath it. Rapid play/pause and rapid minimise/expand both triggered it, and once started it kept going on its own while paused.

Cause

The backdrop's breathing effect was started with withAnimation(.easeInOut(duration: 6.0).repeatForever(autoreverses: true)).

An endless SwiftUI animation is a transaction, and a transaction applies to every animatable change in its subtree — not only the state it was started for. That subtree included the safe-area expansion that sizes the backdrop: .ignoresSafeArea() does not pin a view to the screen, it expands it by the current insets.

So the breath latched onto the backdrop's top edge and oscillated it between the real inset and zero for as long as the view lived, sliding the artwork off screen and uncovering the layer beneath.

Nothing stopped it: pausing only reset the flag the breath read, while the runaway animation lived on the geometry. Scoping it with .animation(_:value:) is not enough either — that modifier still animates everything in its subtree.

On-device probes confirmed it directly. The top safe-area inset ramped 0 → 67 → 0 on every play/pause tap, autoreversing, while every size probe stayed silent — the geometry was never resized, only the inset-derived expansion moved.

Fix

Drive the breath from a clock with TimelineView, matching the existing EqualizerBars pattern. Same motion, no transaction to leak, and paused: stops it without leaving anything running.

Three supporting changes from the same investigation:

  • Clamp the artwork layer via an overlay. .aspectRatio(.fill) always resolves larger than the proposal, and .frame(maxWidth:maxHeight:) adopts an oversized child rather than clamping it — so the image could inflate its own container to the artwork's aspect ratio instead of the screen's. Overlay content never feeds size back to its parent.
  • Overscan the layers past every edge, so the backdrop's extent no longer depends on safe-area insets at all. Defence in depth against this class.
  • Refuse inherited transactions at the view boundary, so a repeatForever started anywhere else in the app cannot animate this backdrop's geometry.

Also drops the drawingGroup(). The source is the 32px server-blurred variant, so there is no costly rasterization worth caching, and it forced a full-screen offscreen pass on every re-render of a view that re-renders constantly during playback.

Testing

Verified on device across several builds:

  • Rapid play/pause with the full player open — backdrop stays put.
  • Rapid minimise/expand of the popup.
  • Paused and left idle, which previously started the wipe on its own.
  • Breathing still drifts and scales while playing, and stops on pause.
  • Song skips still crossfade the backdrop.

TwinskaraokeTests passes; the iOS app target builds clean.

Notes

Bound preference LNPopupItemPreferenceKey tried to update multiple times per frame still appears in the console. It is unrelated to this bug and predates the change, but it is a real update loop worth looking at separately.

Summary by Sourcery

Stabilize the full-screen player’s ambient backdrop so it no longer slides off screen or exposes the system background during playback or layout changes.

Bug Fixes:

  • Prevent the ambient backdrop from oscillating with safe-area inset changes, which previously caused the artwork layer to wipe off screen during rapid play/pause or expand/minimize actions.

Enhancements:

  • Drive the backdrop breathing motion from a TimelineView-based clock instead of a repeatForever animation, ensuring the effect stops cleanly when paused and does not leak transactions into its geometry.
  • Overscan and clip the backdrop layers independently of safe-area insets so the painted area reliably covers the display regardless of inset changes.
  • Isolate the backdrop from inheriting ambient animations started elsewhere in the app by clearing incoming transactions at the view boundary.
  • Clamp the artwork as an overlay so its aspect ratio cannot inflate the backdrop’s layout, keeping the background aligned to the screen.
  • Remove unnecessary drawingGroup usage on the blurred artwork to avoid costly offscreen rasterization during frequent re-renders.

Summary by CodeRabbit

  • New Features

    • Added a smoother ambient breathing animation for the player backdrop.
    • Ambient motion now pauses automatically when playback is paused or reduced-motion settings are enabled.
  • Bug Fixes

    • Improved artwork rendering to prevent unintended geometry animations and visual clipping issues.

With the full player open the ambient backdrop wiped away and exposed the
black system background beneath it. Rapid play/pause and rapid
minimise/expand both triggered it, and once started it continued on its own
while paused.

The breathing effect was driven by a repeatForever animation. An endless
SwiftUI animation is a transaction, and a transaction applies to every
animatable change in its subtree — not only the state it was started for.
The subtree included the safe-area expansion that sizes this backdrop:
.ignoresSafeArea() does not pin a view to the screen, it expands it by the
current insets. So the breath latched onto the backdrop's top edge and
oscillated it between the real inset and zero for as long as the view lived,
sliding the artwork off screen and uncovering the layer beneath. Device
probes caught it directly, the top inset ramping 0 to 67 and back on every
tap while every size probe stayed silent.

Nothing stopped it either: pausing only reset the flag the breath read, while
the runaway animation lived on the geometry. Scoping it with
.animation(_:value:) is not enough — that modifier still animates everything
in its subtree.

Drive the breath from a clock with TimelineView instead, matching
EqualizerBars. Same motion, no transaction to leak, and `paused:` stops it
without leaving anything running.

Three supporting changes fall out of the same investigation:

- Clamp the artwork layer by putting it in an overlay. .aspectRatio(.fill)
  always resolves larger than the proposal and .frame(maxWidth:maxHeight:)
  adopts an oversized child rather than clamping it, so the image could
  inflate its own container to the artwork's aspect ratio instead of the
  screen's. Overlay content never feeds size back to its parent.
- Overscan the layers past every edge, so the backdrop's extent no longer
  depends on safe-area insets at all. Defence in depth against this class.
- Refuse inherited transactions at the view's boundary, so a repeatForever
  started anywhere else in the app cannot animate this backdrop's geometry.

Drop the drawingGroup while here. The source is the 32px server-blurred
variant, so there is no costly rasterization worth caching, and it forced a
full-screen offscreen pass on every re-render of a view that re-renders
constantly during playback.
@sourcery-ai

sourcery-ai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Reviewer's Guide

This PR fixes the full‑screen player backdrop wiping off screen by replacing the repeatForever animation with a clock‑driven TimelineView breath, decoupling backdrop geometry from safe-area insets and from inherited transactions, and tightening how the artwork image is laid out and rendered (overlay clamp, overscan, and removal of unnecessary drawingGroup).

Sequence diagram for clock-driven ambient breath animation in player backdrop

sequenceDiagram
    participant AudioManager
    participant SwiftUI
    participant PlayerAmbientBackground
    participant TimelineView
    participant BackdropImage

    AudioManager->>SwiftUI: publish isPlaying
    SwiftUI->>PlayerAmbientBackground: body recompute
    PlayerAmbientBackground->>PlayerAmbientBackground: shouldAnimateAmbient
    PlayerAmbientBackground->>TimelineView: TimelineView.animation(minimumInterval:paused:)
    alt shouldAnimateAmbient
        TimelineView->>TimelineView: tick with context.date
        TimelineView->>PlayerAmbientBackground: breathingPhase(at: context.date)
        PlayerAmbientBackground->>BackdropImage: scaleEffect(1.22 + 0.06 * phase)
        PlayerAmbientBackground->>BackdropImage: offset(x: -8 + 16 * phase, y: 6 - 12 * phase)
    else !shouldAnimateAmbient
        TimelineView->>BackdropImage: scaleEffect(1.22)
        TimelineView->>BackdropImage: offset(x: -8, y: 6)
    end
    PlayerAmbientBackground->>PlayerAmbientBackground: transaction { animation = nil }
Loading

File-Level Changes

Change Details Files
Replace the backdrop’s repeatForever state animation with a clock‑driven breathing effect using TimelineView, and simplify the ambient animation state.
  • Remove @State animationPhase and its start/stop breathing helpers driven by withAnimation(.repeatForever).
  • Change shouldAnimateAmbient to depend only on isPlaying && !reduceMotion.
  • Introduce breathingPeriod and breathingPhase(at:) to compute an eased 0→1→0 phase from the current date.
  • Drive image scale and offset from the TimelineView’s animation context and breathing phase instead of a toggling Bool state.
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift
Make the backdrop extent independent of safe-area and prevent external transactions from animating its geometry.
  • Add safeAreaOverscan constant and apply negative padding to overscan the backdrop past all edges before calling .ignoresSafeArea().
  • Add a .transaction { $0.animation = nil } at the view boundary so inherited animations elsewhere in the app cannot animate this backdrop.
  • Keep explicit .animation(_:value:) modifiers for artworkURL and palette transitions inside the transaction boundary.
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift
Clamp the artwork layer via an overlay, adjust the image layout, and remove unnecessary rasterization.
  • Replace the GeometryReader-based frame sizing with a Color.clear.overlay that hosts the WebImage, so the image cannot affect parent layout.
  • Rely on .aspectRatio(.fill) without an explicit size frame, letting the overlay keep the parent’s size while the image covers it.
  • Move blur and saturation onto the overlaid image within TimelineView; drop the .drawingGroup() call to avoid full-screen offscreen passes on every re-render.
  • Ensure the backdrop remains clipped to its host by applying .clipped() to the overlay container instead of the image frame.
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift
Adjust placeholder handling and palette loading hooks to match the new layout.
  • Call loadPalette() only from onAppear and onChange(of: artworkURL); remove breathing-related logic from lifecycle and reduceMotion changes.
  • Simplify the placeholder path to render fallbackGradient without geometry-based framing, consistent with the overlay-based layout.
  • Retain transitions for backdrop changes via .transition(.opacity) on the TimelineView-backed image.
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@Mag1cByt3s, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 43 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 7ffc37b4-ee00-4ff4-a920-d2551550a082

📥 Commits

Reviewing files that changed from the base of the PR and between f55984d and af934fb.

📒 Files selected for processing (1)
  • Twinskaraoke/Components/Player/PlayerAmbientBackground.swift
📝 Walkthrough

Walkthrough

PlayerAmbientBackground now uses a date-driven TimelineView for breathing motion. Playback and reduced-motion settings disable movement. Artwork uses clipped overlay rendering with interpolated scale and offset values.

Changes

Ambient background animation

Layer / File(s) Summary
Timeline phase and animation controls
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift
The view replaces repeating animation state with playback-aware and reduced-motion-aware timeline control. It adds fixed-period breathing, safe-area overscan, and transaction isolation.
Artwork transform rendering
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift
The artwork uses a clipped overlay with timeline-driven scale and offset interpolation. GeometryReader and drawingGroup() are removed. Existing blur, saturation, transitions, and fallback rendering remain.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

Suggested reviewers: xiaoyuan151

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main fix for the player backdrop moving off screen.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've left some high level feedback:

  • The root-level .transaction { $0.animation = nil } prevents any implicit animations from leaking in, but it also disables any unscoped implicit animations inside this view tree; consider whether that might unintentionally suppress future layout or state transitions added under PlayerAmbientBackground that rely on default animations.
  • The hard-coded safeAreaOverscan of 160 works as a defensive buffer, but it might be clearer and more maintainable to derive it from screen dimensions (e.g. as a fraction of the longest edge) or tie it to a named design constant so its rationale is easier to adjust across devices.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The root-level `.transaction { $0.animation = nil }` prevents any implicit animations from leaking in, but it also disables any unscoped implicit animations inside this view tree; consider whether that might unintentionally suppress future layout or state transitions added under `PlayerAmbientBackground` that rely on default animations.
- The hard-coded `safeAreaOverscan` of 160 works as a defensive buffer, but it might be clearer and more maintainable to derive it from screen dimensions (e.g. as a fraction of the longest edge) or tie it to a named design constant so its rationale is easier to adjust across devices.

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
Twinskaraoke/Components/Player/PlayerAmbientBackground.swift (1)

94-123: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Consider smoothing the transition when shouldAnimateAmbient toggles.

phase is forced to 0 whenever shouldAnimateAmbient is false (Line 100-102), but breathingPhase(at:) is driven by absolute wall-clock time. On a rapid play/pause or minimize/expand cycle, the artwork's scaleEffect/offset can jump discontinuously: once when the value snaps to the resting phase on pause, and again on resume when the phase recomputes from "now" instead of continuing from the frozen value. Neither of the two .animation(_:value:) modifiers on the outer ZStack (Line 38-39) covers this transition.

Add a scoped, non-repeating .animation(_:value:) keyed on shouldAnimateAmbient directly on the artwork transform. This does not reintroduce the original bug, since it is a single discrete transition (not a repeatForever) and does not touch safe-area geometry.

♻️ Proposed fix to smooth the play/pause transition
                                 .scaleEffect(1.22 + 0.06 * phase)
                                 .offset(
                                     x: -8 + 16 * phase,
                                     y: 6 - 12 * phase
                                 )
+                                .animation(
+                                    reduceMotion ? nil : .easeInOut(duration: 0.6),
+                                    value: shouldAnimateAmbient
+                                )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Twinskaraoke/Components/Player/PlayerAmbientBackground.swift` around lines 94
- 123, In the TimelineView artwork transform, add a scoped non-repeating
animation keyed by shouldAnimateAmbient after the scaleEffect/offset transforms
so transitions between the resting phase and the current breathing phase
interpolate smoothly. Use an appropriate easing and duration, and keep the
animation local to the artwork without modifying the outer ZStack animations or
safe-area geometry.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@Twinskaraoke/Components/Player/PlayerAmbientBackground.swift`:
- Around line 94-123: In the TimelineView artwork transform, add a scoped
non-repeating animation keyed by shouldAnimateAmbient after the
scaleEffect/offset transforms so transitions between the resting phase and the
current breathing phase interpolate smoothly. Use an appropriate easing and
duration, and keep the animation local to the artwork without modifying the
outer ZStack animations or safe-area geometry.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 84e01345-d4ba-4caa-a2ab-3b19fcbb2a80

📥 Commits

Reviewing files that changed from the base of the PR and between ffed09f and f55984d.

📒 Files selected for processing (1)
  • Twinskaraoke/Components/Player/PlayerAmbientBackground.swift

Review feedback.

The breath phase was forced to zero while stopped and recomputed from
wall-clock time on resume, so the artwork transform jumped at both ends of a
pause. Track the time already breathed instead and carry it across, so the
motion picks up where it left off.

Doing it in state rather than with a scoped .animation(_:value:) keyed on the
play/pause flag is deliberate. That modifier animates everything in its
subtree, including the safe-area expansion that sizes this backdrop — the
reason scoping the original repeatForever that way did not fix the bug — and
the flag changes on play/pause, which is exactly when the popup animates its
insets. It would re-arm the same leak on the same trigger, one-shot instead
of endless.

Also bring the overscan down to a size argued from the insets it has to
clear, ~67pt at the extreme on device, rather than a round number. Deriving
it from the display instead would overscan an iPad far past anything the
insets can reach and pay for it in blurred area every frame.
@Mag1cByt3s

Copy link
Copy Markdown
Collaborator Author

Pushed a follow-up commit for the review feedback. Notes on each point:

Smoothing the transform transition on play/pause — real problem, but I've taken a different route than suggested.

The proposed .animation(_:value: shouldAnimateAmbient) on the artwork transform is the construct this PR exists to remove. .animation(_:value:) animates everything in its subtree, including the safe-area expansion that sizes the backdrop — that is exactly why scoping the original repeatForever that way did not fix the bug. And shouldAnimateAmbient flips on play/pause, which is when the popup animates its insets. On-device probes showed the top inset ramping 0 → 67 → 0 on every tap. Keying an animation to that flag would re-arm the same leak on the same trigger, one-shot rather than endless, but visible.

The underlying observation was correct though: the phase was forced to zero while stopped and recomputed from wall-clock time on resume, so the transform jumped at both ends of a pause. Fixed by banking the elapsed breath time in state and carrying it across, so the motion resumes where it stopped. No animation modifier involved.

safeAreaOverscan being a round number — fair, trimmed to 96 and argued from the insets it has to clear (~67pt at the extreme on device) rather than picked arbitrarily.

I've left it as a constant rather than deriving it from the display. A fraction of the longest edge would overscan an iPad far past anything the insets can reach, and that is blurred area paid for on every frame.

The root .transaction { $0.animation = nil } suppressing implicit animations in the subtree — correct, and intended.

This is a decorative full-bleed backdrop where inherited motion is the failure mode rather than a feature. The two .animation(_:value:) modifiers sit inside the boundary and still drive the artwork crossfade and palette wash, and anything added later can opt in the same way. The trade-off is documented at the call site.

Retested on device: rapid play/pause, rapid minimise/expand, and left paused. Backdrop holds.

@Mag1cByt3s
Mag1cByt3s merged commit 0a93d07 into Evil-Project:main Jul 31, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant