Skip to content

Commit c595c49

Browse files
authored
Merge pull request #90 from raulshma/dev/v0.9.5
Enhance playback sync, player UX, and database reliability
2 parents 20658b3 + 78dc315 commit c595c49

99 files changed

Lines changed: 5276 additions & 671 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/src/main/java/com/raulshma/jellyplay/JellyPlayApplication.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
1111
import coil3.request.crossfade
1212
import coil3.size.Size
1313
import com.raulshma.jellyplay.core.datastore.di.ApplicationScope
14+
import com.raulshma.jellyplay.core.model.ImageCache
1415
import com.raulshma.jellyplay.core.notification.scheduler.NotificationScheduler
1516
import androidx.hilt.work.HiltWorkerFactory
1617
import androidx.work.Configuration
@@ -183,7 +184,7 @@ class JellyPlayApplication : Application(), SingletonImageLoader.Factory, Config
183184
}
184185
.diskCache {
185186
DiskCache.Builder()
186-
.directory(cacheDir.resolve("image_cache").absolutePath.toPath())
187+
.directory(cacheDir.resolve(ImageCache.DIR).absolutePath.toPath())
187188
.maxSizeBytes(cacheSize)
188189
.build()
189190
}

app/src/main/java/com/raulshma/jellyplay/MainViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ class MainViewModel @Inject constructor(
5555
private val mediaRepository: MediaRepository,
5656
private val playbackRepository: PlaybackRepository,
5757
private val offlineRepository: OfflineRepository,
58+
private val offlineModeManager: com.raulshma.jellyplay.core.data.offline.OfflineModeManager,
5859
val userMessageBus: UserMessageBus,
5960
private val serverHealthMonitor: com.raulshma.jellyplay.core.data.network.ServerHealthMonitor,
6061
val cacheManager: com.raulshma.jellyplay.core.data.cache.CacheManager,
@@ -64,6 +65,14 @@ class MainViewModel @Inject constructor(
6465

6566
val serverHealth = serverHealthMonitor.serverHealth
6667

68+
/**
69+
* App-wide offline mode. Collected by [com.raulshma.jellyplay.navigation.JellyPlayApp]
70+
* so the floating nav can hide server-bound destinations (Library, Live TV)
71+
* that have no offline fallback and would otherwise land on a dead-end
72+
* [com.raulshma.jellyplay.core.ui.components.ErrorScreen].
73+
*/
74+
val offlineMode = offlineModeManager.offlineMode
75+
6776
private val _isRestoring = stateFlow(true)
6877
val isRestoring = _isRestoring.flow
6978

app/src/main/java/com/raulshma/jellyplay/navigation/JellyPlayApp.kt

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -362,15 +362,25 @@ private fun MainContent(
362362
val currentBackStack = navigationState.backStacks[navigationState.topLevelRoute.value]
363363
val isFullScreenRoute = currentBackStack?.any { it is Route && it.isFullScreen } ?: false
364364

365+
// App-wide offline state. Library + Live TV have no offline fallback (they
366+
// always fetch live and degrade to a dead-end ErrorScreen), so while offline
367+
// they are hidden from the floating nav. Home is the offline hub, Search has
368+
// an offline-results path, Shortcuts are device-local, and MusicBrowse's
369+
// home surfaces the downloaded music library — all stay visible.
370+
val offlineMode by viewModel.offlineMode.collectAsStateWithLifecycle()
371+
val isOffline = offlineMode != com.raulshma.jellyplay.core.model.OfflineMode.ONLINE
372+
365373
// Memoize the route filter+reorder so it only re-runs when homeMode /
366-
// hiddenNavItems / navItemOrder actually change. MainContent recomposes
367-
// frequently (it reads audioTitle/artist/... for the mini player), so the
368-
// previous eager `when{}` allocated a fresh LinkedHashMap + intermediate
369-
// entry lists + KClass.simpleName lookups on every recomposition.
374+
// hiddenNavItems / navItemOrder / offline actually change. MainContent
375+
// recomposes frequently (it reads audioTitle/artist/... for the mini
376+
// player), so the previous eager `when{}` allocated a fresh LinkedHashMap +
377+
// intermediate entry lists + KClass.simpleName lookups on every
378+
// recomposition.
370379
val activeTopLevelRoutes: LinkedHashMap<Route, String> by remember(
371380
homeMode,
372381
preferences.hiddenNavItems,
373382
preferences.navItemOrder,
383+
isOffline,
374384
) {
375385
derivedStateOf {
376386
when (homeMode) {
@@ -379,8 +389,16 @@ private fun MainContent(
379389
}.let { routes ->
380390
val hidden = preferences.hiddenNavItems
381391
val order = preferences.navItemOrder
392+
// Server-bound destinations with no offline fallback. Hidden
393+
// by simpleName to stay consistent with the user hidden-item
394+
// filter below (also keyed on simpleName).
395+
val offlineHidden = if (isOffline) {
396+
setOf(Route.Library::class.simpleName, Route.LiveTv::class.simpleName)
397+
} else {
398+
emptySet()
399+
}
382400
val filtered = routes.filterKeys { route ->
383-
route::class.simpleName !in hidden
401+
route::class.simpleName !in hidden && route::class.simpleName !in offlineHidden
384402
}
385403
if (order.isEmpty()) {
386404
LinkedHashMap(filtered)

app/src/main/java/com/raulshma/jellyplay/startup/DownloadRecoveryInitializer.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@ package com.raulshma.jellyplay.startup
22

33
import android.content.Context
44
import android.util.Log
5+
import androidx.work.BackoffPolicy
56
import androidx.work.Data
67
import androidx.work.ExistingWorkPolicy
78
import androidx.work.OneTimeWorkRequestBuilder
89
import androidx.work.WorkManager
910
import com.raulshma.jellyplay.core.database.dao.DownloadDao
11+
import com.raulshma.jellyplay.core.data.repository.DownloadRepositoryImpl
1012
import com.raulshma.jellyplay.core.data.worker.DownloadWorker
1113
import com.raulshma.jellyplay.core.model.DownloadStatus
1214
import dagger.hilt.android.qualifiers.ApplicationContext
1315
import java.io.File
16+
import java.util.concurrent.TimeUnit
1417
import javax.inject.Inject
1518

1619
/**
@@ -41,6 +44,11 @@ class DownloadRecoveryInitializer @Inject constructor(
4144
val pending = downloadDao.getRecoveryRows(DownloadStatus.PENDING.name)
4245
for (download in pending) {
4346
val workRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
47+
.setBackoffCriteria(
48+
BackoffPolicy.EXPONENTIAL,
49+
DownloadRepositoryImpl.DOWNLOAD_BACKOFF_DELAY_MS,
50+
TimeUnit.MILLISECONDS,
51+
)
4452
.setInputData(
4553
Data.Builder()
4654
.putString(DownloadWorker.KEY_DOWNLOAD_ID, download.id)
@@ -63,6 +71,11 @@ class DownloadRecoveryInitializer @Inject constructor(
6371
for (download in stale) {
6472
downloadDao.updateProgress(download.id, download.downloadedBytes, DownloadStatus.PENDING.name)
6573
val workRequest = OneTimeWorkRequestBuilder<DownloadWorker>()
74+
.setBackoffCriteria(
75+
BackoffPolicy.EXPONENTIAL,
76+
DownloadRepositoryImpl.DOWNLOAD_BACKOFF_DELAY_MS,
77+
TimeUnit.MILLISECONDS,
78+
)
6679
.setInputData(
6780
Data.Builder()
6881
.putString(DownloadWorker.KEY_DOWNLOAD_ID, download.id)

app/src/main/java/com/raulshma/jellyplay/tile/JellyPlayTileService.kt

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,16 @@ class JellyPlayTileService : TileService() {
2828
override fun onStartListening() {
2929
super.onStartListening()
3030
updateTile()
31+
// Collect both play state and title so the tile can reflect three states:
32+
// playing, paused (session loaded but not playing), and inactive (no
33+
// session). QS tiles are binary (ACTIVE/INACTIVE), so the paused state is
34+
// surfaced via the label/contentDescription rather than the tile icon.
3135
job = tileScope.launch {
32-
audioPlaybackManager.isPlaying.collect { isPlaying ->
33-
updateTile(isPlaying)
36+
kotlinx.coroutines.flow.combine(
37+
audioPlaybackManager.isPlaying,
38+
audioPlaybackManager.title,
39+
) { isPlaying, title -> isPlaying to title }.collect { (isPlaying, title) ->
40+
updateTile(isPlaying, title)
3441
}
3542
}
3643
}
@@ -46,10 +53,10 @@ class JellyPlayTileService : TileService() {
4653
val isPlaying = audioPlaybackManager.isPlaying.value
4754
if (isPlaying) {
4855
audioPlaybackManager.pause()
49-
updateTile(false)
56+
updateTile(false, audioPlaybackManager.title.value)
5057
} else if (audioPlaybackManager.hasActiveSession) {
5158
audioPlaybackManager.resume()
52-
updateTile(true)
59+
updateTile(true, audioPlaybackManager.title.value)
5360
} else {
5461
val intent = Intent(this, MainActivity::class.java).apply {
5562
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
@@ -67,15 +74,26 @@ class JellyPlayTileService : TileService() {
6774
}
6875
}
6976

70-
private fun updateTile(isPlaying: Boolean = false) {
77+
private fun updateTile(isPlaying: Boolean = false, title: String = "") {
78+
val hasSession = audioPlaybackManager.hasActiveSession
7179
qsTile?.apply {
7280
state = if (isPlaying) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
73-
label = if (audioPlaybackManager.hasActiveSession) {
74-
audioPlaybackManager.title.value.ifEmpty { getString(R.string.app_name) }
75-
} else {
76-
getString(R.string.app_name)
81+
label = when {
82+
// Paused: a track is loaded but not playing.
83+
hasSession && !isPlaying -> {
84+
val t = title.ifEmpty { getString(R.string.app_name) }
85+
getString(R.string.tile_paused_label, t)
86+
}
87+
// Playing: show the track title.
88+
hasSession -> title.ifEmpty { getString(R.string.app_name) }
89+
// No session: just the app name.
90+
else -> getString(R.string.app_name)
91+
}
92+
contentDescription = when {
93+
isPlaying -> getString(R.string.tile_playing_cd)
94+
hasSession -> getString(R.string.tile_paused_cd)
95+
else -> getString(R.string.app_name)
7796
}
78-
contentDescription = if (isPlaying) "Playing" else getString(R.string.app_name)
7997
updateTile()
8098
}
8199
}

app/src/main/java/com/raulshma/jellyplay/widget/WidgetImageLoader.kt

Lines changed: 59 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import coil3.request.CachePolicy
1313
import coil3.request.ImageRequest
1414
import coil3.request.allowHardware
1515
import coil3.toBitmap
16+
import android.util.LruCache
1617
import kotlinx.coroutines.Dispatchers
1718
import kotlinx.coroutines.coroutineScope
1819
import kotlinx.coroutines.async
@@ -41,9 +42,38 @@ object WidgetImageLoader {
4142
// fall back to its placeholder drawable — preferable to a frozen cell.
4243
private const val WIDGET_LOAD_TIMEOUT_MS = 2_000L
4344

45+
// Overall deadline for a whole batch preload. RemoteViewsFactory callbacks
46+
// run on the widget host's binder thread, so without a hard ceiling N slow
47+
// URLs (each up to WIDGET_LOAD_TIMEOUT_MS) can stall the launcher and ANR
48+
// it. Bounded preloads return a partial map; unresolved URLs fall back to
49+
// the placeholder just like an individual timeout.
50+
private const val WIDGET_PRELOAD_DEADLINE_MS = 2_000L
51+
52+
// Cap how many distinct posters a single preload attempts. A widget cell
53+
// count is small; loading more just widens the blocking window on the
54+
// binder thread for content the user has to scroll to see anyway.
55+
private const val WIDGET_PRELOAD_MAX_URLS = 12
56+
57+
/**
58+
* Process-scoped decoded-poster cache keyed by image URL. RemoteViewsService
59+
* factories are recreated frequently (new binder, process restart, config
60+
* change) and previously re-fetched every poster on each re-bind even when
61+
* the URL was unchanged. This LruCache makes repeat binds a map lookup.
62+
*
63+
* Sized by bitmap byte count (~250KB per decoded+rounded poster at
64+
* [WIDGET_IMAGE_TARGET]); ~6MB comfortably holds a full widget grid and is
65+
* well inside the widget process's bitmap budget.
66+
*/
67+
private val posterMemoryCache: LruCache<String, Bitmap> = object : LruCache<String, Bitmap>(6 * 1024 * 1024) {
68+
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount
69+
}
70+
4471
suspend fun loadPoster(context: Context, url: String?, cornerRadiusDp: Float = 10f): Bitmap? {
4572
if (url.isNullOrBlank()) return null
46-
return withTimeoutOrNull(WIDGET_LOAD_TIMEOUT_MS) {
73+
// Serve from the process cache first so a factory re-bind (or a second
74+
// widget on the same launcher) does not re-decode/re-fetch.
75+
posterMemoryCache.get(url)?.let { return it }
76+
val bitmap = withTimeoutOrNull(WIDGET_LOAD_TIMEOUT_MS) {
4777
withContext(Dispatchers.IO) {
4878
runCatching {
4979
val request = ImageRequest.Builder(context)
@@ -67,24 +97,43 @@ object WidgetImageLoader {
6797
}.getOrNull()
6898
}
6999
}
100+
if (bitmap != null) posterMemoryCache.put(url, bitmap)
101+
return bitmap
70102
}
71103

72104
/**
73-
* Pre-fetches every poster concurrently so `RemoteViewsFactory.getViewAt`
105+
* Pre-fetches posters concurrently so `RemoteViewsFactory.getViewAt`
74106
* can read from the resulting cache instead of doing per-cell network I/O
75-
* on the binder thread. A single slow URL won't blow the budget — each
76-
* individual load is bounded by [WIDGET_LOAD_TIMEOUT_MS] via [loadPoster].
107+
* on the binder thread.
108+
*
109+
* Two safety rails keep this from ANR-ing the launcher when called from a
110+
* factory's `onDataSetChanged`:
111+
* 1. The batch is capped at [WIDGET_PRELOAD_MAX_URLS] distinct URLs.
112+
* 2. The whole batch is bounded by [WIDGET_PRELOAD_DEADLINE_MS]; any URL
113+
* not resolved in time simply maps to `null` (placeholder fallback).
114+
*
115+
* Already-cached posters (from a prior preload in this process) are served
116+
* instantly from [posterMemoryCache] and don't count against the deadline.
77117
*/
78118
suspend fun preloadPosters(
79119
context: Context,
80120
urls: Collection<String>,
81121
cornerRadiusDp: Float = 10f,
82-
): Map<String, Bitmap?> = coroutineScope {
83-
urls.filter { it.isNotBlank() }
84-
.distinct()
85-
.map { url -> async { url to loadPoster(context, url, cornerRadiusDp) } }
86-
.awaitAll()
87-
.toMap()
122+
): Map<String, Bitmap?> {
123+
val distinct = urls.filter { it.isNotBlank() }.distinct()
124+
if (distinct.isEmpty()) return emptyMap()
125+
val capped = distinct.take(WIDGET_PRELOAD_MAX_URLS)
126+
// withTimeoutOrNull returns null on timeout — treat that as "resolve
127+
// whatever landed". Because the async loads write into posterMemoryCache
128+
// as they complete, a timeout still leaves any finished entries cached
129+
// for the next bind; here we just report the ones that resolved in time.
130+
return withTimeoutOrNull(WIDGET_PRELOAD_DEADLINE_MS) {
131+
coroutineScope {
132+
capped.map { url -> async { url to loadPoster(context, url, cornerRadiusDp) } }
133+
.awaitAll()
134+
.toMap()
135+
}
136+
} ?: emptyMap()
88137
}
89138

90139
private fun applyRoundedCorners(context: Context, bitmap: Bitmap, cornerRadiusDp: Float = 10f): Bitmap {

app/src/main/res/values/strings.xml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
<resources>
22
<string name="app_name">JellyPlay</string>
33
<string name="open_app_description">Open %1$s</string>
4+
<string name="tile_paused_label">Paused: %1$s</string>
5+
<string name="tile_playing_cd">Playing</string>
6+
<string name="tile_paused_cd">Paused</string>
47

58
<!-- TV exit confirmation: shown on first back press from a top-level screen. -->
69
<string name="press_back_again_to_exit">Press back again to exit</string>

core/data/src/main/java/com/raulshma/jellyplay/core/data/newsletter/NewsletterTriggerManager.kt

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,18 +17,36 @@ class NewsletterTriggerManager @Inject constructor(
1717
if (!prefs.newsletterEnabled) return@map false
1818

1919
val configuredDay = dayOfWeekFromPref(prefs.newsletterDayOfWeek)
20-
val today = LocalDate.now().dayOfWeek
21-
if (today != configuredDay) return@map false
20+
val today = LocalDate.now()
21+
val todayDow = today.dayOfWeek
2222

23+
// The configured weekday is the *primary* trigger, but if the user never
24+
// opened the app that day (or opened it before the digest was ready and
25+
// hasn't viewed this week's issue), surface it on the next launch instead
26+
// of silently dropping it. The issue stays "current" for a full week —
27+
// from one configured weekday up to (but not including) the next — so it
28+
// must remain due on any day in that window, including the Mon–Thu after
29+
// a Friday digest the user missed. The lastViewedDate < issueDate check
30+
// below is the sole gate for whether this cycle's issue has been seen.
2331
if (prefs.newsletterLastViewedMs <= 0L) return@map true
2432

2533
val lastViewedDate = java.time.Instant
2634
.ofEpochMilli(prefs.newsletterLastViewedMs)
2735
.atZone(ZoneId.systemDefault())
2836
.toLocalDate()
2937

30-
val startOfToday = LocalDate.now()
31-
lastViewedDate < startOfToday
38+
// Show again only if the user hasn't viewed an issue since the most recent
39+
// configured weekday. Compute this week's issue date: if today is on/after
40+
// the configured day, the issue date is this week's configured day; for a
41+
// Sunday wrap-around the issue is the prior week's day.
42+
val issueDate = if (todayDow.value >= configuredDay.value) {
43+
today.minusDays((todayDow.value - configuredDay.value).toLong())
44+
} else {
45+
// todayDow < configuredDay.value only happens at the Sunday wrap branch
46+
today.minusDays((todayDow.value + 7 - configuredDay.value).toLong())
47+
}
48+
49+
lastViewedDate < issueDate
3250
}
3351

3452
private fun dayOfWeekFromPref(value: Int): DayOfWeek = when (value) {

core/data/src/main/java/com/raulshma/jellyplay/core/data/playback/AudioCrossfader.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ class AudioCrossfader(
4040
private val onCrossfadeTransition: suspend (secondary: ExoPlayer, nextIndex: Int, nextItem: AudioQueueItem) -> Unit,
4141
private val detachPrimaryListener: (ExoPlayer) -> Unit,
4242
private val onCrossfadeError: (PlaybackException) -> Unit,
43+
private val onCrossfadeFailed: (nextIndex: Int) -> Unit,
4344
private val dataSourceFactoryProvider: () -> androidx.media3.datasource.DataSource.Factory,
4445
) {
4546
private var crossfadePlayer: ExoPlayer? = null
@@ -206,6 +207,12 @@ class AudioCrossfader(
206207
// item's detail) must release the crossfade flag so that the
207208
// next attempt is not permanently blocked.
208209
isCrossfadingSetter(false)
210+
// Notify the manager so it can reconcile `_currentIndex` /
211+
// `currentItemId`. Without this, the primary player will reach
212+
// STATE_ENDED and, under REPEAT_MODE_OFF, neither ExoPlayer's
213+
// auto-advance nor `onMediaItemTransition` fires — leaving the
214+
// UI's current-track highlight stuck on the ended item.
215+
onCrossfadeFailed(actualIndex)
209216
}
210217
}
211218
}

0 commit comments

Comments
 (0)