Repository navigation
fix(lyrics): stabilize line layout, fix autoscroll and library scroll range - #156
casha-cashu wants to merge 4 commits into
Conversation
…ange - Stabilize lyrics line layout and word wrapping by setting consistent Bold font weight across active/inactive states - Remove redundant invisible duplicate Text boxes and overlapping Box containers - Apply LineBreak.Paragraph and Hyphens.None to prevent awkward mid-word breaks - Await totalItemsCount > 0 via snapshotFlow in SyncedLyricsList LaunchedEffect to fix initial autoscroll on tab open - Fix LibrarySongsTab locate button visible range calculation using 'first until (first + count)' - Prevent infinite duration polling loop in TransitionController when player is paused/idle with a 10-attempt timeout
[Medium impact] The PR appears safe to merge; no actionable issues remain.
|
- Retry transition scheduling upon player ready state, timeline update, or playback resumption if initial duration wait timed out - Keep bounded duration wait to prevent 500ms busy loops while idle or paused
lostf1sh
left a comment
There was a problem hiding this comment.
Ran :app:compileDebugKotlin :app:lintDebug :app:testDebugUnitTest on the PR head: it compiles, lint passes (the one warning is in a file this PR does not touch), and all 1250 unit tests pass.
Two things need changing before merge, the rest is cleanup:
- The
LibrarySongsTabchange is a no-op, and the locate button bug it describes is still there. Inline comment has the actual cause. needsDurationReschedulecan stay set after a later successful wait, which causes a redundant cancel and reschedule. Inline comment.- Every lyric line is now bold, which is a visible design change the description does not mention. Please call it out so it can be decided on purpose.
Smaller points:
- Item 4 of the description still says scheduling is aborted; the second commit reschedules instead.
- No regression tests were added. The visibility predicate is a pure function waiting to be extracted and tested.
- The repo uses plain imperative commit subjects (
Fix ...) rather thanfix(scope):prefixes, and prefers one fix per PR. Four unrelated fixes in one PR makes it harder to merge the ready parts (the autoscroll fix is one) on their own.
| false | ||
| } else { | ||
| currentSongListIndex in visibleItems.first().index..visibleItems.last().index | ||
| currentSongListIndex in first until (first + count) |
There was a problem hiding this comment.
This is the same range as before. visibleItemsInfo in a LazyColumn without sticky headers is contiguous, so first().index..last().index and first until first + count cover identical indices, and the locate button behaves exactly as it does on main.
The symptom in the description has a different cause: visibleItemsInfo still includes rows that sit under the bottom contentPadding (bottom bar + mini player + 30dp), so the current song counts as visible while the mini player covers it. To fix it, compare the item's offset + size against layoutInfo.viewportEndOffset - layoutInfo.afterContentPadding (and offset against viewportStartOffset + beforeContentPadding) instead of relying on the index range. That predicate fits in a small pure function with a unit test, which the contributing guidelines ask for on behaviour changes.
| while ((duration == C.TIME_UNSET || duration <= 0) && isActive) { | ||
| if (++durationWaitAttempts > 10) { | ||
| Timber.tag("TransitionDebug").w("Timed out waiting for duration (%d). Pausing transition scheduling until duration becomes available.", duration) | ||
| needsDurationReschedule = true |
There was a problem hiding this comment.
The flag stays set when the wait later succeeds inside the same job. Sequence: the loop times out and sets needsDurationReschedule = true; the settings combine emits again, collectLatest re-runs this block, and this time player.duration is valid, so the transition is scheduled normally. Nothing clears the flag on that path (only scheduleTransitionFor and the listener hooks do). The next onTimelineChanged with a non-playlist reason, or a pause and resume, then sees the stale flag, cancels the already scheduled job and starts over with another 1.5 s delay and a fresh prepareNext. Clearing the flag as soon as the loop obtains a valid duration (or at the top of the collectLatest block) closes that gap.
Smaller point: engine.prepareNext(transitionTarget) has already run by the time we bail out here, so the auxiliary player stays prepared with nothing scheduled until the next scheduleTransitionFor. That matches the old behaviour, but an engine.cancelNext() before returning would release it.
| fontSize = fontSizeSp.sp, | ||
| lineHeight = (fontSizeSp * lineHeightRatio).sp | ||
| lineHeight = (fontSizeSp * lineHeightRatio).sp, | ||
| fontWeight = FontWeight.Bold, |
There was a problem hiding this comment.
This makes every lyric line bold: non-current synced lines, non-highlighted words and plain lyrics, which on main render at the theme's titleLarge weight (regular). That is a visible design change for everyone who opens lyrics, not only a layout fix, and the description does not mention it. If the uniform weight is the intended look, say so in the description so it can be judged on that basis. The secondary text scale change from 0.75 to 0.72 (lines 1496 and 1716) is in the same category.
| lineHeight = (fontSizeSp * lineHeightRatio).sp | ||
| lineHeight = (fontSizeSp * lineHeightRatio).sp, | ||
| fontWeight = FontWeight.Bold, | ||
| lineBreak = LineBreak.Paragraph, |
There was a problem hiding this comment.
Hyphens.None and TextOverflow.Clip are already the defaults (TextStyle.hyphens resolves to None, and Text defaults overflow to Clip), so those additions change nothing. LineBreak.Paragraph only switches the strategy from Simple to HighQuality; a word longer than the line still gets broken mid-word under either strategy, so it does not back the "prevent awkward mid-word breaks" claim. None of this is harmful, but I would drop the no-op properties and reword the description.
| if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { | ||
| renderEffect = lineBlurEffectCache.get(blurRadius.toPx().roundToInt().toFloat()) | ||
| val radiusPx = blurRadius.toPx() | ||
| renderEffect = if (radiusPx > 0.5f) { |
There was a problem hiding this comment.
BlurEffectCache.get already returns null for radiusPx <= 0f, and roundToInt() maps anything below 0.5 px to 0, so this guard only differs from the previous call at exactly 0.5 px. Fine to keep, but it is not a behaviour change.
| if (currentLineIndex !in lines.indices) return@LaunchedEffect | ||
| if (listState.layoutInfo.totalItemsCount == 0) return@LaunchedEffect | ||
| if (listState.layoutInfo.totalItemsCount == 0) { | ||
| snapshotFlow { listState.layoutInfo.totalItemsCount }.first { it > 0 } |
There was a problem hiding this comment.
This one checks out. LaunchedEffect runs before the first measure pass, so totalItemsCount is 0 on open, and none of the keys re-trigger the effect once the list is laid out (metrics comes from BoxWithConstraints, which is already resolved). Suspending on the snapshot is the right fix.
…g, and clean up lyrics styling - LibrarySongsTab: Check exact item bounds against clear viewport range (accounting for beforeContentPadding and afterContentPadding) rather than visibleItemsInfo index range, correctly showing the locate FAB when an item is obscured beneath the floating mini-player or bottom bar. - LibrarySongsTabTest: Add unit tests verifying visibility in middle, at exact bounds, partially/fully under padding, outside viewport, and with inverted padding bounds. - TransitionController: Clear needsDurationReschedule at top of collectLatest and after successful duration wait; invoke engine.cancelNext() upon duration wait timeout to release auxiliary player. - LyricsSheet: Revert withLyricsFontSize and plain lyrics to regular font weight and restore 0.75f secondary text scale; drop no-op text properties while keeping FontWeight.Bold for synced lines to prevent metric reflow jumps.
|
Thanks for the thorough review and clear pointers, @lostf1sh! I have addressed all the feedback in commit
Verified locally with |
…oll to resolve conflicts
Summary of Changes
This pull request addresses UX and stability issues in lyrics display, playlist locating, and playback transition scheduling:
Lyrics Line Layout Stability & Reflow Jump Prevention (
LyricsSheet.kt):NormalandBold, altering glyph metrics. An overlappingBoxcontaining an invisible duplicate boldTextwas previously used as a workaround.FontWeight.Boldfor synchronized lyrics lines (LyricLineRowandLyricWordSpan), eliminating font-metric differences between active and inactive states so text layouts remain completely stable without needing invisible duplicate measure layers. Plain (unsynchronized) lyrics retain the theme's regular weight (titleLarge) and0.75fsecondary scale since they do not toggle active states. Removed redundantoverflow = TextOverflow.Clipand experimental wrapping parameters.Initial Lyrics Autoscroll Dropped on Opening (
LyricsSheet.kt):LaunchedEffectinSyncedLyricsListcheckedif (listState.layoutInfo.totalItemsCount == 0) return@LaunchedEffect. If the items had not yet been laid out during the initial composition pass, the effect returned and never re-triggered ifcurrentLineIndexremained constant, leaving the view unscrolled.snapshotFlow { listState.layoutInfo.totalItemsCount }.first { it > 0 }, suspending until the layout is ready and reliably performing the initial alignment.Locate Button Viewport Visibility Predicate (
LibrarySongsTab.kt):visibleItemsInfoinLazyColumnincludes items extending into bottom content padding (floating mini-player + bottom bar + 30dp). The previous index check (currentSongListIndex in first until first + count) considered the currently playing song "visible" even when it was completely covered by the bottom bar and mini-player, preventing the locate floating action button from showing.isItemFullyVisibleInViewportthat verifies the item's offset and size against the unobstructed viewport (layoutInfo.viewportStartOffset + layoutInfo.beforeContentPaddingthroughlayoutInfo.viewportEndOffset - layoutInfo.afterContentPadding).LibrarySongsTabTest.ktverifying item visibility in the clear zone, under top/bottom content padding, outside viewport bounds, and invalid/inverted bounds.Bounded Duration Wait & Safe Rescheduling in TransitionController (
TransitionController.kt):TransitionController, while waiting for track duration when player is paused or idle, awhile ((duration == C.TIME_UNSET || duration <= 0) && isActive)loop repeatedly delayed by 500 ms indefinitely. Additionally, if the wait timed out,needsDurationReschedulecould remain set after a later successful wait in the same job, leading to redundant cancellations and reschedules.engine.cancelNext()is called to release the prepared auxiliary player andneedsDurationRescheduleis set to retry when the player reports ready or updates duration.needsDurationRescheduleis cleared at the top ofcollectLatestand immediately upon obtaining a valid duration.Philosophy & Dependencies
./gradlew testDebugUnitTestand./gradlew lintDebug(all passing).