Skip to content

Commit 6dd22ca

Browse files
committed
fix review comments
Signed-off-by: VishnuSanal <t.v.s10123@gmail.com>
1 parent def3b7e commit 6dd22ca

10 files changed

Lines changed: 215 additions & 47 deletions

File tree

CLAUDE.md

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
77
DialogMusicPlayer is a minimal Android music player that displays as a dialog. It handles audio files via `VIEW`/`SEND` intents — it is not a library browser. Licensed under GPLv3.
88

99
Package: `phone.vishnu.dialogmusicplayer` (debug suffix: `.debug`)
10-
Min SDK 21, Target SDK 35, Kotlin 2.0, Java 21.
10+
Min SDK 21, Target SDK 35, Kotlin 2.0, Java 21. The app code is Kotlin + Jetpack
11+
Compose; only the boilerplate test stubs remain in Java.
1112

1213
## Build Commands
1314

@@ -23,27 +24,29 @@ Min SDK 21, Target SDK 35, Kotlin 2.0, Java 21.
2324
## Code Formatting
2425

2526
Spotless is configured in the root `build.gradle`:
26-
- **Java**: Google Java Format (AOSP style), import order: `android, androidx, com, java, phone`, unused imports removed
27+
- **Java**: Google Java Format (AOSP style), import order: `android, androidx, com, java, phone`, unused imports removed (only the test stubs are Java)
2728
- **Kotlin**: ktlint 0.49.1, trailing whitespace trimmed
2829
- **XML**: tabs for indentation, trailing whitespace trimmed
2930
- All source files must have the GPLv3 license header from `spotless-header`
3031

3132
## Architecture
3233

33-
Single-module app (`app/`), single-activity (`MainActivity`), mixed Java/Kotlin codebase.
34+
Single-module app (`app/`), single-activity (`MainActivity`), Kotlin codebase. MVVM:
35+
`MainActivity` maps `MediaControllerCompat` callbacks into a `StateFlow<PlayerUiState>`
36+
on `MainViewModel`; the Compose UI renders that state.
3437

35-
**UI layer (Java):**
36-
- `MainActivity` — Dialog-styled activity that receives audio URIs via intent, connects to `MediaPlaybackService` through `MediaBrowserCompat`, controls playback via `MediaControllerCompat`
37-
- Layout: `activity_main.xml` (single layout)
38+
**UI layer (Kotlin + Jetpack Compose):**
39+
- `MainActivity` — Dialog-styled activity that receives audio URIs via intent, connects to `MediaPlaybackService` through `MediaBrowserCompat`, controls playback via `MediaControllerCompat`, and hosts the Compose UI with `setContent`
40+
- `PlayerScreen` — the player UI as composables (album art, marquee title/artist, slider, transport + speed controls)
41+
- `PlayerUiState` — immutable snapshot the screen renders; `DmpTheme` — Material3 theme wrapper (dynamic color on API 31+)
3842

39-
**Playback layer (Java):**
43+
**Playback layer (Kotlin):**
4044
- `MediaPlaybackService``MediaBrowserServiceCompat` that manages `MediaPlayer`, audio focus, media session, and foreground notification. Communicates state back to `MainActivity` via media session callbacks.
4145

4246
**Persistence layer (Kotlin):**
4347
- Room database for saving/resuming playback position per track
4448
- `SaveItem` (entity, keyed by track ID) → `SaveItemDao``SaveItemDatabase``SaveItemRepository``MainViewModel` (coroutines + LiveData)
4549

46-
**Utilities (Java):**
50+
**Utilities (Kotlin):**
4751
- `AudioUtils` — metadata extraction from audio URIs
48-
- `ColorUtils` — dynamic theming from album art
49-
- `FileUtils` — file path resolution from URIs
52+
- `FileUtils` — one-time cleanup of legacy internal-storage files

CODE_REVIEW_NOTES.md

Lines changed: 53 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,9 @@ app/src/main/java/phone/vishnu/dialogmusicplayer/SaveItemDatabase.kt
9999
app/src/main/java/phone/vishnu/dialogmusicplayer/SaveItemRepository.kt
100100
```
101101

102-
`AndroidManifest.xml` was intentionally **not** changedSpotless wanted a
103-
whitespace-only reformat of a pre-existing comment block; out of scope for this
104-
pass.
102+
`AndroidManifest.xml` carries one **non-functional** additionan API /
103+
testing-status comment block at the top of the file. No permissions,
104+
components or attributes were changed.
105105

106106
---
107107

@@ -176,8 +176,10 @@ The whole UI layer was migrated from XML Views to Jetpack Compose.
176176
### Removed
177177
- `activity_main.xml` and `layout-v23/activity_main.xml`.
178178
- `ColorUtils.kt`.
179-
- The (already `visibility="gone"`) playback-speed UI was not carried over — it
180-
was dead UI in the XML version. The service still supports speed changes.
179+
180+
> **Correction (see §9.2):** an earlier draft of these notes claimed the
181+
> playback-speed button was dead UI. It was not — `layout-v23/activity_main.xml`
182+
> showed it on API 23+. The Compose `SpeedButton` restores that control.
181183
182184
### Not verifiable here
183185
`assembleDebug` passes, but **the dialog window sizing, dynamic theming, marquee
@@ -224,3 +226,49 @@ listener. On the test device `requestAudioFocus()` returned
224226

225227
Verified on device: file plays, position advances, metadata + embedded album
226228
art render in the Compose UI.
229+
230+
---
231+
232+
## 9. PR #68 review fixes
233+
234+
Addressed the 11 automated review comments on
235+
[PR #68](https://github.com/VishnuSanal/DialogMusicPlayer/pull/68).
236+
237+
### 9.1 Correctness
238+
- **`FileUtils.clearApplicationData`** marked the legacy cleanup "done" even when
239+
`deleteRecursively()` failed (its `Boolean` result was ignored and the
240+
preference was written unconditionally). Now the flag is persisted only when
241+
every entry was actually deleted, so a failed/partial sweep is retried.
242+
- **`MediaPlaybackService` repeat-one** — an `isPlayingOnceInProgress` flag made
243+
`REPEAT_MODE_ONE` replay only every *other* completion. Removed the flag;
244+
repeat-one (and repeat-all) now replay on every completion.
245+
246+
### 9.2 Restored playback-speed control
247+
The Compose migration dropped the speed button, but `layout-v23` exposed it on
248+
API 23+ and the service still handles `onSetPlaybackSpeed`. Restored:
249+
- `PlayerUiState.playbackSpeed`; `MainViewModel.onPlaybackStateChanged` now
250+
carries speed (keeping the last real value while the session reports `0`).
251+
- `PlayerScreen` has a tappable `SpeedButton` (0.5×→0.75×→1×→1.25×→1.5×→2×),
252+
shown only on API 23+ — exactly where the service honours it.
253+
- `MainActivity.cyclePlaybackSpeed()` drives it via `setPlaybackSpeed`.
254+
255+
### 9.3 Accessibility
256+
- The remaining-time toggle used a raw `pointerInput`/`detectTapGestures`, which
257+
is invisible to TalkBack and keyboard users. Switched to `Modifier.clickable`
258+
with `Role.Button` and an `onClickLabel`. The new `SpeedButton` does the same.
259+
- Rewind/forward content descriptions moved to string resources.
260+
261+
### 9.4 Permission gating for shared files
262+
`onCreate` gated *all* playback on the library-wide media permission. A
263+
`content://` URI from `ACTION_VIEW`/`SEND` carries its own temporary read grant,
264+
so a file the user explicitly opened can play without that permission. Playback
265+
now starts when the essential permission is held **or** the intent URI is a
266+
`content://` URI (`canPlayWithoutPermission`).
267+
268+
### 9.5 Documentation
269+
- `CLAUDE.md` updated — the UI is Kotlin + Compose (not Java/XML), the playback
270+
layer is Kotlin, `ColorUtils` is gone, and only the test stubs are Java.
271+
- The root `build.gradle` Java Spotless block was restored (the two Java test
272+
stubs still need formatting/header coverage).
273+
- §4 and §7 above corrected (manifest comment block; speed button was not dead
274+
UI).

app/src/main/java/phone/vishnu/dialogmusicplayer/FileUtils.kt

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,21 @@ object FileUtils {
4444
if (prefs.getBoolean(KEY_LEGACY_FILES_CLEARED, false)) return
4545

4646
ioExecutor.execute {
47-
runCatching {
48-
context.filesDir.listFiles()?.forEach { it.deleteRecursively() }
49-
}.onFailure { Log.w(TAG, "clearApplicationData() failed", it) }
47+
// deleteRecursively() returns false on a failed/partial delete; only
48+
// treat the cleanup as done when every entry was actually removed,
49+
// otherwise it is retried on the next launch.
50+
val cleared = runCatching {
51+
context.filesDir.listFiles()
52+
?.map { it.deleteRecursively() }
53+
?.all { it }
54+
?: true
55+
}.onFailure {
56+
Log.w(TAG, "clearApplicationData() failed", it)
57+
}.getOrDefault(false)
5058

51-
prefs.edit().putBoolean(KEY_LEGACY_FILES_CLEARED, true).apply()
59+
if (cleared) {
60+
prefs.edit().putBoolean(KEY_LEGACY_FILES_CLEARED, true).apply()
61+
}
5262
}
5363
}
5464
}

app/src/main/java/phone/vishnu/dialogmusicplayer/MainActivity.kt

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import android.Manifest
2323
import android.annotation.SuppressLint
2424
import android.content.BroadcastReceiver
2525
import android.content.ComponentName
26+
import android.content.ContentResolver
2627
import android.content.Context
2728
import android.content.Intent
2829
import android.content.IntentFilter
@@ -90,7 +91,7 @@ class MainActivity : AppCompatActivity() {
9091
override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
9192
super.onPlaybackStateChanged(state)
9293
state ?: return
93-
viewModel.onPlaybackStateChanged(state.state, state.position)
94+
viewModel.onPlaybackStateChanged(state.state, state.position, state.playbackSpeed)
9495
}
9596

9697
override fun onRepeatModeChanged(repeatMode: Int) {
@@ -126,6 +127,7 @@ class MainActivity : AppCompatActivity() {
126127
onRewind = { seekBy(-SEEK_STEP_MS) },
127128
onForward = { seekBy(SEEK_STEP_MS) },
128129
onRepeat = ::cycleRepeatMode,
130+
onCycleSpeed = ::cyclePlaybackSpeed,
129131
onBackgroundTap = { moveTaskToBack(false) },
130132
)
131133
}
@@ -134,8 +136,10 @@ class MainActivity : AppCompatActivity() {
134136
// Playback only needs the audio-read permission. POST_NOTIFICATIONS is
135137
// optional (a nicer notification) and must NOT gate playback — so we
136138
// start as soon as the essential permission is available and request
137-
// anything still missing separately.
138-
if (hasEssentialPermission()) initTasks(intent)
139+
// anything still missing separately. A content:// URI delivered by
140+
// ACTION_VIEW/SEND carries its own temporary read grant, so the file
141+
// the user explicitly opened plays even without library-wide access.
142+
if (hasEssentialPermission() || canPlayWithoutPermission(intent)) initTasks(intent)
139143
requestMissingPermissions()
140144
}
141145

@@ -227,6 +231,10 @@ class MainActivity : AppCompatActivity() {
227231
return
228232
}
229233

234+
// A content:// file may already be playing on its own temporary grant —
235+
// don't nag about the denied library-wide permission in that case.
236+
if (mediaBrowser != null) return
237+
230238
val essential = essentialPermission() ?: return
231239
if (ActivityCompat.shouldShowRequestPermissionRationale(this, essential)) {
232240
Toast.makeText(
@@ -276,6 +284,16 @@ class MainActivity : AppCompatActivity() {
276284
viewModel.onRepeatModeChanged(next)
277285
}
278286

287+
/** Steps to the next speed in [PLAYBACK_SPEEDS], wrapping around. */
288+
private fun cyclePlaybackSpeed() {
289+
val current = mediaController?.playbackState?.playbackSpeed ?: 1f
290+
val baseIndex = PLAYBACK_SPEEDS.indexOfFirst { it == current }
291+
.let { if (it == -1) PLAYBACK_SPEEDS.indexOf(1f) else it }
292+
transportControls?.setPlaybackSpeed(
293+
PLAYBACK_SPEEDS[(baseIndex + 1) % PLAYBACK_SPEEDS.size],
294+
)
295+
}
296+
279297
// ---- Media browser plumbing ------------------------------------------------
280298

281299
private fun initTasks(intent: Intent) {
@@ -286,12 +304,7 @@ class MainActivity : AppCompatActivity() {
286304
return
287305
}
288306

289-
val uri: Uri? = if (Intent.ACTION_VIEW == intent.action) {
290-
intent.data
291-
} else {
292-
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
293-
}
294-
307+
val uri = resolveUri(intent)
295308
if (uri == null) {
296309
showFatalError(intent.action)
297310
return
@@ -304,6 +317,27 @@ class MainActivity : AppCompatActivity() {
304317
}
305318
}
306319

320+
/** The audio URI carried by an ACTION_VIEW / ACTION_SEND intent, if any. */
321+
private fun resolveUri(intent: Intent): Uri? {
322+
return when (intent.action) {
323+
Intent.ACTION_VIEW -> intent.data
324+
Intent.ACTION_SEND ->
325+
IntentCompat.getParcelableExtra(intent, Intent.EXTRA_STREAM, Uri::class.java)
326+
327+
else -> null
328+
}
329+
}
330+
331+
/**
332+
* A `content://` URI from ACTION_VIEW/SEND comes with a temporary read grant
333+
* that is enough to open that one file, so it can play even when the user
334+
* has denied the library-wide media permission. `file://` URIs have no such
335+
* grant and genuinely need [essentialPermission].
336+
*/
337+
private fun canPlayWithoutPermission(intent: Intent): Boolean {
338+
return resolveUri(intent)?.scheme == ContentResolver.SCHEME_CONTENT
339+
}
340+
307341
private fun showFatalError(action: String?) {
308342
Toast.makeText(this, "Oops! Something went wrong\n\n$action", Toast.LENGTH_LONG).show()
309343
finish()
@@ -391,5 +425,8 @@ class MainActivity : AppCompatActivity() {
391425

392426
private const val PERMISSION_REQUEST_CODE = 0
393427
private const val SEEK_STEP_MS = 10_000L
428+
429+
/** Speed values the speed control cycles through, in order. */
430+
private val PLAYBACK_SPEEDS = listOf(0.5f, 0.75f, 1f, 1.25f, 1.5f, 2f)
394431
}
395432
}

app/src/main/java/phone/vishnu/dialogmusicplayer/MainViewModel.kt

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
5252
}
5353
}
5454

55-
fun onPlaybackStateChanged(playbackState: Int, positionMs: Long) {
56-
_uiState.update { it.copy(playbackState = playbackState, positionMs = positionMs) }
55+
fun onPlaybackStateChanged(playbackState: Int, positionMs: Long, playbackSpeed: Float) {
56+
_uiState.update {
57+
it.copy(
58+
playbackState = playbackState,
59+
positionMs = positionMs,
60+
// The session reports speed 0 before playback starts; keep the
61+
// last real speed so the speed control never shows a "0x" label.
62+
playbackSpeed = if (playbackSpeed > 0f) playbackSpeed else it.playbackSpeed,
63+
)
64+
}
5765
}
5866

5967
fun onRepeatModeChanged(repeatMode: Int) {

app/src/main/java/phone/vishnu/dialogmusicplayer/MediaPlaybackService.kt

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ class MediaPlaybackService :
6161
private lateinit var mediaPlayer: MediaPlayer
6262
private var audio: Audio? = null
6363

64-
private var isPlayingOnceInProgress = false
6564
private var wasPlayingWhenLosingAudioFocus = false
6665
private var isPlayerReleased = false
6766

@@ -136,18 +135,12 @@ class MediaPlaybackService :
136135
setPlaybackState(PlaybackStateCompat.STATE_STOPPED, KEEP_SPEED)
137136
startForeground(NOTIFICATION_ID, getNotification())
138137

138+
// Single-track player: REPEAT_ONE and REPEAT_ALL both mean "replay this
139+
// track". Replay on *every* completion — no every-other-time guard.
139140
when (mediaSession.controller.repeatMode) {
140-
PlaybackStateCompat.REPEAT_MODE_ONE -> {
141-
if (!isPlayingOnceInProgress) {
142-
isPlayingOnceInProgress = true
143-
if (mediaPlayer.currentPosition == mediaPlayer.duration) mediaPlayer.seekTo(0)
144-
mediaSession.controller.transportControls.play()
145-
} else {
146-
isPlayingOnceInProgress = false
147-
}
148-
}
149-
150-
PlaybackStateCompat.REPEAT_MODE_ALL -> {
141+
PlaybackStateCompat.REPEAT_MODE_ONE,
142+
PlaybackStateCompat.REPEAT_MODE_ALL,
143+
-> {
151144
if (mediaPlayer.currentPosition == mediaPlayer.duration) mediaPlayer.seekTo(0)
152145
mediaSession.controller.transportControls.play()
153146
}

0 commit comments

Comments
 (0)