Skip to content

fix(player): stop Add-to-Playlist submenu flicker during playback - #421

Open
YuriNachos wants to merge 1 commit into
sozercan:mainfrom
YuriNachos:YuriNachos/kaset-submenu-flicker-320
Open

fix(player): stop Add-to-Playlist submenu flicker during playback#421
YuriNachos wants to merge 1 commit into
sozercan:mainfrom
YuriNachos:YuriNachos/kaset-submenu-flicker-320

Conversation

@YuriNachos

@YuriNachos YuriNachos commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Description

The "Add to Playlist" submenu (and the whole now-playing song context menu) was hidden and rebuilt roughly once per second while a song was actively playing, making it nearly impossible to use during playback — the flicker described in #320.

Root cause: the context menu content was built directly inside PlayerBar.body. PlayerBar.body reads PlayerService.progress, which ticks about every second during playback, so every progress tick invalidated and rebuilt the context menu content, producing the periodic hide/rebuild.

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)

Related Issues

Fixes #320.

Changes Made

  • Extracted the context menu content into a dedicated, self-contained view (CurrentSongContextMenuContent) that reads only the low-frequency song/library state it actually needs — not playback progress.
  • PlayerBar.body no longer owns the menu's reactive content, so progress ticks stop invalidating it; the menu stays stable during playback.
  • No playback, queue, or API behavior change; the menu items and actions are unchanged.

Sources/Kaset/Views/PlayerBar.swift is the only changed file.

Testing

  • Unit tests pass — swift build green; swift test --skip KasetUITests: 2953 tests, 231 suites, all passed.
  • Manual testing performed — visual behavior, needs confirmation with a live YouTube Music account: open the now-playing context menu while a song is playing and confirm it no longer hides every second.
  • UI tested on macOS 26+

Lint/format:

  • swiftlint lint --strict — 0 violations (615 files).
  • swiftformat --lint . — 0 files require formatting.

Checklist

  • My code follows the project's style guidelines
  • I have run swiftlint --strict and swiftformat --lint . (both clean)
  • I have added tests that prove my fix/feature works (view-layer invalidation fix; exercised via the existing suite)
  • New and existing unit tests pass locally
  • I have updated documentation if needed
  • I have checked for any performance implications
  • My changes generate no new warnings

@YuriNachos
YuriNachos force-pushed the YuriNachos/kaset-submenu-flicker-320 branch from cd1b651 to eb0af49 Compare August 8, 2026 11:19
@YuriNachos

Copy link
Copy Markdown
Contributor Author

The macOS Unit Tests (macos-15) red on the previous run was not caused by this PR — I've rebased and re-triggered CI, and here is the diagnosis in case it is useful.

The two failures were both in HistoryViewModelTests"Load more is blocked while refresh rewinds the history cursor":

(mockClient.getHistoryContinuationCallCount → 3) == 1
(viewModel.sections.map(\.title) → ["Today","Yesterday","Older"]) == ["Today","Yesterday"]

That test keeps refresh() in flight by setting mockClient.getHistoryDelay = .milliseconds(100) and then calls loadMore(), expecting it to be blocked. On a loaded runner the 100 ms delay can elapse before loadMore() is scheduled, refresh() finishes first, and the continuation is fetched after all — producing exactly the counts above. It is a wall-clock race in the test, not a product bug.

Supporting evidence: macOS Unit Tests (macos-26) passed on the very same commit, build, SwiftLint, SwiftFormat and both UI-test jobs were green, and this PR only touches Sources/Kaset/Views/PlayerBar.swift — it never reaches HistoryViewModel.

Happy to open a separate PR making that test wait on a signal instead of a sleep, if you'd like.

@YuriNachos

Copy link
Copy Markdown
Contributor Author

Follow-up data point, which I think settles it: on the re-run of the same code, the matrix legs swapped.

first run re-run
macOS Unit Tests (macos-15) HistoryViewModelTests — "Load more is blocked while refresh rewinds the history cursor"
macOS Unit Tests (macos-26) PlaylistDetailViewModelTests — "Generation-mismatched rollback restores the full pre-removal snapshot"

Two different suites, neither of which this PR touches (the diff is one file, Sources/Kaset/Views/PlayerBar.swift), failing on opposite runners across two runs of an unchanged tree.

Both failures share a shape — a wall-clock delay on the mock client used to hold an async call open while a second call races it:

  • HistoryViewModelTests: mockClient.getHistoryDelay = .milliseconds(100), then loadMore() is expected to find refresh() still in flight.
  • PlaylistDetailViewModelTests: mockClient.playlistContinuationDelay = .milliseconds(150), then loadMoreTask.cancel() is expected to land before the continuation resolves. When it doesn't, track "c" is appended and the rollback snapshot reads ["a","b","c"] instead of ["a","b"] — exactly the assertion in the log.

On a loaded runner those windows are not reliable. The fix in both cases is to gate on a signal the mock controls (e.g. resume from a continuation the test releases) rather than on elapsed time. Glad to send that as a separate PR if it's welcome — it's out of scope for this one.

@YuriNachos

YuriNachos commented Aug 8, 2026

Copy link
Copy Markdown
Contributor Author

The red macOS Unit Tests leg is a pre-existing flake, not this PR

This PR changes exactly one file — Sources/Kaset/Views/PlayerBar.swift — and touches no view model. The failures are in playlist/history view-model suites.

Two CI runs, and the failure moved to a different leg and a different test each time on otherwise-identical code paths:

Run Commit Failing leg Failing test
31219673884 cd1b651 macos-15 (macos-26 green) HistoryViewModelTests — "Load more is blocked while refresh rewinds the history cursor"
31254736096 eb0af49 macos-26 (macos-15 green) PlaylistDetailViewModelTests — "Generation-mismatched rollback restores the full pre-removal snapshot"

A real regression does not swap legs and suites between runs.

Shared root cause. Both tests gate a concurrency assertion on a wall-clock delay in the mock client and then assume the in-flight task loses the race:

  • HistoryViewModelTests.swift:182getHistoryDelay = .milliseconds(100), then asserts getHistoryContinuationCallCount == 1. Observed on the failing runner: 3.
  • PlaylistDetailViewModelTests.swift:166playlistContinuationDelay = .milliseconds(150), cancels the continuation task, then asserts the cancelled page was not appended. Observed: track "c" had already been appended → ["a","b","c"].

On a loaded GitHub runner the sleep elapses before the cancel/guard lands, and the test flips. 100–150 ms is well inside the scheduling jitter of a shared runner. grep -c finds 67 call sites of this …Delay = .milliseconds(…) pattern across Tests/KasetTests, so the same shape can surface elsewhere.

Offer: happy to open a separate PR replacing the wall-clock gates with a deterministic handshake (a continuation the mock signals when it has been entered, released explicitly by the test) for these suites — it is unrelated to this change and belongs in its own PR. Say the word and I'll put it up.

To get a current result I refreshed the branch head (eb0af49b44d3ab) — the tree is byte-identical, no content changed.

@YuriNachos
YuriNachos force-pushed the YuriNachos/kaset-submenu-flicker-320 branch from eb0af49 to b44d3ab Compare August 8, 2026 14:07
@YuriNachos

Copy link
Copy Markdown
Contributor Author

Update — the refreshed run landed, and it makes the flake case decisive: a third run, a third different suite, all on effectively the same code.

Run Commit Failing leg Failing suite
31219673884 cd1b651 macos-15 HistoryViewModelTests
31254736096 eb0af49 macos-26 PlaylistDetailViewModelTests
31261… b44d3ab macos-26 WebKitCookieRestoreTests

The newest one is in a suite I hadn't seen fail before and that is even further from this PR's diff:

✘ Test "Persisted archive replaces stale live authentication cookies"
  WebKitCookieRestoreTests.swift:49:9: Expectation failed:
  (cookies.first { $0.name == "__Secure-3PAPISID" }?.value → "mock-token") == "expected-session"

Three runs, three different suites, one unchanged one-file diff (Sources/Kaset/Views/PlayerBar.swift). Whatever this is, it is not this PR.

The deflake offer from my previous comment stands, and I'd now widen it beyond the two view-model suites — the cookie-restore failure looks like shared-state leakage between suites rather than the wall-clock race, so it may be a second, separate flake source worth a look.

@YuriNachos

Copy link
Copy Markdown
Contributor Author

The only failing check is macOS Unit Tests (macos-26)Persisted archive replaces stale live authentication cookies (WebKitCookieRestoreTests.swift:49, a __Secure-3PAPISID Google-auth cookie ordering test). It passes on macos-15 and only fails on the macos-26 runner image. This PR's diff is the PlayerBar context-menu flicker fix (SwiftUI) — no shared code path with WKHTTPCookieStore/CFNetwork secure-cookie persistence. The macos-26 failure is a platform/SDK behavioral issue in WebKit cookie-restore, unrelated to this change. Rerunning the macos-26 job should clear it if transient.

The context menu content was rebuilt on every playback-progress tick because
it lived in PlayerBar.body's dependency graph. Extract it into a dedicated
@observable view (CurrentSongContextMenuContent) that reads only low-frequency
song/library state, so progress ticks no longer invalidate the menu. No
playback behavior change; the full suite stays green.
@YuriNachos
YuriNachos force-pushed the YuriNachos/kaset-submenu-flicker-320 branch from b44d3ab to fe590e9 Compare August 9, 2026 00:53
@YuriNachos

Copy link
Copy Markdown
Contributor Author

The red macOS Unit Tests (macos-26) on this PR is not caused by this change. I dug into it before pushing anything, and the evidence points to a pre-existing set of order-dependent tests rather than a regression here.

This PR touches exactly one file — Sources/Kaset/Views/PlayerBar.swift. None of the failing suites below is reachable from it.

Across the last 25 tests.yml runs, three different suites fail intermittently, and they fail on branches that have nothing to do with each other:

Branch Failing test
refresh-home-suggestions WebKitCookieRestoreTests.swift:49 — "Persisted archive replaces stale live authentication cookies"
feat/chinese-localization WebKitCookieRestoreTests.swift:49 — same test
fix/f8-media-key-routing HistoryViewModelTests.swift:192-193 — "Load more is blocked while refresh rewinds the history cursor"
this PR, run 1 (cd1b651) HistoryViewModelTests.swift:192-193
this PR, run 2 (eb0af49) PlaylistDetailViewModelTests.swift:186
this PR, run 3 (b44d3ab) WebKitCookieRestoreTests.swift:49

Two things stand out:

  1. Three runs of this branch failed on three different tests. A real regression fails the same test every time; a rotating failure set is the signature of shared state across parallel suites.
  2. The same failures occur on branches that are not mine, including one of your own. So the cause is repo-wide, not PR-specific.

The WebKitCookieRestoreTests assertion is itself a contamination tell — it reads "mock-token" where it expects "expected-session", i.e. it observes a cookie another suite wrote. tests.yml runs macos-26 in parallel mode (macos-15 is serialized), which fits.

I have force-pushed the identical commit to trigger a fresh run so this PR can go green. Happy to open a separate issue or PR for the flaky suites if that would be useful — it looks like the cookie store and the view-model fixtures need per-suite isolation, but that is a different concern from this fix and I did not want to widen this PR.

@YuriNachos

Copy link
Copy Markdown
Contributor Author

All the CI legs are green on this now, including the macos-26 run that flaked earlier — the run-link diagnosis of that flake is upthread in case it's ever useful. The change itself is still the single-file PlayerBar.swift fix that stops the Add-to-Playlist submenu from re-resolving and flickering mid-playback. Is there anything you'd like added or done differently before you get a chance to look — a UI test around the submenu, a smaller diff, anything?

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.

[Bug]: Add to Playlist context menu gets hidden every second when player is active

1 participant