Skip to content

Commit dc2f183

Browse files
committed
Improve seek reliability for local M4A/MP4 files and update MediaItem construction
- **DualPlayerEngine**: Configure `Mp4Extractor` with `FLAG_WORKAROUND_IGNORE_EDIT_LISTS` to prevent seek drift and snap-back issues caused by broken edit lists in vendor-produced M4A files. - **MediaItemBuilder**: - Update `playbackUri` to prefer direct file URIs over `content://` URIs for specific local audio formats (M4A, MP4, AAC, 3GP, etc.) to improve playback stability. - Implement `shouldPreferDirectLocalFileUri` to identify when a direct file path should be used based on MIME type and file extension. - Centralize `MediaItem` URI generation by accepting `Song` objects directly. - **PlayerViewModel**: Update `MediaItem` creation to include MIME types and use the updated `MediaItemBuilder.playbackUri` signature. - **Tests**: - Add `MediaItemBuilderTest` to verify direct file URI selection logic. - Update `PlayerViewModelTest` to match the new `playbackUri` signature.
1 parent 4891e65 commit dc2f183

5 files changed

Lines changed: 139 additions & 15 deletions

File tree

app/src/main/java/com/theveloper/pixelplay/data/service/player/DualPlayerEngine.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import androidx.media3.exoplayer.DefaultRenderersFactory
1414
import androidx.media3.exoplayer.ExoPlayer
1515
import androidx.media3.exoplayer.audio.AudioSink
1616
import androidx.media3.exoplayer.audio.DefaultAudioSink
17+
import androidx.media3.extractor.DefaultExtractorsFactory
18+
import androidx.media3.extractor.mp4.Mp4Extractor
1719
//import androidx.media3.exoplayer.ffmpeg.FfmpegAudioRenderer
1820
import com.theveloper.pixelplay.data.model.TransitionSettings
1921
import com.theveloper.pixelplay.utils.envelope
@@ -340,6 +342,10 @@ class DualPlayerEngine @Inject constructor(
340342

341343
val dataSourceFactory = DefaultDataSource.Factory(context)
342344
val resolvingFactory = ResolvingDataSource.Factory(dataSourceFactory, resolver)
345+
val extractorsFactory = DefaultExtractorsFactory()
346+
// Some vendor-produced M4A files expose broken edit lists that make seek
347+
// drift or snap back. Ignore them so MP4-family local playback stays seekable.
348+
.setMp4ExtractorFlags(Mp4Extractor.FLAG_WORKAROUND_IGNORE_EDIT_LISTS)
343349

344350
// Tune LoadControl to prevent "loop of death" (underrun -> start -> underrun)
345351
// Increase bufferForPlaybackMs to wait for more data before starting/resuming.
@@ -353,7 +359,7 @@ class DualPlayerEngine @Inject constructor(
353359
.build()
354360

355361
return ExoPlayer.Builder(context, renderersFactory)
356-
.setMediaSourceFactory(DefaultMediaSourceFactory(resolvingFactory))
362+
.setMediaSourceFactory(DefaultMediaSourceFactory(resolvingFactory, extractorsFactory))
357363
.setLoadControl(loadControl)
358364
.build().apply {
359365
setAudioAttributes(audioAttributes, handleAudioFocus)

app/src/main/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModel.kt

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2825,7 +2825,8 @@ class PlayerViewModel @Inject constructor(
28252825

28262826
mediaItems += MediaItem.Builder()
28272827
.setMediaId(song.id)
2828-
.setUri(MediaItemBuilder.playbackUri(song.contentUriString))
2828+
.setUri(MediaItemBuilder.playbackUri(song))
2829+
.setMimeType(song.mimeType)
28292830
.setMediaMetadata(metadataBuilder.build())
28302831
.build()
28312832
}
@@ -2898,7 +2899,7 @@ class PlayerViewModel @Inject constructor(
28982899

28992900
// Pre-resolve the starting song's cloud URI before ExoPlayer touches it.
29002901
// This populates the resolvedUriCache so resolveDataSpec finds it instantly.
2901-
val startingUri = MediaItemBuilder.playbackUri(effectiveStartSong.contentUriString)
2902+
val startingUri = MediaItemBuilder.playbackUri(effectiveStartSong)
29022903
if (startingUri.scheme == "telegram" || startingUri.scheme == "netease" || startingUri.scheme == "qqmusic") {
29032904
if (startingUri.scheme == "telegram") {
29042905
ensureTelegramPlaybackObserversStarted()
@@ -2965,7 +2966,8 @@ class PlayerViewModel @Inject constructor(
29652966

29662967
val mediaItem = MediaItem.Builder()
29672968
.setMediaId(song.id)
2968-
.setUri(MediaItemBuilder.playbackUri(song.contentUriString))
2969+
.setUri(MediaItemBuilder.playbackUri(song))
2970+
.setMimeType(song.mimeType)
29692971
.setMediaMetadata(MediaItemBuilder.build(song).mediaMetadata)
29702972
.build()
29712973
if (controller.currentMediaItem?.mediaId == song.id) {
@@ -3037,7 +3039,8 @@ class PlayerViewModel @Inject constructor(
30373039
mediaController?.let { controller ->
30383040
val mediaItem = MediaItem.Builder()
30393041
.setMediaId(song.id)
3040-
.setUri(MediaItemBuilder.playbackUri(song.contentUriString))
3042+
.setUri(MediaItemBuilder.playbackUri(song))
3043+
.setMimeType(song.mimeType)
30413044
.setMediaMetadata(MediaMetadata.Builder()
30423045
.setTitle(song.title)
30433046
.setArtist(song.displayArtist)
@@ -3053,7 +3056,8 @@ class PlayerViewModel @Inject constructor(
30533056
mediaController?.let { controller ->
30543057
val mediaItem = MediaItem.Builder()
30553058
.setMediaId(song.id)
3056-
.setUri(MediaItemBuilder.playbackUri(song.contentUriString))
3059+
.setUri(MediaItemBuilder.playbackUri(song))
3060+
.setMimeType(song.mimeType)
30573061
.setMediaMetadata(
30583062
MediaMetadata.Builder()
30593063
.setTitle(song.title)

app/src/main/java/com/theveloper/pixelplay/utils/MediaItemBuilder.kt

Lines changed: 82 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,28 @@ import java.io.File
1313
object MediaItemBuilder {
1414
private const val EXTERNAL_MEDIA_ID_PREFIX = "external:"
1515
private const val EXTERNAL_EXTRA_PREFIX = "com.theveloper.pixelplay.external."
16+
private val DIRECT_FILE_URI_MIME_TYPES = setOf(
17+
"audio/mp4",
18+
"audio/m4a",
19+
"audio/x-m4a",
20+
"audio/mp4a-latm",
21+
"audio/aac",
22+
"audio/x-aac",
23+
"audio/3gp",
24+
"audio/3gpp",
25+
"audio/3gpp2",
26+
)
27+
private val DIRECT_FILE_URI_EXTENSIONS = setOf(
28+
"m4a",
29+
"m4b",
30+
"m4p",
31+
"mp4",
32+
"aac",
33+
"3ga",
34+
"3gp",
35+
"3gpp",
36+
"alac",
37+
)
1638
private val SUPPORTED_ARTWORK_SCHEMES = setOf(
1739
"content",
1840
"file",
@@ -37,15 +59,17 @@ object MediaItemBuilder {
3759
fun build(song: Song): MediaItem {
3860
return MediaItem.Builder()
3961
.setMediaId(song.id)
40-
.setUri(playbackUri(song.contentUriString))
62+
.setUri(playbackUri(song))
63+
.setMimeType(song.mimeType)
4164
.setMediaMetadata(buildMediaMetadataForSong(song))
4265
.build()
4366
}
4467

4568
fun buildForExternalController(context: Context, song: Song): MediaItem {
4669
return MediaItem.Builder()
4770
.setMediaId(song.id)
48-
.setUri(playbackUri(song.contentUriString))
71+
.setUri(playbackUri(song))
72+
.setMimeType(song.mimeType)
4973
.setMediaMetadata(
5074
buildMediaMetadataForSong(
5175
song = song,
@@ -55,13 +79,20 @@ object MediaItemBuilder {
5579
.build()
5680
}
5781

58-
fun playbackUri(contentUriString: String): Uri {
82+
fun playbackUri(song: Song): Uri = playbackUri(
83+
contentUriString = song.contentUriString,
84+
filePath = song.path,
85+
mimeType = song.mimeType
86+
)
87+
88+
fun playbackUri(
89+
contentUriString: String,
90+
filePath: String? = null,
91+
mimeType: String? = null
92+
): Uri {
93+
directLocalFileUri(contentUriString, filePath, mimeType)?.let { return it }
5994
val uri = runCatching { Uri.parse(contentUriString) }.getOrNull()
60-
?: return if (contentUriString.startsWith("/")) {
61-
Uri.fromFile(File(contentUriString))
62-
} else {
63-
Uri.fromFile(File(contentUriString))
64-
}
95+
?: return Uri.fromFile(File(contentUriString))
6596
// Telegram downloaded files can be stored as absolute paths (without file://).
6697
// Normalize them so ExoPlayer always gets a canonical local-file URI.
6798
return if (uri.scheme.isNullOrBlank() && contentUriString.startsWith("/")) {
@@ -71,6 +102,49 @@ object MediaItemBuilder {
71102
}
72103
}
73104

105+
private fun directLocalFileUri(
106+
contentUriString: String,
107+
filePath: String?,
108+
mimeType: String?
109+
): Uri? {
110+
val normalizedPath = filePath?.takeIf { it.startsWith("/") } ?: return null
111+
if (!shouldPreferDirectLocalFileUri(contentUriString, normalizedPath, mimeType)) {
112+
return null
113+
}
114+
115+
return Uri.fromFile(File(normalizedPath))
116+
}
117+
118+
internal fun shouldPreferDirectLocalFileUri(
119+
contentUriString: String,
120+
filePath: String?,
121+
mimeType: String?
122+
): Boolean {
123+
val normalizedPath = filePath?.takeIf { it.startsWith("/") } ?: return false
124+
if (!LocalArtworkUri.isLikelyLocalMedia(contentUriString)) {
125+
return false
126+
}
127+
128+
if (!contentUriString.startsWith("content://")) {
129+
return false
130+
}
131+
132+
return shouldPreferDirectFileUri(normalizedPath, mimeType)
133+
}
134+
135+
private fun shouldPreferDirectFileUri(
136+
filePath: String,
137+
mimeType: String?
138+
): Boolean {
139+
val normalizedMimeType = mimeType?.lowercase()
140+
if (normalizedMimeType != null && normalizedMimeType in DIRECT_FILE_URI_MIME_TYPES) {
141+
return true
142+
}
143+
144+
val extension = filePath.substringAfterLast('.', "").lowercase()
145+
return extension in DIRECT_FILE_URI_EXTENSIONS
146+
}
147+
74148
/**
75149
* Artwork URIs are surfaced to external controllers (Android Auto, widgets, etc.).
76150
* Keep only schemes that these surfaces can usually resolve, and normalize raw paths.

app/src/test/java/com/theveloper/pixelplay/presentation/viewmodel/PlayerViewModelTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,7 @@ class PlayerViewModelTest {
436436
.build()
437437
val mockedPlaybackUri = mockk<android.net.Uri>(relaxed = true)
438438
every { mockedPlaybackUri.scheme } returns "file"
439-
every { MediaItemBuilder.playbackUri(any()) } returns mockedPlaybackUri
439+
every { MediaItemBuilder.playbackUri(any<Song>()) } returns mockedPlaybackUri
440440
}
441441

442442
@Test
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package com.theveloper.pixelplay.utils
2+
3+
import com.google.common.truth.Truth.assertThat
4+
import org.junit.Test
5+
6+
class MediaItemBuilderTest {
7+
8+
@Test
9+
fun shouldPreferDirectLocalFileUri_prefersDirectFileUriForLocalM4aMediaStoreItems() {
10+
val shouldPreferFile = MediaItemBuilder.shouldPreferDirectLocalFileUri(
11+
contentUriString = "content://media/external/audio/media/42",
12+
filePath = "/storage/emulated/0/Music/test-track.m4a",
13+
mimeType = "audio/mp4"
14+
)
15+
16+
assertThat(shouldPreferFile).isTrue()
17+
}
18+
19+
@Test
20+
fun shouldPreferDirectLocalFileUri_keepsContentUriForFormatsThatAlreadySeekCorrectly() {
21+
val shouldPreferFile = MediaItemBuilder.shouldPreferDirectLocalFileUri(
22+
contentUriString = "content://media/external/audio/media/24",
23+
filePath = "/storage/emulated/0/Music/test-track.flac",
24+
mimeType = "audio/flac"
25+
)
26+
27+
assertThat(shouldPreferFile).isFalse()
28+
}
29+
30+
@Test
31+
fun shouldPreferDirectLocalFileUri_keepsCloudUrisUntouched() {
32+
val shouldPreferFile = MediaItemBuilder.shouldPreferDirectLocalFileUri(
33+
contentUriString = "telegram://123/456",
34+
filePath = "/storage/emulated/0/Download/cached-track.m4a",
35+
mimeType = "audio/mp4"
36+
)
37+
38+
assertThat(shouldPreferFile).isFalse()
39+
}
40+
}

0 commit comments

Comments
 (0)