Skip to content

Commit 64a5643

Browse files
committed
silly bug fix
scrolling now works as expected on playlists added couple new themes
1 parent 7cebfc5 commit 64a5643

9 files changed

Lines changed: 531 additions & 18 deletions

File tree

src/app/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ dependencies {
105105
implementation("androidx.media3:media3-ui:$media3Version")
106106
implementation("androidx.media3:media3-session:$media3Version")
107107
implementation("androidx.media3:media3-common:$media3Version")
108+
implementation("androidx.media3:media3-datasource:$media3Version")
109+
implementation("androidx.media3:media3-database:$media3Version")
108110

109111
// Media support for Android Auto
110112
implementation("androidx.media:media:1.7.0")

src/app/src/main/java/com/melodee/autoplayer/presentation/ui/playlist/PlaylistScreen.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,7 @@ fun PlaylistScreen(
179179
// Song position indicator (above the list)
180180
if (songs.isNotEmpty() && totalSongs > 0) {
181181
Text(
182-
text = "Displaying $currentSongsStart to $currentSongsEnd of $totalSongs results",
182+
text = "Displaying $currentSongsStart to $currentSongsEnd of $totalSongs songs",
183183
style = MaterialTheme.typography.bodyMedium,
184184
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
185185
)

src/app/src/main/java/com/melodee/autoplayer/presentation/ui/playlist/PlaylistViewModel.kt

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,22 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
110110
private var currentPlaylistId: String? = null
111111
private var isRefreshing = false
112112

113+
companion object {
114+
private const val MAX_SONGS_IN_MEMORY = 500
115+
private const val KEEP_SONGS_ON_CLEANUP = 300
116+
}
117+
118+
private fun applyVirtualScrolling(current: List<Song>, incoming: List<Song>, isFirstPage: Boolean): List<Song> {
119+
return if (isFirstPage) {
120+
incoming
121+
} else {
122+
val combined = current + incoming
123+
if (combined.size > MAX_SONGS_IN_MEMORY) {
124+
combined.takeLast(KEEP_SONGS_ON_CLEANUP) + incoming
125+
} else combined
126+
}
127+
}
128+
113129
init {
114130
// Start progress updates when playing
115131
viewModelScope.launch {
@@ -219,12 +235,12 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
219235
val playlistName: String
220236
get() = _playlist.value?.name ?: ""
221237

222-
fun loadPlaylist(playlistId: String) {
238+
fun loadPlaylist(playlistId: String, allowPagination: Boolean = false) {
223239
// Check if this is a different playlist than what's currently playing
224240
val isNewPlaylist = playlistId != currentPlaylistId
225241

226242
// If it's the same playlist and we already have songs loaded, don't reload
227-
if (playlistId == currentPlaylistId && _songs.value.isNotEmpty() && !isNewPlaylist) {
243+
if (playlistId == currentPlaylistId && _songs.value.isNotEmpty() && !isNewPlaylist && !allowPagination) {
228244
Log.d("PlaylistViewModel", "Playlist $playlistId already loaded, skipping reload")
229245
return
230246
}
@@ -267,19 +283,21 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
267283
// Handle error
268284
}
269285
?.collect { response ->
270-
val allSongs = if (currentPage == 1) {
271-
response.data
272-
} else {
273-
_songs.value + response.data
274-
}
286+
val allSongs = applyVirtualScrolling(_songs.value, response.data, currentPage == 1)
275287
_songs.value = allSongs
276288
hasMoreSongs = response.meta.hasNext
277289
currentPage = response.meta.currentPage + 1
278290

279291
// Update pagination display values
280292
_totalSongs.value = response.meta.totalCount
281-
_currentSongsStart.value = if (allSongs.isNotEmpty()) 1 else 0
282-
_currentSongsEnd.value = allSongs.size
293+
if (response.meta.currentPage == 1) {
294+
_currentSongsStart.value = if (response.data.isNotEmpty()) 1 else 0
295+
_currentSongsEnd.value = response.data.size
296+
} else {
297+
val prevEnd = _currentSongsEnd.value
298+
_currentSongsStart.value = prevEnd + 1
299+
_currentSongsEnd.value = prevEnd + response.data.size
300+
}
283301

284302
// If this was a refresh and we have songs, trigger scroll to top
285303
if (isRefreshing && response.data.isNotEmpty() && currentPage == 2) {
@@ -325,6 +343,8 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
325343
hasMoreSongs = true
326344
_songs.value = emptyList()
327345
isRefreshing = true
346+
_currentSongsStart.value = 0
347+
_currentSongsEnd.value = 0
328348
currentPlaylistId?.let { loadPlaylist(it) }
329349
}
330350

@@ -401,7 +421,7 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
401421
fun loadMoreSongs() {
402422
if (!hasMoreSongs || _isLoading.value) return
403423
isRefreshing = false // Ensure we don't trigger scroll during pagination
404-
currentPlaylistId?.let { loadPlaylist(it) }
424+
currentPlaylistId?.let { loadPlaylist(it, allowPagination = true) }
405425
}
406426

407427
fun playSong(song: Song) {
@@ -504,4 +524,4 @@ class PlaylistViewModel(application: Application) : AndroidViewModel(application
504524
// Clear repository
505525
repository = null
506526
}
507-
}
527+
}

src/app/src/main/java/com/melodee/autoplayer/presentation/ui/settings/ThemeSettingsScreen.kt

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,30 @@ private fun getPaletteOptions(): List<PaletteOption> = listOf(
181181
secondaryColor = MelodeeColors.Blue60,
182182
tertiaryColor = MelodeeColors.Orange60
183183
),
184+
PaletteOption(
185+
palette = ThemePalette.PRIMARY_COLORS,
186+
name = "Primary Colors",
187+
description = "Bold RGB-inspired palette: blue, red, and green",
188+
primaryColor = AlternativePalettes.PrimaryColors.Primary50,
189+
secondaryColor = AlternativePalettes.PrimaryColors.Secondary50,
190+
tertiaryColor = AlternativePalettes.PrimaryColors.Tertiary50
191+
),
192+
PaletteOption(
193+
palette = ThemePalette.RETRO_80S,
194+
name = "Retro 80s",
195+
description = "Neon magenta, electric cyan, and neon yellow",
196+
primaryColor = AlternativePalettes.Retro80s.Primary60,
197+
secondaryColor = AlternativePalettes.Retro80s.Secondary60,
198+
tertiaryColor = AlternativePalettes.Retro80s.Tertiary60
199+
),
200+
PaletteOption(
201+
palette = ThemePalette.WINAMP,
202+
name = "WinAmp Classic",
203+
description = "Gold accents on a dark slate with electric blue",
204+
primaryColor = AlternativePalettes.WinAmpClassic.Gold,
205+
secondaryColor = AlternativePalettes.WinAmpClassic.Blue,
206+
tertiaryColor = AlternativePalettes.WinAmpClassic.EQGreen
207+
),
184208
PaletteOption(
185209
palette = ThemePalette.MUSIC_GREEN,
186210
name = "Music Green",
@@ -196,6 +220,22 @@ private fun getPaletteOptions(): List<PaletteOption> = listOf(
196220
primaryColor = Color(0xFF6750A4),
197221
secondaryColor = Color(0xFF625B71),
198222
tertiaryColor = Color(0xFF7D5260)
223+
),
224+
PaletteOption(
225+
palette = ThemePalette.BUBBLEGUM,
226+
name = "Bubblegum",
227+
description = "A pink candy-coated nightmare — maximum bubblegum energy",
228+
primaryColor = AlternativePalettes.Bubblegum.Pink60,
229+
secondaryColor = AlternativePalettes.Bubblegum.CandyBlue50,
230+
tertiaryColor = AlternativePalettes.Bubblegum.Lavender50
231+
),
232+
PaletteOption(
233+
palette = ThemePalette.JUST_GREY,
234+
name = "Just Grey",
235+
description = "Monochrome minimalism in layered greys",
236+
primaryColor = AlternativePalettes.JustGrey.Grey50,
237+
secondaryColor = AlternativePalettes.JustGrey.Grey70,
238+
tertiaryColor = AlternativePalettes.JustGrey.Grey40
199239
)
200240
)
201241

@@ -300,4 +340,4 @@ private fun ColorCircle(
300340
shape = CircleShape
301341
)
302342
)
303-
}
343+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package com.melodee.autoplayer.service
2+
3+
import android.content.Context
4+
import android.net.Uri
5+
import android.util.Log
6+
import androidx.media3.datasource.DataSource
7+
import androidx.media3.datasource.DefaultHttpDataSource
8+
import androidx.media3.datasource.cache.Cache
9+
import androidx.media3.datasource.cache.CacheDataSource
10+
import androidx.media3.datasource.cache.CacheWriter
11+
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
12+
import androidx.media3.datasource.cache.SimpleCache
13+
import androidx.media3.datasource.DataSpec
14+
import androidx.media3.database.DatabaseProvider
15+
import androidx.media3.database.StandaloneDatabaseProvider
16+
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
17+
import androidx.media3.exoplayer.source.MediaSourceFactory
18+
import kotlinx.coroutines.CancellationException
19+
20+
import java.io.File
21+
22+
object MediaCache {
23+
private const val TAG = "MediaCache"
24+
private const val CACHE_FOLDER = "media_cache"
25+
private const val MAX_CACHE_SIZE_BYTES = 200L * 1024L * 1024L // 200 MB
26+
27+
@Volatile private var cache: Cache? = null
28+
@Volatile private var databaseProvider: DatabaseProvider? = null
29+
30+
private fun ensureCache(context: Context): Cache {
31+
val existing = cache
32+
if (existing != null) return existing
33+
34+
synchronized(this) {
35+
val again = cache
36+
if (again != null) return again
37+
val cacheDir = File(context.cacheDir, CACHE_FOLDER)
38+
if (!cacheDir.exists()) cacheDir.mkdirs()
39+
val dbProvider = StandaloneDatabaseProvider(context)
40+
databaseProvider = dbProvider
41+
val evictor = LeastRecentlyUsedCacheEvictor(MAX_CACHE_SIZE_BYTES)
42+
val simpleCache = SimpleCache(cacheDir, evictor, dbProvider)
43+
cache = simpleCache
44+
Log.d(TAG, "Initialized SimpleCache at ${cacheDir.absolutePath}")
45+
return simpleCache
46+
}
47+
}
48+
49+
fun mediaSourceFactory(context: Context): MediaSourceFactory {
50+
return DefaultMediaSourceFactory(dataSourceFactory(context))
51+
}
52+
53+
fun dataSourceFactory(context: Context): DataSource.Factory {
54+
val upstream = DefaultHttpDataSource.Factory()
55+
.setConnectTimeoutMs(30_000)
56+
.setReadTimeoutMs(30_000)
57+
return CacheDataSource.Factory()
58+
.setCache(ensureCache(context))
59+
.setUpstreamDataSourceFactory(upstream)
60+
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
61+
}
62+
63+
fun clearCache(context: Context) {
64+
try {
65+
cache?.release()
66+
} catch (e: Exception) {
67+
Log.w(TAG, "Error releasing cache", e)
68+
} finally {
69+
cache = null
70+
databaseProvider = null
71+
}
72+
try {
73+
val dir = File(context.cacheDir, CACHE_FOLDER)
74+
if (dir.exists()) {
75+
dir.deleteRecursively()
76+
Log.d(TAG, "Cleared media cache at ${dir.absolutePath}")
77+
}
78+
} catch (e: Exception) {
79+
Log.w(TAG, "Error deleting cache directory", e)
80+
}
81+
}
82+
83+
fun prefetchUrl(context: Context, url: String) {
84+
try {
85+
val dataSpec = DataSpec(Uri.parse(url))
86+
val upstreamDs = DefaultHttpDataSource.Factory()
87+
.setConnectTimeoutMs(30_000)
88+
.setReadTimeoutMs(30_000)
89+
.createDataSource()
90+
val cacheDataSource = CacheDataSource(ensureCache(context), upstreamDs)
91+
val writer = CacheWriter(cacheDataSource, dataSpec, ByteArray(8 * 1024), /* progressListener = */ null)
92+
writer.cache()
93+
Log.d(TAG, "Prefetched: $url")
94+
} catch (ce: CancellationException) {
95+
Log.d(TAG, "Prefetch cancelled for $url")
96+
} catch (e: Exception) {
97+
Log.w(TAG, "Prefetch failed for $url: ${e.message}")
98+
}
99+
}
100+
}

src/app/src/main/java/com/melodee/autoplayer/service/MusicPlaybackManager.kt

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,10 @@ class MusicPlaybackManager(private val context: Context) {
6969

7070
fun initializePlayer(): ExoPlayer {
7171
if (exoPlayer == null) {
72-
exoPlayer = ExoPlayer.Builder(context).build().also { player ->
72+
val mediaSourceFactory = MediaCache.mediaSourceFactory(context)
73+
exoPlayer = ExoPlayer.Builder(context)
74+
.setMediaSourceFactory(mediaSourceFactory)
75+
.build().also { player ->
7376
player.addListener(object : Player.Listener {
7477
override fun onIsPlayingChanged(isPlaying: Boolean) {
7578
_isPlaying.value = isPlaying
@@ -356,4 +359,4 @@ class MusicPlaybackManager(private val context: Context) {
356359
exoPlayer = null
357360
Logger.d("MusicPlaybackManager", "Player released")
358361
}
359-
}
362+
}

src/app/src/main/java/com/melodee/autoplayer/service/MusicService.kt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ class MusicService : MediaBrowserServiceCompat() {
4747
private lateinit var authenticationManager: AuthenticationManager
4848
private val serviceScope = CoroutineScope(Dispatchers.Main + SupervisorJob())
4949
private var positionUpdateJob: Job? = null
50+
private var prefetchJob: Job? = null
5051

5152
// Audio focus management
5253
private lateinit var audioManager: AudioManager
@@ -835,6 +836,9 @@ class MusicService : MediaBrowserServiceCompat() {
835836
// Release playback manager (which handles player cleanup)
836837
playbackManager.release()
837838
player = null
839+
840+
// Clear on-disk media cache on service shutdown
841+
MediaCache.clearCache(applicationContext)
838842
super.onDestroy()
839843
}
840844

@@ -1040,6 +1044,9 @@ class MusicService : MediaBrowserServiceCompat() {
10401044
Log.d("MusicService", "Started scrobble tracking for: ${song.title}, duration: $duration")
10411045
}
10421046
}
1047+
1048+
// Prefetch upcoming songs to tolerate spotty networks
1049+
prefetchUpcoming()
10431050
}
10441051
Player.STATE_BUFFERING -> {
10451052
Log.d("MusicService", "Player buffering")
@@ -1071,6 +1078,8 @@ class MusicService : MediaBrowserServiceCompat() {
10711078
// Start or stop position updates based on playback state
10721079
if (isPlaying) {
10731080
startPositionUpdates()
1081+
// Prefetch again in case queue position changed
1082+
prefetchUpcoming()
10741083
} else {
10751084
stopPositionUpdates()
10761085
}
@@ -1298,6 +1307,26 @@ class MusicService : MediaBrowserServiceCompat() {
12981307
Log.d("MusicService", "Stopped foreground service and removed notification")
12991308
}
13001309

1310+
private fun prefetchUpcoming(count: Int = 2) {
1311+
prefetchJob?.cancel()
1312+
prefetchJob = serviceScope.launch(Dispatchers.IO) {
1313+
try {
1314+
val queue = queueManager().currentQueue.value
1315+
val index = queueManager().currentIndex.value
1316+
if (queue.isEmpty() || index < 0) return@launch
1317+
for (i in 1..count) {
1318+
val nextIdx = index + i
1319+
if (nextIdx in queue.indices) {
1320+
val url = queue[nextIdx].streamUrl
1321+
MediaCache.prefetchUrl(this@MusicService, url)
1322+
}
1323+
}
1324+
} catch (e: Exception) {
1325+
Log.w("MusicService", "Prefetch job error: ${e.message}")
1326+
}
1327+
}
1328+
}
1329+
13011330
private fun skipToNext() {
13021331
Log.d("MusicService", "Skipping to next song, context: ${getCurrentPlaybackContext()}")
13031332

@@ -2443,4 +2472,4 @@ class MusicService : MediaBrowserServiceCompat() {
24432472
// Direct access for internal code
24442473
private fun queueManager() = _queueManager
24452474
private fun playlistManager() = _playlistManager
2446-
}
2475+
}

0 commit comments

Comments
 (0)