diff --git a/automotive/build.gradle.kts b/automotive/build.gradle.kts index e602bbf..04242d4 100644 --- a/automotive/build.gradle.kts +++ b/automotive/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.chamika.dashtune" minSdk = 28 targetSdk = 36 - versionCode = 20 - versionName = "1.2.4" + versionCode = 21 + versionName = "1.2.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt b/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt index 74be0a8..cf3c6d2 100644 --- a/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt +++ b/automotive/src/main/java/com/chamika/dashtune/AlbumArtContentProvider.kt @@ -14,12 +14,16 @@ import okio.buffer import okio.sink import java.io.File import java.io.FileNotFoundException +import java.io.IOException import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit class AlbumArtContentProvider : ContentProvider() { - private val client = OkHttpClient() + private val client = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(10, TimeUnit.SECONDS) + .build() companion object { private val uriMap = mutableMapOf() @@ -86,28 +90,38 @@ class AlbumArtContentProvider : ContentProvider() { .build() Log.d(LOG_TAG, "Downloading $remoteUri ...") - client.newCall(request).execute().use { - if (it.code == 200) { - Log.d(LOG_TAG, "Downloaded $remoteUri") - val source = it.body.source() - source.request(Long.MAX_VALUE) - - val sink = tmpFile.sink().buffer() - sink.writeAll(source) - sink.flush() - sink.close() - - tmpFile.renameTo(file) - } else { - Log.w(LOG_TAG, "Failed to download $remoteUri: \n ${it.code} - ${it.body}") - FirebaseUtils.safeSetCustomKey("album_art_path", remoteUri.path ?: "unknown") - FirebaseUtils.safeRecordException(Exception("Album art download failed: HTTP ${it.code}")) + try { + client.newCall(request).execute().use { + if (it.code == 200) { + Log.d(LOG_TAG, "Downloaded $remoteUri") + val source = it.body.source() + source.request(Long.MAX_VALUE) + + val sink = tmpFile.sink().buffer() + sink.writeAll(source) + sink.flush() + sink.close() + + tmpFile.renameTo(file) + } else { + Log.w(LOG_TAG, "Failed to download $remoteUri: \n ${it.code} - ${it.body}") + FirebaseUtils.safeSetCustomKey("album_art_path", remoteUri.path ?: "unknown") + FirebaseUtils.safeRecordException(Exception("Album art download failed: HTTP ${it.code}")) + } + } + } catch (e: IOException) { + Log.w(LOG_TAG, "Network error downloading $remoteUri", e) + } finally { + tmpFile.delete() + synchronized(inProgress) { + inProgress[remoteUri]?.countDown() + inProgress.remove(remoteUri) } - - inProgress.get(remoteUri)?.countDown() - inProgress.remove(remoteUri) } + if (!file.exists()) { + throw FileNotFoundException(uri.path) + } return ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY) } diff --git a/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt b/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt index c2f70cf..72f2219 100644 --- a/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt +++ b/automotive/src/main/java/com/chamika/dashtune/DashTuneMusicService.kt @@ -193,6 +193,23 @@ class DashTuneMusicService : MediaLibraryService() { } playerListener = object : Player.Listener { + override fun onPlayerError(error: androidx.media3.common.PlaybackException) { + val p = mediaLibrarySession.player + val failedId = p.currentMediaItem?.mediaId + Log.e(LOG_TAG, "Player error on $failedId: ${error.errorCodeName}", error) + FirebaseUtils.safeSetCustomKey("player_error_code", error.errorCodeName) + FirebaseUtils.safeSetCustomKey("failed_track_id", failedId ?: "") + FirebaseUtils.safeRecordException(error) + + if (p.hasNextMediaItem()) { + p.seekToNextMediaItem() + p.prepare() + } else { + p.stop() + p.clearMediaItems() + } + } + override fun onEvents(player: Player, events: Player.Events) { if (events.contains(Player.EVENT_MEDIA_ITEM_TRANSITION)) { // Save audiobook position for the OLD track before transition @@ -419,6 +436,24 @@ class DashTuneMusicService : MediaLibraryService() { super.onUpdateNotification(session, startInForegroundRequired) } + fun isTrackCachedOrOnline(mediaId: String): Boolean { + if (hasInternet()) return true + return try { + val state = downloadManager.downloadIndex.getDownload(mediaId)?.state + state == Download.STATE_COMPLETED + } catch (e: Exception) { + Log.w(LOG_TAG, "Failed to check cache for $mediaId", e) + false + } + } + + private fun hasInternet(): Boolean { + val net = connectivityManager.activeNetwork ?: return false + val caps = connectivityManager.getNetworkCapabilities(net) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } + fun onLogin() { FirebaseUtils.safeLog("Login applied to service") jellyfinApi.update( diff --git a/automotive/src/main/java/com/chamika/dashtune/DashTuneSessionCallback.kt b/automotive/src/main/java/com/chamika/dashtune/DashTuneSessionCallback.kt index bf19969..40b28e4 100644 --- a/automotive/src/main/java/com/chamika/dashtune/DashTuneSessionCallback.kt +++ b/automotive/src/main/java/com/chamika/dashtune/DashTuneSessionCallback.kt @@ -124,28 +124,25 @@ class DashTuneSessionCallback( ): ListenableFuture> { Log.i(LOG_TAG, "onGetRoot") - if (!accountManager.isAuthenticated) { - return Futures.immediateFuture( - LibraryResult.ofError( - SessionError( - SessionError.ERROR_SESSION_AUTHENTICATION_EXPIRED, - service.getString(R.string.sign_in_to_your_jellyfin_server) - ), - MediaLibraryService.LibraryParams.Builder() - .setExtras(authenticationExtras()).build() - ) - ) - } - val artSize = params?.extras?.getInt(EXTRAS_KEY_MEDIA_ART_SIZE_PIXELS) ensureTreeInitialized(artSize) + // Always return a valid root MediaItem. Returning LibraryResult.ofError here + // causes the Media3 -> legacy MediaBrowserService bridge to deliver a null root + // to the AAOS Media Center, which then fails with onConnectFailed and shows + // a blank screen instead of the sign-in button. The unauthenticated case is + // surfaced via the auth extras on the root params and the auth check in + // onGetChildren. return SuspendToFutureAdapter.launchFuture { try { - LibraryResult.ofItem( - repository.getItem(ROOT_ID), + val rootItem = repository.getItem(ROOT_ID) + val outParams = if (!accountManager.isAuthenticated) { + MediaLibraryService.LibraryParams.Builder() + .setExtras(authenticationExtras()).build() + } else { params - ) + } + LibraryResult.ofItem(rootItem, outParams) } catch (e: Exception) { Log.e(LOG_TAG, "Failed to get library root", e) FirebaseUtils.safeSetCustomKey("failed_operation", "get_library_root") @@ -455,21 +452,35 @@ class DashTuneSessionCallback( try { val prefs = PreferenceManager.getDefaultSharedPreferences(service) - val mediaItemsToRestore = prefs + val savedIds = prefs .getString(PLAYLIST_IDS_PREF, "") ?.split(",") ?.filter { it.isNotEmpty() } - ?.map { async { repository.getItem(it) } } - ?.awaitAll() ?: listOf() + ?: emptyList() + + val playableIds = savedIds.filter { service.isTrackCachedOrOnline(it) } + if (playableIds.size < savedIds.size) { + Log.i(LOG_TAG, "Skipping ${savedIds.size - playableIds.size} unplayable tracks (offline + uncached)") + } + + val mediaItemsToRestore = playableIds + .map { async { repository.getItem(it) } } + .awaitAll() Log.d(LOG_TAG, "Resuming playback with $mediaItemsToRestore") FirebaseUtils.safeLog("Restoring playback: index=${prefs.getInt(PLAYLIST_INDEX_PREF, 0)}, trackCount=${mediaItemsToRestore.size}") - MediaSession.MediaItemsWithStartPosition( - mediaItemsToRestore, - prefs.getInt(PLAYLIST_INDEX_PREF, 0), - prefs.getLong(PLAYLIST_TRACK_POSITON_MS_PREF, 0), - ) + if (mediaItemsToRestore.isEmpty()) { + MediaSession.MediaItemsWithStartPosition(emptyList(), 0, 0L) + } else { + val savedIndex = prefs.getInt(PLAYLIST_INDEX_PREF, 0) + .coerceIn(0, mediaItemsToRestore.lastIndex) + MediaSession.MediaItemsWithStartPosition( + mediaItemsToRestore, + savedIndex, + prefs.getLong(PLAYLIST_TRACK_POSITON_MS_PREF, 0), + ) + } } catch (e: Exception) { Log.e(LOG_TAG, "Failed to resume playback", e) FirebaseUtils.safeRecordException(e)