Skip to content

feat: add song countdown for sleep timer, redesign sleep timer ui#3302

Closed
Ryfters wants to merge 6 commits intoMetrolistGroup:mainfrom
Ryfters:feat/redesign-sleeptimer-ui
Closed

feat: add song countdown for sleep timer, redesign sleep timer ui#3302
Ryfters wants to merge 6 commits intoMetrolistGroup:mainfrom
Ryfters:feat/redesign-sleeptimer-ui

Conversation

@Ryfters
Copy link
Copy Markdown

@Ryfters Ryfters commented Mar 20, 2026

Add song countown, and redesign ui

Changes

  • Add song countdown
  • Redesign UI (See screenshots, For End of Song, make a 1 song countdown)
  • Defaults will also take in account the countdown type (song, time finish, time)
Screenshot_20260320-190242 Screenshot_20260320-190245

Testing

Tested all new options, with and without crossfade

Known bug: If a user manually goes to the next song when only 1 song remains, it will end the timer without pausing.

For maintainers

  • The fade out part is not implemented yet. It seems to be disabled in this build. If it was for the previous UI redesign, I can now fit a fade out check button where the End of Song button was.
  • Identical sleep timer UIs (one in app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt, one in app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt). I disabled the one in player as it didn't affect anything, I can update it like the new version if required.

Summary by CodeRabbit

  • New Features

    • Sleep timer now supports three modes: minutes, finish-current-song, and song-count.
    • You can set and persist a preferred sleep-timer mode and value as the default.
  • Refactor

    • Redesigned sleep-timer dialog with a unified selection UI for mode and value.
  • Behavior Changes

    • Crossfade will only start when the sleep timer has one remaining song.
    • Fade-out/volume-fade option removed.
  • Localization

    • Improved sleep-timer strings and plurals for minutes and songs.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 20, 2026

📝 Walkthrough

Walkthrough

Refactors sleep timer from boolean flags to a typed model (TIME, TIME_FINISH, SONGS), adds persisted default timer type, updates service/player integrations to use value+type, replaces inline dialogs with a new SleepTimerDialog UI, and adjusts crossfade/scheduling to account for remaining songs.

Changes

Cohort / File(s) Summary
Preference Infrastructure
app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt
Added SleepTimerDefaultTypeKey (intPreferencesKey("sleepTimerDefaultType")) to persist the default sleep-timer type.
Timer Core Logic
app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt
Replaced boolean flags with type and remainingSongs; changed start() to start(value: Int, timerType: Int); updated isActive, song transition handling, timeout behavior, and removed fade-out volume logic.
Playback Integration
app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt, app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt
Crossfade gating now checks remainingSongs == 1; auto-start reads and validates persisted default type and calls sleepTimer.start(value, timerType) (with fallback to TIME).
UI Layer
app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt, app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt, app/src/main/kotlin/com/metrolist/music/ui/player/SleepTimerDialog.kt
Replaced inline AlertDialog-based sleep-timer UI with SleepTimerDialog (ActionPromptDialog). UI supports selecting timer type (Time / Time (finish) / Songs), type-aware slider/labels, persisting default type/value, and starts timer with value+type. Removed fade/stop-after-current-song UI options.
String Resources
app/src/main/res/values/metrolist_strings.xml
Added labels and plurals for song-count and timer-type strings, plus default-setting feedback (already_set_as_default, sleep_timer_time*, sleep_timer_songs, plurals).

Sequence Diagram

sequenceDiagram
    participant User
    participant PlayerUI as Player UI
    participant DataStore as Preference Store
    participant MusicService
    participant SleepTimer

    User->>PlayerUI: Open SleepTimerDialog
    PlayerUI->>DataStore: Read SleepTimerDefaultTypeKey & default value
    DataStore-->>PlayerUI: Return default type/value
    User->>PlayerUI: Select type & value (or set as default)
    alt set as default
        PlayerUI->>DataStore: Write SleepTimerDefaultTypeKey & default value
        DataStore-->>PlayerUI: Ack
    end
    User->>PlayerUI: Confirm start
    PlayerUI->>MusicService: sleepTimer.start(value, timerType)
    MusicService->>SleepTimer: start(value, timerType)
    alt timerType == SONGS
        SleepTimer->>SleepTimer: remainingSongs = value, triggerTime = -1
    else
        SleepTimer->>SleepTimer: triggerTime = now + value.minutes
    end
    loop playback progression
        MusicService->>SleepTimer: notifySongTransition()
        SleepTimer->>SleepTimer: decrement remainingSongs if SONGS
        SleepTimer-->>PlayerUI: update remaining display
    end
    alt remainingSongs == 0 or triggerTime elapsed
        SleepTimer->>MusicService: completeTimerAndPause()
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Suggested reviewers

  • nyxiereal
  • isotjs
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 9.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The description deviates significantly from the required template structure and lacks formal sections for Problem, Cause, Solution, and Related Issues. Restructure the description to follow the template with clear sections for Problem, Cause, and Solution; add proper Related Issues references (e.g., Closes #XXXX).
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main changes: adding song countdown functionality and redesigning the sleep timer UI, matching the primary objectives of the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

Comment on lines +112 to +115
if (remainingSongs > 0) {
if (--remainingSongs == 0){
completeTimerAndPause()
}
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.

notifySongTransition() already does the exact same decrement. If MusicService calls notifySongTransition() on song change and this listener might also fire, remainingSongs drops twice per transition. This is likely the root cause of the "manually skipping ends the timer early" bug you mentioned.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

notifySongTransition() is only called on the crossfade transition, as this doesn't trigger onMediaItemTransition(). I meant to say that manually skipping correctly decrements the song counter, but when there are no more songs, it deactivates the sleep timer, but without pausing the song like it should.

Comment on lines +119 to +125
// override fun onPlaybackStateChanged(
// @Player.State playbackState: Int,
// ) {
// if (playbackState == Player.STATE_ENDED && pauseWhenSongEnd) {
// completeTimerAndPause()
// }
// }
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.

This needs to come back, adapted for remainingSongs. When the playlist ends and STATE_ENDED fires, the timer is never cleared now.

sleepTimerJob?.cancel()
sleepTimerJob = null
updateVolumeMultiplier(1f)
// updateVolumeMultiplier(1f)
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.

Commenting these out means if a previous session left the volume multiplier below 1.0 mid-fade, it never resets. These should stay active unconditionally, or at minimum be guarded with if (fadeOutEnabled) rather than removed entirely.

Comment on lines +136 to +158
// private fun updateVolumeMultiplierForRemainingTime(remainingMs: Long) {
// updateVolumeMultiplier(volumeMultiplierForRemainingTime(remainingMs))
// }

// private fun updateVolumeMultiplierForCurrentSong() {
// val duration = player.duration
// if (duration == C.TIME_UNSET || duration <= 0) {
// updateVolumeMultiplier(1f)
// return
// }
//
// val remainingMs = (duration - player.currentPosition).coerceAtLeast(0L)
// updateVolumeMultiplierForRemainingTime(remainingMs)
// }

// private fun volumeMultiplierForRemainingTime(remainingMs: Long): Float {
// if (remainingMs >= FADE_OUT_WINDOW_MS) return 1f
// return (remainingMs.toFloat() / FADE_OUT_WINDOW_MS).coerceIn(0f, 1f)
// }
//
// private fun updateVolumeMultiplier(multiplier: Float) {
// onVolumeMultiplierChanged(multiplier)
// }
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.

I understand fade-out is deferred, but commenting out the implementation while keeping fadeOutEnabled as a live field and FADE_OUT_WINDOW_MS as a constant leaves the class in an inconsistent state. Either keep the functions (even if not called yet) or mark the feature clearly as a stub.

Comment on lines 550 to 712
// if (showSleepTimerDialog) {
// ActionPromptDialog(
// titleBar = {
// Row(
// modifier = Modifier.fillMaxWidth(),
// horizontalArrangement = Arrangement.Center,
// ) {
// Text(
// text = stringResource(R.string.sleep_timer),
// overflow = TextOverflow.Ellipsis,
// maxLines = 1,
// style = MaterialTheme.typography.headlineSmall,
// )
// }
// },
// onDismiss = { showSleepTimerDialog = false },
// onConfirm = {
// showSleepTimerDialog = false
// playerConnection.service.sleepTimer.start(
// value = sleepTimerValue.roundToInt(),
// _type = sleepTimerType
//// fadeOut = sleepTimerFadeOut,
// )
// },
// onCancel = {
// showSleepTimerDialog = false
// },
// onReset = {
// sleepTimerValue = sleepTimerDefault
// sleepTimerType = sleepTimerDefaultType
// },
// content = {
// Column(horizontalAlignment = Alignment.CenterHorizontally) {
//
// Row(
// horizontalArrangement = Arrangement.spacedBy(2.dp),
// verticalAlignment = Alignment.CenterVertically,
// modifier = Modifier
// ) {
// OutlinedToggleButton(
// checked = sleepTimerType == SleepTimer.TIME,
// onCheckedChange = {
// if (sleepTimerType == SleepTimer.SONGS) sleepTimerValue*=5
// sleepTimerType = 0
// },
// shapes = ButtonGroupDefaults.connectedLeadingButtonShapes(),
// colors = ToggleButtonDefaults.toggleButtonColors(),
// modifier = Modifier.weight(1f)
// ) {
// Text("Time")
// }
// OutlinedToggleButton(
// checked = sleepTimerType == SleepTimer.TIME_FINISH,
// onCheckedChange = {
// if (sleepTimerType == SleepTimer.SONGS) sleepTimerValue*=5
// sleepTimerType = SleepTimer.TIME_FINISH
// },
// shapes = ButtonGroupDefaults.connectedMiddleButtonShapes(),
// colors = ToggleButtonDefaults.toggleButtonColors(),
// modifier = Modifier.weight(1f)
// ) {
// Text("Time (FS)")
// }
// OutlinedToggleButton(
// checked = sleepTimerType == SleepTimer.SONGS,
// onCheckedChange = {
// if (sleepTimerType != SleepTimer.SONGS ) sleepTimerValue/=5
// sleepTimerType = SleepTimer.SONGS
// },
// shapes = ButtonGroupDefaults.connectedTrailingButtonShapes(),
// colors = ToggleButtonDefaults.toggleButtonColors(),
// modifier = Modifier.weight(1f)
// ) {
// Text("Songs")
// }
// }
//
// Text(
// text = "ⓘ FS: Finishes Song before stopping",
// style = MaterialTheme.typography.bodySmall,
// color = MaterialTheme.colorScheme.onSurfaceVariant,
// textAlign = TextAlign.End,
// modifier = Modifier.fillMaxWidth()
// )
//
// Spacer(Modifier.height(16.dp))
//
// Text(
// text = pluralStringResource(
// if (sleepTimerType == SleepTimer.SONGS) R.plurals.song else R.plurals.minute,
// sleepTimerValue.roundToInt(),
// sleepTimerValue.roundToInt(),
// ),
// style = MaterialTheme.typography.bodyLarge,
// )
//
// Spacer(Modifier.height(8.dp))
//
// val topValue = if (sleepTimerType == SleepTimer.SONGS) 24 else 120
// val bottomValue = if (sleepTimerType == SleepTimer.SONGS) 1 else 5
// Slider(
// value = sleepTimerValue,
// onValueChange = { sleepTimerValue = it },
// valueRange = bottomValue.toFloat()..topValue.toFloat(),
// steps = (topValue - bottomValue) / bottomValue - 1,
// modifier = Modifier.fillMaxWidth(),
// )
//
// Spacer(Modifier.height(8.dp))
//
// Row(
// horizontalArrangement = Arrangement.spacedBy(8.dp),
// verticalAlignment = Alignment.CenterVertically,
// ) {
// if (isAtDefault) {
// val text = stringResource(R.string.already_set_as_default)
// Button(
// onClick = {
// Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
// },
// colors = ButtonDefaults.buttonColors(
// containerColor = MaterialTheme.colorScheme.primary,
// contentColor = MaterialTheme.colorScheme.onPrimary,
// ),
// ) {
// Text(stringResource(R.string.set_as_default))
// }
// } else {
// OutlinedButton(
// onClick = {
// coroutineScope.launch {
// context.dataStore.edit { settings ->
// settings[SleepTimerDefaultKey] = sleepTimerValue
// settings[SleepTimerDefaultTypeKey] = sleepTimerType
// }
// }
// Toast.makeText(
// context,
// String.format(sleepTimerDefaultSetTemplate, sleepTimerValue.roundToInt()),
// Toast.LENGTH_SHORT,
// ).show()
// },
// ) {
// Text(stringResource(R.string.set_as_default))
// }
// }
//
//// OutlinedButton(
//// onClick = {
//// showSleepTimerDialog = false
//// playerConnection.service.sleepTimer.start(
//// minute = -1,
//// )
//// },
//// ) {
//// Text(stringResource(R.string.end_of_song))
//// }
// }
// }
// },
// )
// }

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.

Both Player.kt and Queue.kt entry points should reflect the new state model consistently. Disabling one is a UX regression for users who open the timer from the player screen directly.

@isotjs
Copy link
Copy Markdown
Contributor

isotjs commented Mar 21, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 21, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt (1)

183-188: ⚠️ Potential issue | 🟡 Minor

Remove duplicate imports.

These imports are duplicates of those already present earlier in the file (lines 115, 141-143, 170).

🧹 Proposed fix
-import com.metrolist.music.constants.SleepTimerDefaultKey
-import com.metrolist.music.utils.dataStore
-import androidx.datastore.preferences.core.edit
-import com.metrolist.music.constants.SleepTimerFadeOutKey
-import com.metrolist.music.constants.SleepTimerStopAfterCurrentSongKey
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt` around lines 183
- 188, Remove the duplicate imports listed in the diff (SleepTimerDefaultKey,
dataStore, edit, SleepTimerFadeOutKey, SleepTimerStopAfterCurrentSongKey) so
each symbol is imported only once; locate the repeated import statements in
Player.kt and delete the redundant import block leaving the original import
declarations earlier in the file intact.
🧹 Nitpick comments (2)
app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt (1)

48-84: Consider renaming _type parameter for clarity.

The underscore prefix is unconventional in Kotlin. Consider timerType or sleepTimerType to avoid shadowing the class property while maintaining readability.

♻️ Suggested improvement
 fun start(
     value: Int,
-    _type: Int,
+    timerType: Int,
     fadeOut: Boolean = false,
 ) {
     sleepTimerJob?.cancel()
     sleepTimerJob = null
     fadeOutEnabled = fadeOut
-    type = _type
+    type = timerType

-    if (type == SONGS) {
+    if (timerType == SONGS) {
         // ...
     } else {
         // ...
-            if (type == TIME_FINISH) {
+            if (timerType == TIME_FINISH) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt` around lines
48 - 84, Rename the confusing parameter `_type` in the SleepTimer.start function
to a clearer name (e.g., timerType or sleepTimerType) to avoid the
unconventional underscore prefix and shadowing with the class property `type`;
update all references inside `start` (including the assignment `type = _type`
and any comparisons against `SONGS` and `TIME_FINISH`) to use the new parameter
name so behavior remains the same.
app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt (1)

594-619: Consider clamping sleepTimerValue after type conversion.

The conversion logic multiplies/divides by 5 when switching modes. While the slider will eventually constrain the value, there's a brief moment where sleepTimerValue could be outside the new mode's valid range. Adding explicit clamping ensures consistent state.

♻️ Suggested improvement
 OutlinedToggleButton(
     checked = sleepTimerType == SleepTimer.TIME,
     onCheckedChange = {
-        if (sleepTimerType == SleepTimer.SONGS) sleepTimerValue*=5
+        if (sleepTimerType == SleepTimer.SONGS) {
+            sleepTimerValue = (sleepTimerValue * 5).coerceIn(5f, 120f)
+        }
         sleepTimerType = SleepTimer.TIME
     },
     // ...
 )
 // Apply similar pattern to other toggle buttons
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt` around lines 594
- 619, When switching sleepTimerType in the OutlinedToggleButton onCheckedChange
handlers, clamp the adjusted sleepTimerValue to the valid range for the target
mode to avoid briefly holding an out-of-range value; update the three
onCheckedChange blocks that modify sleepTimerValue (the handlers that
multiply/divide by 5 when setting sleepTimerType to SleepTimer.TIME,
SleepTimer.TIME_FINISH, or SleepTimer.SONGS) to call a clamp/coerceIn using the
min/max bounds used by the slider that controls sleepTimerValue so the value is
immediately constrained to the new mode's valid range.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt`:
- Around line 480-482: Persisted SleepTimerDefaultTypeKey may contain invalid
ints; before calling start(...) validate and normalize the retrieved value. Read
the raw int from dataStore into a local (e.g., sleepTimerDefaultTypeRaw),
map/validate it against the SleepTimer enum/constants (e.g., ensure it equals a
known value like SleepTimer.TIME or other supported types) and fall back to a
safe default (SleepTimer.TIME) if out of range or unknown; then pass the
normalized value into start(...). Apply the same validation logic for the other
occurrence around the code referenced at lines 562-563 so both start(...) calls
use a validated SleepTimer type.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt`:
- Around line 591-627: Replace the hardcoded labels in the three
OutlinedToggleButton composables and any related info text with
stringResource(...) lookups: change the "Time", "Time (FS)", and "Songs" Text()
calls to use resources (e.g. R.string.sleep_timer_time,
R.string.sleep_timer_time_finish, R.string.sleep_timer_songs) and update any
other hardcoded info text nearby to use string resources as well; add
corresponding entries to strings.xml (with appropriate keys and translations)
and import androidx.compose.ui.res.stringResource where needed so the UI uses
localized strings while leaving logic around sleepTimerType / SleepTimer and
sleepTimerValue untouched.

In `@app/src/main/res/values/metrolist_strings.xml`:
- Line 253: Replace the single string resource sleep_timer_default_set_songs
with an Android plurals resource (name it e.g. sleep_timer_default_set_songs)
that defines quantity="one" and quantity="other" forms, then update the code
that displays this message to use
Resources.getQuantityString(R.plurals.sleep_timer_default_set_songs, count,
count) (or context.resources.getQuantityString(...) /
resources.getQuantityString(...) in Kotlin) passing the song count as both the
quantity and the format argument so "1 song" and "N songs" render correctly.

In `@app/src/main/res/values/strings.xml`:
- Around line 211-214: The plural resource "song" was added to the wrong file
and its other form has a stray quote; remove the <plurals name="song"> entry
from strings.xml and add it into the Metrolist resource file as a corrected
plural resource: <plurals name="song"> with <item quantity="one">1 song</item>
and <item quantity="other">%d songs</item> (no trailing quote); ensure you only
modify the Metrolist/app/src/main/res/values/metrolist_strings.xml when adding
the corrected resource and do not change other strings.xml or
metrolist_strings.xml files.

---

Outside diff comments:
In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt`:
- Around line 183-188: Remove the duplicate imports listed in the diff
(SleepTimerDefaultKey, dataStore, edit, SleepTimerFadeOutKey,
SleepTimerStopAfterCurrentSongKey) so each symbol is imported only once; locate
the repeated import statements in Player.kt and delete the redundant import
block leaving the original import declarations earlier in the file intact.

---

Nitpick comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt`:
- Around line 48-84: Rename the confusing parameter `_type` in the
SleepTimer.start function to a clearer name (e.g., timerType or sleepTimerType)
to avoid the unconventional underscore prefix and shadowing with the class
property `type`; update all references inside `start` (including the assignment
`type = _type` and any comparisons against `SONGS` and `TIME_FINISH`) to use the
new parameter name so behavior remains the same.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt`:
- Around line 594-619: When switching sleepTimerType in the OutlinedToggleButton
onCheckedChange handlers, clamp the adjusted sleepTimerValue to the valid range
for the target mode to avoid briefly holding an out-of-range value; update the
three onCheckedChange blocks that modify sleepTimerValue (the handlers that
multiply/divide by 5 when setting sleepTimerType to SleepTimer.TIME,
SleepTimer.TIME_FINISH, or SleepTimer.SONGS) to call a clamp/coerceIn using the
min/max bounds used by the slider that controls sleepTimerValue so the value is
immediately constrained to the new mode's valid range.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 814ca679-8638-4806-a310-9dfe54ddb80c

📥 Commits

Reviewing files that changed from the base of the PR and between 5a9ec0e and 00dfb91.

📒 Files selected for processing (8)
  • app/src/main/kotlin/com/metrolist/music/constants/PreferenceKeys.kt
  • app/src/main/kotlin/com/metrolist/music/playback/MusicService.kt
  • app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt
  • app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt
  • app/src/main/res/values/metrolist_strings.xml
  • app/src/main/res/values/strings.xml

Comment thread app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt Outdated
<string name="set_as_default">Set as default</string>
<string name="already_set_as_default">Already set as default</string>
<string name="sleep_timer_default_set">Sleep timer default set to %d min</string>
<string name="sleep_timer_default_set_songs">Sleep timer default set to %d songs</string>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Use pluralization for the songs default message.

"%d songs" will render incorrectly for 1. Please switch this to a plural resource and fetch it with quantity.

💡 Proposed fix
-    <string name="sleep_timer_default_set_songs">Sleep timer default set to %d songs</string>
+    <plurals name="sleep_timer_default_set_songs">
+        <item quantity="one">Sleep timer default set to %d song</item>
+        <item quantity="other">Sleep timer default set to %d songs</item>
+    </plurals>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<string name="sleep_timer_default_set_songs">Sleep timer default set to %d songs</string>
<plurals name="sleep_timer_default_set_songs">
<item quantity="one">Sleep timer default set to %d song</item>
<item quantity="other">Sleep timer default set to %d songs</item>
</plurals>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/res/values/metrolist_strings.xml` at line 253, Replace the
single string resource sleep_timer_default_set_songs with an Android plurals
resource (name it e.g. sleep_timer_default_set_songs) that defines
quantity="one" and quantity="other" forms, then update the code that displays
this message to use
Resources.getQuantityString(R.plurals.sleep_timer_default_set_songs, count,
count) (or context.resources.getQuantityString(...) /
resources.getQuantityString(...) in Kotlin) passing the song count as both the
quantity and the format argument so "1 song" and "N songs" render correctly.

Comment thread app/src/main/res/values/strings.xml Outdated
Comment on lines +211 to +214
<plurals name="song">
<item quantity="one">1 song</item>
<item quantity="other">%d songs"</item>
</plurals>
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Move this resource out of upstream strings.xml and fix the value typo.

This new feature string is added in the wrong file, and the other form includes an extra trailing quote (%d songs").

💡 Proposed fix
-    <plurals name="song">
-        <item quantity="one">1 song</item>
-        <item quantity="other">%d songs"</item>
-    </plurals>

Then add the corrected resource under app/src/main/res/values/metrolist_strings.xml instead.

Based on learnings: All string edits must be made to the Metrolist/app/src/main/res/values/metrolist_strings.xml file, NOT Metrolist/app/src/main/res/values/strings.xml. Do not touch other strings.xml or metrolist_strings.xml files in the project.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/res/values/strings.xml` around lines 211 - 214, The plural
resource "song" was added to the wrong file and its other form has a stray
quote; remove the <plurals name="song"> entry from strings.xml and add it into
the Metrolist resource file as a corrected plural resource: <plurals
name="song"> with <item quantity="one">1 song</item> and <item
quantity="other">%d songs</item> (no trailing quote); ensure you only modify the
Metrolist/app/src/main/res/values/metrolist_strings.xml when adding the
corrected resource and do not change other strings.xml or metrolist_strings.xml
files.

@nyxiereal
Copy link
Copy Markdown
Member

DO NOT TOUCH strings.xml!!!!!!!!!

@adrielGGmotion
Copy link
Copy Markdown
Collaborator

I think they touched strings.xml... 🧐

@Ryfters
Copy link
Copy Markdown
Author

Ryfters commented Mar 21, 2026

oops... 🫣

@Ryfters
Copy link
Copy Markdown
Author

Ryfters commented Mar 21, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 21, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt (1)

90-116: ⚠️ Potential issue | 🔴 Critical

Critical: Double decrement of remainingSongs causes timer to end prematurely.

Both notifySongTransition() (called by MusicService during crossfade) and onMediaItemTransition() (player listener callback) decrement remainingSongs. When a normal song transition occurs, onMediaItemTransition fires. When crossfade triggers, MusicService calls notifySongTransition(). However, if both fire for the same transition, or if a manual skip triggers both paths, remainingSongs drops twice.

This is likely the root cause of the known bug where manually skipping with 1 song remaining ends the timer without pausing.

🐛 Proposed fix: guard against double-decrement per transition
+    private var lastTransitionTimestamp = 0L
+    private val transitionDebounceMs = 100L
+
     fun notifySongTransition() {
-        if (remainingSongs > 0) {
-            if (--remainingSongs == 0){
+        val now = System.currentTimeMillis()
+        if (remainingSongs > 0 && (now - lastTransitionTimestamp) > transitionDebounceMs) {
+            lastTransitionTimestamp = now
+            if (--remainingSongs == 0) {
                 completeTimerAndPause()
             }
         }
     }
 
     // ...
 
     override fun onMediaItemTransition(
         mediaItem: MediaItem?,
         reason: Int,
     ) {
-        if (remainingSongs > 0) {
-            if (--remainingSongs == 0){
+        val now = System.currentTimeMillis()
+        if (remainingSongs > 0 && (now - lastTransitionTimestamp) > transitionDebounceMs) {
+            lastTransitionTimestamp = now
+            if (--remainingSongs == 0) {
                 completeTimerAndPause()
             }
         }
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt` around lines
90 - 116, notifySongTransition() and onMediaItemTransition(...) both decrement
remainingSongs causing double-decrements; fix by ensuring each physical
transition only decrements once: add a small transition-identity guard (e.g., a
field like lastHandledMediaId or lastHandledTransitionTimestamp) and in both
notifySongTransition() and onMediaItemTransition() check that the current
transition identity differs from lastHandled before doing --remainingSongs and
calling completeTimerAndPause(); update lastHandled after you decrement.
Reference symbols: notifySongTransition, onMediaItemTransition, remainingSongs,
completeTimerAndPause.
🧹 Nitpick comments (7)
app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt (3)

534-534: Consider consolidating coroutine scope declarations.

There are two rememberCoroutineScope() calls in this composable (lines 206 and 534). While functionally equivalent, consolidating to a single scope improves readability.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt` at line 534, The
composable currently calls rememberCoroutineScope() twice; consolidate to a
single coroutine scope by declaring one rememberCoroutineScope() (e.g., val
scope = rememberCoroutineScope()) in the top-level of the Player composable (the
existing declaration near line ~206) and remove the second declaration (the one
at the later site referencing val scope = rememberCoroutineScope()); update any
usages to reference that single scope variable so all launches use the same
scope.

608-644: Value conversion when switching types may produce unexpected results.

When switching from time to songs mode, sleepTimerValue /= 5 could produce fractional values (e.g., 7 minutes → 1.4 songs). While the slider clamps to bottomValue=1, the intermediate float value persists until user interaction. Consider rounding or clamping immediately:

♻️ Clamp value after conversion
 OutlinedToggleButton(
     checked = sleepTimerType == SleepTimer.SONGS,
     onCheckedChange = {
-        if (sleepTimerType != SleepTimer.SONGS ) sleepTimerValue/=5
+        if (sleepTimerType != SleepTimer.SONGS) {
+            sleepTimerValue = (sleepTimerValue / 5).coerceIn(1f, 24f)
+        }
         sleepTimerType = SleepTimer.SONGS
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt` around lines 608
- 644, The sleep-timer value conversion can produce fractional values when
toggling types (sleepTimerType, SleepTimer.SONGS, SleepTimer.TIME, TIME_FINISH,
and sleepTimerValue), so update the toggle handlers to normalize the value
immediately: when switching to SleepTimer.SONGS divide the current value by 5
then round (e.g., round or ceil) and clamp to a minimum of 1 (and cast to an
integer/count type if you use integers for songs); when switching from SONGS to
a time mode multiply and clamp/round as needed; apply the normalization in the
onCheckedChange blocks for the three OutlinedToggleButton handlers so the stored
sleepTimerValue is always valid and non-fractional for SONGS and stays within
allowed bounds.

548-553: Verify that sleepTimerEnabled recomposes correctly.

Using remember() with the timer's state properties as keys should work, but consider using derivedStateOf for consistency with other derived state in this file (like isAtDefault). The current approach relies on Compose runtime snapshot state from SleepTimer, which should trigger recomposition, but the explicit remember keys may cause unnecessary recompositions.

♻️ Alternative using derivedStateOf
-    val sleepTimerEnabled = remember(
-        playerConnection.service.sleepTimer.triggerTime,
-        playerConnection.service.sleepTimer.remainingSongs
-    ) {
-        playerConnection.service.sleepTimer.isActive
-    }
+    val sleepTimerEnabled by remember {
+        derivedStateOf { playerConnection.service.sleepTimer.isActive }
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt` around lines 548
- 553, The current remember(...) block that computes sleepTimerEnabled from
playerConnection.service.sleepTimer properties can cause unnecessary
recompositions; replace it with a derived state to mirror other derived state
usages (like isAtDefault). Specifically, change the sleepTimerEnabled
declaration to use derivedStateOf { playerConnection.service.sleepTimer.isActive
} (or a derived expression that reads triggerTime/remainingSongs if you need
them) so Compose tracks the underlying snapshot reads instead of relying on
explicit remember keys; keep the same variable name sleepTimerEnabled and
reference playerConnection.service.sleepTimer to locate the code.
app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt (3)

614-619: Same value conversion edge case as in Player.kt.

The /5 conversion when switching to songs mode can produce fractional values. Apply the same fix recommended for Player.kt (coerce to valid range after conversion).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt` around lines 614
- 619, When switching to Songs mode in the OutlinedToggleButton handler (checked
= sleepTimerType == SleepTimer.SONGS) the current division sleepTimerValue /= 5
can produce non-integer/invalid values; after converting (divide by 5) coerce
the result into the valid range for song counts (use the same clamping logic
used in Player.kt) and then assign sleepTimerType = SleepTimer.SONGS so
sleepTimerValue is always within allowed bounds; locate the OutlinedToggleButton
onCheckedChange block and replace the raw division with
convert-then-coerce-to-valid-range logic referencing sleepTimerValue and
SleepTimer.SONGS.

336-336: Consider adding unit indicator to sleep timer remaining display.

When displaying sleepTimerRemaining for songs mode, it shows just a number (e.g., "5"). Users may not immediately understand this represents songs remaining vs. minutes. Consider adding a suffix or icon to differentiate.

♻️ Example with unit indicator
text = if (sleepTimerEnabled) {
    if (playerConnection.service.sleepTimer.type == SleepTimer.SONGS) {
        "$sleepTimerRemaining" // or use an icon
    } else {
        sleepTimerRemaining
    }
} else null
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt` at line 336, The
sleep timer label currently shows only sleepTimerRemaining which is ambiguous;
update the expression assigning text (the line using sleepTimerEnabled and
sleepTimerRemaining) to append a unit or icon when
playerConnection.service.sleepTimer.type == SleepTimer.SONGS (e.g., add a "♫"
prefix/suffix or "songs" suffix) so song-mode shows a clear indicator while
keeping the existing value for minute mode; change the inline conditional at
text = ... to branch on SleepTimer.SONGS and return the modified string when
enabled, otherwise return the original sleepTimerRemaining or null.

582-706: Recommend extracting shared sleep timer dialog to a reusable component.

The sleep timer dialog implementation in Queue.kt is nearly identical to Player.kt. Consider extracting to a shared composable (e.g., SleepTimerDialog) to reduce duplication and ensure future changes stay synchronized.

♻️ Example extraction

Create a new composable in a shared location:

`@Composable`
fun SleepTimerDialog(
    sleepTimerValue: Float,
    onSleepTimerValueChange: (Float) -> Unit,
    sleepTimerType: Int,
    onSleepTimerTypeChange: (Int) -> Unit,
    sleepTimerDefault: Float,
    sleepTimerDefaultType: Int,
    isAtDefault: Boolean,
    onDismiss: () -> Unit,
    onConfirm: () -> Unit,
) {
    // Shared implementation
}

Then both Player.kt and Queue.kt can use this component.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt` around lines 582
- 706, The sleep-timer UI block in Queue.kt (the Column starting at content = {
... } using sleepTimerValue, sleepTimerType, isAtDefault, coroutineScope,
SleepTimerDefaultKey, SleepTimerDefaultTypeKey, and context) is duplicated in
Player.kt; extract it into a shared composable like SleepTimerDialog(...) that
takes sleepTimerValue, onSleepTimerValueChange, sleepTimerType,
onSleepTimerTypeChange, isAtDefault, onSetAsDefault (or onConfirm) and onDismiss
callbacks (or pass context if needed), move the Slider/Buttons/Toast/datastore
edit logic into that composable or into the onSetAsDefault callback, and replace
the inline Column blocks in both Queue.kt and Player.kt with calls to
SleepTimerDialog to remove duplication and keep behavior identical.
app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt (1)

43-45: Acknowledged: fadeOutEnabled is a placeholder.

The comment clarifies fade-out is disabled. Consider removing this field entirely if fade-out won't be implemented soon, or add a TODO comment with a tracking issue reference.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt` around lines
43 - 45, The placeholder property fadeOutEnabled (declared as var fadeOutEnabled
by mutableStateOf(false) in SleepTimer) should not remain unused: if no fade-out
feature will be implemented soon, remove the fadeOutEnabled property and any
references to it (search for fadeOutEnabled in SleepTimer and callers) to avoid
dead state; otherwise keep the property but replace the inline comment with a
TODO that references the tracking issue/ID (e.g., TODO: implement fade-out —
ISSUE-123) and document why it's disabled, so intent is clear.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt`:
- Around line 563-564: The log message is misleading when sleepTimerDefaultType
== SONGS because sleepTimerDefaultValue represents a song count, not minutes;
update the logging call (using TAG, sleepTimerDefaultValue,
sleepTimerDefaultType and the call to service.sleepTimer.start) to conditionally
format the message—e.g., log "AUTO SLEEP TIMER STARTED: X minutes" when type is
MINUTES and "AUTO SLEEP TIMER STARTED: X songs" when type is SONGS—so the log
matches the unit passed to service.sleepTimer.start.

---

Duplicate comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt`:
- Around line 90-116: notifySongTransition() and onMediaItemTransition(...) both
decrement remainingSongs causing double-decrements; fix by ensuring each
physical transition only decrements once: add a small transition-identity guard
(e.g., a field like lastHandledMediaId or lastHandledTransitionTimestamp) and in
both notifySongTransition() and onMediaItemTransition() check that the current
transition identity differs from lastHandled before doing --remainingSongs and
calling completeTimerAndPause(); update lastHandled after you decrement.
Reference symbols: notifySongTransition, onMediaItemTransition, remainingSongs,
completeTimerAndPause.

---

Nitpick comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt`:
- Around line 43-45: The placeholder property fadeOutEnabled (declared as var
fadeOutEnabled by mutableStateOf(false) in SleepTimer) should not remain unused:
if no fade-out feature will be implemented soon, remove the fadeOutEnabled
property and any references to it (search for fadeOutEnabled in SleepTimer and
callers) to avoid dead state; otherwise keep the property but replace the inline
comment with a TODO that references the tracking issue/ID (e.g., TODO: implement
fade-out — ISSUE-123) and document why it's disabled, so intent is clear.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt`:
- Line 534: The composable currently calls rememberCoroutineScope() twice;
consolidate to a single coroutine scope by declaring one
rememberCoroutineScope() (e.g., val scope = rememberCoroutineScope()) in the
top-level of the Player composable (the existing declaration near line ~206) and
remove the second declaration (the one at the later site referencing val scope =
rememberCoroutineScope()); update any usages to reference that single scope
variable so all launches use the same scope.
- Around line 608-644: The sleep-timer value conversion can produce fractional
values when toggling types (sleepTimerType, SleepTimer.SONGS, SleepTimer.TIME,
TIME_FINISH, and sleepTimerValue), so update the toggle handlers to normalize
the value immediately: when switching to SleepTimer.SONGS divide the current
value by 5 then round (e.g., round or ceil) and clamp to a minimum of 1 (and
cast to an integer/count type if you use integers for songs); when switching
from SONGS to a time mode multiply and clamp/round as needed; apply the
normalization in the onCheckedChange blocks for the three OutlinedToggleButton
handlers so the stored sleepTimerValue is always valid and non-fractional for
SONGS and stays within allowed bounds.
- Around line 548-553: The current remember(...) block that computes
sleepTimerEnabled from playerConnection.service.sleepTimer properties can cause
unnecessary recompositions; replace it with a derived state to mirror other
derived state usages (like isAtDefault). Specifically, change the
sleepTimerEnabled declaration to use derivedStateOf {
playerConnection.service.sleepTimer.isActive } (or a derived expression that
reads triggerTime/remainingSongs if you need them) so Compose tracks the
underlying snapshot reads instead of relying on explicit remember keys; keep the
same variable name sleepTimerEnabled and reference
playerConnection.service.sleepTimer to locate the code.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt`:
- Around line 614-619: When switching to Songs mode in the OutlinedToggleButton
handler (checked = sleepTimerType == SleepTimer.SONGS) the current division
sleepTimerValue /= 5 can produce non-integer/invalid values; after converting
(divide by 5) coerce the result into the valid range for song counts (use the
same clamping logic used in Player.kt) and then assign sleepTimerType =
SleepTimer.SONGS so sleepTimerValue is always within allowed bounds; locate the
OutlinedToggleButton onCheckedChange block and replace the raw division with
convert-then-coerce-to-valid-range logic referencing sleepTimerValue and
SleepTimer.SONGS.
- Line 336: The sleep timer label currently shows only sleepTimerRemaining which
is ambiguous; update the expression assigning text (the line using
sleepTimerEnabled and sleepTimerRemaining) to append a unit or icon when
playerConnection.service.sleepTimer.type == SleepTimer.SONGS (e.g., add a "♫"
prefix/suffix or "songs" suffix) so song-mode shows a clear indicator while
keeping the existing value for minute mode; change the inline conditional at
text = ... to branch on SleepTimer.SONGS and return the modified string when
enabled, otherwise return the original sleepTimerRemaining or null.
- Around line 582-706: The sleep-timer UI block in Queue.kt (the Column starting
at content = { ... } using sleepTimerValue, sleepTimerType, isAtDefault,
coroutineScope, SleepTimerDefaultKey, SleepTimerDefaultTypeKey, and context) is
duplicated in Player.kt; extract it into a shared composable like
SleepTimerDialog(...) that takes sleepTimerValue, onSleepTimerValueChange,
sleepTimerType, onSleepTimerTypeChange, isAtDefault, onSetAsDefault (or
onConfirm) and onDismiss callbacks (or pass context if needed), move the
Slider/Buttons/Toast/datastore edit logic into that composable or into the
onSetAsDefault callback, and replace the inline Column blocks in both Queue.kt
and Player.kt with calls to SleepTimerDialog to remove duplication and keep
behavior identical.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4009d816-4250-4625-8ab6-b293ee1abb2e

📥 Commits

Reviewing files that changed from the base of the PR and between 00dfb91 and 5fd1c22.

📒 Files selected for processing (5)
  • app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt
  • app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt
  • app/src/main/res/values/metrolist_strings.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/res/values/metrolist_strings.xml

Comment thread app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt Outdated
 - unedited strings.xml (moved to metrolist_strings.xml)
 - rename SleepTimer.start() _type param to timerType
 - pluralised sleep_timer_default_set_songs string
 - removed unused/unworking logic for fadeout
 - set the volume multiplier back to 1
 - clear unused imports
 - ensure sleepTimerDefaultType is valid
 - clear sleep timer songs on playlist end
 - stringified hardcoded strings
 - fixed log message
 - extracted sleep timer dialog to a new component
@Ryfters Ryfters force-pushed the feat/redesign-sleeptimer-ui branch from 5fd1c22 to cd714aa Compare March 21, 2026 21:55
@Ryfters Ryfters marked this pull request as ready for review March 21, 2026 21:59
@Ryfters Ryfters changed the title RFC: feat: add song countdown for sleep timer, redesign sleep timer ui feat: add song countdown for sleep timer, redesign sleep timer ui Mar 21, 2026
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt (1)

533-535: ⚠️ Potential issue | 🟠 Major

Player screen still can't open the sleep timer dialog.

showSleepTimerDialog is only declared and consumed here; nothing in BottomSheetPlayer ever sets it to true. That leaves this dialog unreachable from the player screen, while Queue.kt still has the working entry point. Please wire the player-side action back to this state or remove the dead dialog until that path is restored.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt` around lines 533
- 535, The sleep timer dialog is never opened because showSleepTimerDialog is
only declared and passed to SleepTimerDialog but never set to true by
BottomSheetPlayer; either remove the dead dialog or restore the player-side
trigger by wiring an opener callback into BottomSheetPlayer. Modify
BottomSheetPlayer to accept a lambda like onOpenSleepTimer: () -> Unit (or a
MutableState<Boolean> setter) and call that lambda from the UI action that
should open the timer; in Player.kt set showSleepTimerDialog = true in that
callback (or pass showSleepTimerDialog setter), or alternatively remove the
SleepTimerDialog/SleepTimerDialog(...) invocation if you prefer to keep the
dialog unreachable until the path is reintroduced.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt`:
- Around line 111-114: When the sleep timer completes (completeTimerAndPause())
set a durable flag (e.g., timerCompleted or suppressPlayOnSkip) in SleepTimer
and expose a read-only accessor (or callback) so PlayerConnection can consult
it; then change PlayerConnection.seekToNext() and seekToPrevious() to check that
flag before forcing player.playWhenReady = true and skip re-enabling playback if
the timer has completed. Ensure the flag is set to true inside
completeTimerAndPause() and cleared appropriately when the timer is reset or
restarted.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/SleepTimerDialog.kt`:
- Around line 63-70: The local dialog state (sleepTimerDefault, sleepTimerValue,
sleepTimerDefaultType, sleepTimerType) is declared outside the dialog visibility
check so canceled edits persist across closes; reinitialize or recreate those
states when the dialog is opened by scoping them to the `if (show)` block or by
using a side-effect keyed on `show` (e.g., LaunchedEffect(show) or remember(key
= show)) to reset sleepTimerValue from rememberPreference(SleepTimerDefaultKey)
and sleepTimerType from rememberPreference(SleepTimerDefaultTypeKey) whenever
show becomes true so the dialog always starts with the stored defaults.

---

Duplicate comments:
In `@app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt`:
- Around line 533-535: The sleep timer dialog is never opened because
showSleepTimerDialog is only declared and passed to SleepTimerDialog but never
set to true by BottomSheetPlayer; either remove the dead dialog or restore the
player-side trigger by wiring an opener callback into BottomSheetPlayer. Modify
BottomSheetPlayer to accept a lambda like onOpenSleepTimer: () -> Unit (or a
MutableState<Boolean> setter) and call that lambda from the UI action that
should open the timer; in Player.kt set showSleepTimerDialog = true in that
callback (or pass showSleepTimerDialog setter), or alternatively remove the
SleepTimerDialog/SleepTimerDialog(...) invocation if you prefer to keep the
dialog unreachable until the path is reintroduced.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b0795c79-6a77-4c27-b25a-70edd25e87f3

📥 Commits

Reviewing files that changed from the base of the PR and between 5fd1c22 and cd714aa.

📒 Files selected for processing (6)
  • app/src/main/kotlin/com/metrolist/music/playback/PlayerConnection.kt
  • app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/Player.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/Queue.kt
  • app/src/main/kotlin/com/metrolist/music/ui/player/SleepTimerDialog.kt
  • app/src/main/res/values/metrolist_strings.xml
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/main/res/values/metrolist_strings.xml

Comment on lines +111 to +114
if (remainingSongs > 0) {
if (--remainingSongs == 0){
completeTimerAndPause()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Manual skip still overrides the final-song pause.

When remainingSongs reaches zero here, completeTimerAndPause() pauses the player, but the manual skip paths in PlayerConnection.seekToNext() / seekToPrevious() still force player.playWhenReady = true after the seek. That is why "1 song remaining + skip" keeps playing. The completion path needs to stop those handlers from re-enabling playback once the timer has fired.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/playback/SleepTimer.kt` around lines
111 - 114, When the sleep timer completes (completeTimerAndPause()) set a
durable flag (e.g., timerCompleted or suppressPlayOnSkip) in SleepTimer and
expose a read-only accessor (or callback) so PlayerConnection can consult it;
then change PlayerConnection.seekToNext() and seekToPrevious() to check that
flag before forcing player.playWhenReady = true and skip re-enabling playback if
the timer has completed. Ensure the flag is set to true inside
completeTimerAndPause() and cleared appropriately when the timer is reset or
restarted.

Comment on lines +63 to +70
val sleepTimerDefault by rememberPreference(SleepTimerDefaultKey, 30f)
var sleepTimerValue by remember { mutableFloatStateOf(sleepTimerDefault) }
val sleepTimerDefaultType by rememberPreference(SleepTimerDefaultTypeKey, SleepTimer.TIME)
var sleepTimerType by remember {
mutableIntStateOf(sleepTimerDefaultType.takeIf {
it in listOf(SleepTimer.TIME, TIME_FINISH, SleepTimer.SONGS)
} ?: SleepTimer.TIME)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Canceled edits persist across dialog reopen.

These local states live outside if (show), so dismissing the dialog does not discard unsaved changes. Reopening it can start the last canceled value/type instead of the current stored default. Reinitialize them when show becomes true.

🔧 One way to resync the dialog on open
 val sleepTimerDefault by rememberPreference(SleepTimerDefaultKey, 30f)
 var sleepTimerValue by remember { mutableFloatStateOf(sleepTimerDefault) }
 val sleepTimerDefaultType by rememberPreference(SleepTimerDefaultTypeKey, SleepTimer.TIME)
-var sleepTimerType by remember {
-    mutableIntStateOf(sleepTimerDefaultType.takeIf {
-        it in listOf(SleepTimer.TIME, TIME_FINISH, SleepTimer.SONGS)
-    } ?: SleepTimer.TIME)
-}
+val normalizedDefaultType =
+    sleepTimerDefaultType.takeIf {
+        it in listOf(SleepTimer.TIME, TIME_FINISH, SleepTimer.SONGS)
+    } ?: SleepTimer.TIME
+var sleepTimerType by remember { mutableIntStateOf(normalizedDefaultType) }
+
+LaunchedEffect(show, sleepTimerDefault, normalizedDefaultType) {
+    if (show) {
+        sleepTimerValue = sleepTimerDefault
+        sleepTimerType = normalizedDefaultType
+    }
+}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
val sleepTimerDefault by rememberPreference(SleepTimerDefaultKey, 30f)
var sleepTimerValue by remember { mutableFloatStateOf(sleepTimerDefault) }
val sleepTimerDefaultType by rememberPreference(SleepTimerDefaultTypeKey, SleepTimer.TIME)
var sleepTimerType by remember {
mutableIntStateOf(sleepTimerDefaultType.takeIf {
it in listOf(SleepTimer.TIME, TIME_FINISH, SleepTimer.SONGS)
} ?: SleepTimer.TIME)
}
val sleepTimerDefault by rememberPreference(SleepTimerDefaultKey, 30f)
var sleepTimerValue by remember { mutableFloatStateOf(sleepTimerDefault) }
val sleepTimerDefaultType by rememberPreference(SleepTimerDefaultTypeKey, SleepTimer.TIME)
val normalizedDefaultType =
sleepTimerDefaultType.takeIf {
it in listOf(SleepTimer.TIME, TIME_FINISH, SleepTimer.SONGS)
} ?: SleepTimer.TIME
var sleepTimerType by remember { mutableIntStateOf(normalizedDefaultType) }
LaunchedEffect(show, sleepTimerDefault, normalizedDefaultType) {
if (show) {
sleepTimerValue = sleepTimerDefault
sleepTimerType = normalizedDefaultType
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/com/metrolist/music/ui/player/SleepTimerDialog.kt` around
lines 63 - 70, The local dialog state (sleepTimerDefault, sleepTimerValue,
sleepTimerDefaultType, sleepTimerType) is declared outside the dialog visibility
check so canceled edits persist across closes; reinitialize or recreate those
states when the dialog is opened by scoping them to the `if (show)` block or by
using a side-effect keyed on `show` (e.g., LaunchedEffect(show) or remember(key
= show)) to reset sleepTimerValue from rememberPreference(SleepTimerDefaultKey)
and sleepTimerType from rememberPreference(SleepTimerDefaultTypeKey) whenever
show becomes true so the dialog always starts with the stored defaults.

@isotjs
Copy link
Copy Markdown
Contributor

isotjs commented Mar 23, 2026

Redesign the buttons to match the apps standards

@nyxiereal
Copy link
Copy Markdown
Member

Stale + unwanted kinda

@nyxiereal nyxiereal closed this Apr 4, 2026
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.

4 participants