From cf7feacdc0562337b35400013c6aaf43b0db0301 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sun, 31 Aug 2025 20:24:48 -0700 Subject: [PATCH 001/112] fix: casting full album/playlist working as intended, passing metadata for album artwork in small square #16 --- .../tempo/service/MediaService.kt | 106 ++++++++++++------ .../tempo/service/MediaService.kt | 106 ++++++++++++------ 2 files changed, 140 insertions(+), 72 deletions(-) diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt index 2391a2bb..3eb747ed 100644 --- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -45,8 +45,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { initializePlayerListener() setPlayer( - null, - if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player + null, + if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player ) } @@ -73,13 +73,13 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializePlayer() { player = ExoPlayer.Builder(this) - .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(getMediaSourceFactory()) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_NETWORK) - .setLoadControl(initializeLoadControl()) - .build() + .setRenderersFactory(getRenderersFactory()) + .setMediaSourceFactory(getMediaSourceFactory()) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setLoadControl(initializeLoadControl()) + .build() player.shuffleModeEnabled = Preferences.isShuffleModeEnabled() player.repeatMode = Preferences.getRepeatMode() @@ -87,7 +87,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializeCastPlayer() { if (GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS + .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS ) { castPlayer = CastPlayer(CastContext.getSharedInstance(this)) castPlayer.setSessionAvailabilityListener(this) @@ -96,16 +96,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializeMediaLibrarySession() { val sessionActivityPendingIntent = - TaskStackBuilder.create(this).run { - addNextIntent(Intent(this@MediaService, MainActivity::class.java)) - getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) - } + TaskStackBuilder.create(this).run { + addNextIntent(Intent(this@MediaService, MainActivity::class.java)) + getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) + } librarySessionCallback = createLibrarySessionCallback() mediaLibrarySession = - MediaLibrarySession.Builder(this, player, librarySessionCallback) - .setSessionActivity(sessionActivityPendingIntent) - .build() + MediaLibrarySession.Builder(this, player, librarySessionCallback) + .setSessionActivity(sessionActivityPendingIntent) + .build() } private fun createLibrarySessionCallback(): MediaLibrarySessionCallback { @@ -133,8 +133,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { override fun onIsPlayingChanged(isPlaying: Boolean) { if (!isPlaying) { MediaManager.setPlayingPausedTimestamp( - player.currentMediaItem, - player.currentPosition + player.currentMediaItem, + player.currentPosition ) } else { MediaManager.scrobble(player.currentMediaItem, false) @@ -145,8 +145,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { super.onPlaybackStateChanged(playbackState) if (!player.hasNextMediaItem() && - playbackState == Player.STATE_ENDED && - player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC + playbackState == Player.STATE_ENDED && + player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC ) { MediaManager.scrobble(player.currentMediaItem, true) MediaManager.saveChronology(player.currentMediaItem) @@ -154,9 +154,9 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int ) { super.onPositionDiscontinuity(oldPosition, newPosition, reason) @@ -175,14 +175,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { Preferences.setShuffleModeEnabled(shuffleModeEnabled) mediaLibrarySession.setCustomLayout( - librarySessionCallback.buildCustomLayout(player) + librarySessionCallback.buildCustomLayout(player) ) } override fun onRepeatModeChanged(repeatMode: Int) { Preferences.setRepeatMode(repeatMode) mediaLibrarySession.setCustomLayout( - librarySessionCallback.buildCustomLayout(player) + librarySessionCallback.buildCustomLayout(player) ) } }) @@ -190,17 +190,28 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() - .setBufferDurationsMs( - (DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), - (DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS - ) - .build() + .setBufferDurationsMs( + (DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), + (DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + ) + .build() + } + + private fun getQueueFromPlayer(player: Player): List { + // Helper function to get all media items from a player's queue. + val queue = mutableListOf() + for (i in 0 until player.mediaItemCount) { + queue.add(player.getMediaItemAt(i)) + } + return queue } private fun setPlayer(oldPlayer: Player?, newPlayer: Player) { + // Safely switches the player instance and handles state transfer. if (oldPlayer === newPlayer) return + oldPlayer?.stop() mediaLibrarySession.player = newPlayer } @@ -211,19 +222,42 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { player.release() mediaLibrarySession.release() automotiveRepository.deleteMetadata() - clearListener() } private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) private fun getMediaSourceFactory() = - DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) + DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) override fun onCastSessionAvailable() { + // Get the current queue, item index, and position from the local player. + val currentQueue = getQueueFromPlayer(player) + val currentIndex = player.currentMediaItemIndex + val currentPosition = player.currentPosition + val isPlaying = player.playWhenReady + + // Switch the player to the CastPlayer. setPlayer(player, castPlayer) + + // Transfer the entire queue to the CastPlayer and start playback. + castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition) + castPlayer.playWhenReady = isPlaying + castPlayer.prepare() } override fun onCastSessionUnavailable() { + // Get the current queue, item index, and position from the CastPlayer. + val currentQueue = getQueueFromPlayer(castPlayer) + val currentIndex = castPlayer.currentMediaItemIndex + val currentPosition = castPlayer.currentPosition + val isPlaying = castPlayer.playWhenReady + + // Switch the player back to the local ExoPlayer. setPlayer(castPlayer, player) + + // Transfer the entire queue to the local ExoPlayer and start playback. + player.setMediaItems(currentQueue, currentIndex, currentPosition) + player.playWhenReady = isPlaying + player.prepare() } -} \ No newline at end of file +} diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 2391a2bb..3eb747ed 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -45,8 +45,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { initializePlayerListener() setPlayer( - null, - if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player + null, + if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player ) } @@ -73,13 +73,13 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializePlayer() { player = ExoPlayer.Builder(this) - .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(getMediaSourceFactory()) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_NETWORK) - .setLoadControl(initializeLoadControl()) - .build() + .setRenderersFactory(getRenderersFactory()) + .setMediaSourceFactory(getMediaSourceFactory()) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setLoadControl(initializeLoadControl()) + .build() player.shuffleModeEnabled = Preferences.isShuffleModeEnabled() player.repeatMode = Preferences.getRepeatMode() @@ -87,7 +87,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializeCastPlayer() { if (GoogleApiAvailability.getInstance() - .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS + .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS ) { castPlayer = CastPlayer(CastContext.getSharedInstance(this)) castPlayer.setSessionAvailabilityListener(this) @@ -96,16 +96,16 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializeMediaLibrarySession() { val sessionActivityPendingIntent = - TaskStackBuilder.create(this).run { - addNextIntent(Intent(this@MediaService, MainActivity::class.java)) - getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) - } + TaskStackBuilder.create(this).run { + addNextIntent(Intent(this@MediaService, MainActivity::class.java)) + getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) + } librarySessionCallback = createLibrarySessionCallback() mediaLibrarySession = - MediaLibrarySession.Builder(this, player, librarySessionCallback) - .setSessionActivity(sessionActivityPendingIntent) - .build() + MediaLibrarySession.Builder(this, player, librarySessionCallback) + .setSessionActivity(sessionActivityPendingIntent) + .build() } private fun createLibrarySessionCallback(): MediaLibrarySessionCallback { @@ -133,8 +133,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { override fun onIsPlayingChanged(isPlaying: Boolean) { if (!isPlaying) { MediaManager.setPlayingPausedTimestamp( - player.currentMediaItem, - player.currentPosition + player.currentMediaItem, + player.currentPosition ) } else { MediaManager.scrobble(player.currentMediaItem, false) @@ -145,8 +145,8 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { super.onPlaybackStateChanged(playbackState) if (!player.hasNextMediaItem() && - playbackState == Player.STATE_ENDED && - player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC + playbackState == Player.STATE_ENDED && + player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC ) { MediaManager.scrobble(player.currentMediaItem, true) MediaManager.saveChronology(player.currentMediaItem) @@ -154,9 +154,9 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { } override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int ) { super.onPositionDiscontinuity(oldPosition, newPosition, reason) @@ -175,14 +175,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { Preferences.setShuffleModeEnabled(shuffleModeEnabled) mediaLibrarySession.setCustomLayout( - librarySessionCallback.buildCustomLayout(player) + librarySessionCallback.buildCustomLayout(player) ) } override fun onRepeatModeChanged(repeatMode: Int) { Preferences.setRepeatMode(repeatMode) mediaLibrarySession.setCustomLayout( - librarySessionCallback.buildCustomLayout(player) + librarySessionCallback.buildCustomLayout(player) ) } }) @@ -190,17 +190,28 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializeLoadControl(): DefaultLoadControl { return DefaultLoadControl.Builder() - .setBufferDurationsMs( - (DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), - (DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, - DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS - ) - .build() + .setBufferDurationsMs( + (DefaultLoadControl.DEFAULT_MIN_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), + (DefaultLoadControl.DEFAULT_MAX_BUFFER_MS * Preferences.getBufferingStrategy()).toInt(), + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_MS, + DefaultLoadControl.DEFAULT_BUFFER_FOR_PLAYBACK_AFTER_REBUFFER_MS + ) + .build() + } + + private fun getQueueFromPlayer(player: Player): List { + // Helper function to get all media items from a player's queue. + val queue = mutableListOf() + for (i in 0 until player.mediaItemCount) { + queue.add(player.getMediaItemAt(i)) + } + return queue } private fun setPlayer(oldPlayer: Player?, newPlayer: Player) { + // Safely switches the player instance and handles state transfer. if (oldPlayer === newPlayer) return + oldPlayer?.stop() mediaLibrarySession.player = newPlayer } @@ -211,19 +222,42 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { player.release() mediaLibrarySession.release() automotiveRepository.deleteMetadata() - clearListener() } private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) private fun getMediaSourceFactory() = - DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) + DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) override fun onCastSessionAvailable() { + // Get the current queue, item index, and position from the local player. + val currentQueue = getQueueFromPlayer(player) + val currentIndex = player.currentMediaItemIndex + val currentPosition = player.currentPosition + val isPlaying = player.playWhenReady + + // Switch the player to the CastPlayer. setPlayer(player, castPlayer) + + // Transfer the entire queue to the CastPlayer and start playback. + castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition) + castPlayer.playWhenReady = isPlaying + castPlayer.prepare() } override fun onCastSessionUnavailable() { + // Get the current queue, item index, and position from the CastPlayer. + val currentQueue = getQueueFromPlayer(castPlayer) + val currentIndex = castPlayer.currentMediaItemIndex + val currentPosition = castPlayer.currentPosition + val isPlaying = castPlayer.playWhenReady + + // Switch the player back to the local ExoPlayer. setPlayer(castPlayer, player) + + // Transfer the entire queue to the local ExoPlayer and start playback. + player.setMediaItems(currentQueue, currentIndex, currentPosition) + player.playWhenReady = isPlaying + player.prepare() } -} \ No newline at end of file +} From 7ca04152749e8ce6aa47cd9bd9a083437b191f54 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sun, 31 Aug 2025 20:25:32 -0700 Subject: [PATCH 002/112] chore: bumped version --- app/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 71530899..82d07752 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 31 - versionName '3.14.8' + versionCode 32 + versionName '3.14.9' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' From 473d7e4e9c8d36783cf9f8d340c5ce1d36bcf45d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Smith?= <62805693+benoit-smith@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:48:04 +0200 Subject: [PATCH 003/112] Update strings.xml (FR) --- app/src/main/res/values-fr/strings.xml | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 8c6f2d99..56121783 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -117,6 +117,8 @@ Télécharger Télécharger ces titres peut entraîner une utilisation importante de données On dirait qu\'il y a des titres favoris à synchroniser + Synchroniser les albums favoris + Les albums marqués d\'une étoile seront disponibles hors-ligne Best of Découverte Tout mélanger @@ -341,7 +343,9 @@ %1$s \nUtilisé actuellement : %2$s MiB Le mode de transcodage à prioriser. Si réglé sur \"Lecture directe\", le débit binaire du fichier ne sera pas modifié. Télécharge les médias transcodés. Si activé, les paramètres de transcodage suivants seront utilisés pour les téléchargements.\n\n Si le format de transcodage est reglé à \"Téléchargement direct\", le débit binaire du fichier ne sera pas modifé. - Quand le fichier est transcodé à la volé, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs. + Quand le fichier est transcodé à la volée, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs. + Si activé, les albums favoris seront téléchargées pour l\'écoute hors-ligne + Synchronisation des albums favoris pour écoute hors-ligne Si activé, les pistes favorites seront téléchargées pour l\'écoute hors-ligne Synchronisation des pistes favorites pour écoute hors-ligne Thème @@ -394,8 +398,10 @@ Annuler Continuer Continuer et télécharger - Le téléchargement des titres favoris pourrer utiliser beaucoup de données. + Le téléchargement des titres favoris pourrait consommer beaucoup de données. Synchroniser les titres favoris + Le téléchargement des titres favoris pourrait consommer beaucoup de données. + Synchroniser les albums favoris Veuillez redémarrer l\'app pour appliquer les changements. Modifier le chemin de stockage des fichiers mis en cache risque de provoquer la suppression de tous les fichiers précédemment mis en cache dans le nouvel espace de stockage. Sélectionner une option de stockage @@ -430,4 +436,8 @@ unDraw Un grand merci à unDraw, nous n\'aurions pas pu rendre cette application aussi belle sans leurs illustrations. https://undraw.co/ + + %d album à synchroniser + %d albums à synchroniser + From 92f79a8e3dccc10acd626877e3d993784ec23aad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Beno=C3=AEt=20Smith?= <62805693+benoit-smith@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:49:16 +0200 Subject: [PATCH 004/112] Update strings.xml (FR) [2] --- app/src/main/res/values-fr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 56121783..e0b98da9 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -344,7 +344,7 @@ Le mode de transcodage à prioriser. Si réglé sur \"Lecture directe\", le débit binaire du fichier ne sera pas modifié. Télécharge les médias transcodés. Si activé, les paramètres de transcodage suivants seront utilisés pour les téléchargements.\n\n Si le format de transcodage est reglé à \"Téléchargement direct\", le débit binaire du fichier ne sera pas modifé. Quand le fichier est transcodé à la volée, en général, le client n\'affiche pas la durée de la piste. Il est possible de demander aux serveurs qui le supportent d\'estimer la durée de la piste écoutée, mais les temps de réponses peuvent être plus longs. - Si activé, les albums favoris seront téléchargées pour l\'écoute hors-ligne + Si activé, les albums favoris seront téléchargés pour l\'écoute hors-ligne Synchronisation des albums favoris pour écoute hors-ligne Si activé, les pistes favorites seront téléchargées pour l\'écoute hors-ligne Synchronisation des pistes favorites pour écoute hors-ligne From 6d403f808c7d4d251a85cb40ff85d0b20dfcb6e9 Mon Sep 17 00:00:00 2001 From: Denis Bezykornov Date: Wed, 3 Sep 2025 19:28:28 +0300 Subject: [PATCH 005/112] Update RU locale --- app/src/main/res/values-ru/strings.xml | 44 ++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 43448d65..f2268e37 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -2,7 +2,7 @@ Если у вас возникли проблемы, посетите https://dontkillmyapp.com. Он содержит подробные инструкции о том, как отключить любые функции энергосбережения, которые могут повлиять на производительность приложения. Пожалуйста, отключите оптимизацию батареи для воспроизведения мультимедиа при выключенном экране. Оптимизация батареи - Офлайн-режим + Автономный режим Добавить в плейлист Добавить в очередь Скачать все @@ -90,6 +90,7 @@ Загрузки Выберите два или более фильтров Фильтр + Фильтровать исполнителей Фильтровать жанры Каталог жанров Просмотр жанров @@ -103,6 +104,9 @@ Сохранять Настроить главную Обратите внимание, чтобы внесенные изменения вступили в силу, необходимо перезапустить приложение. + Музыка + Подкасты + Радио Лучшие треки любимых исполнителей Запустите микс с понравившимся вам треком Добавить новое радио @@ -111,6 +115,8 @@ Скачать Загрузка этих треков может потребовать значительного использования данных Похоже, есть несколько отмеченных треков для синхронизации. + Синхронизировать отмеченные альбомы + Отмеченные альбомы будут доступны в автономном режиме Лучшее из Открытие Перемешать все @@ -126,6 +132,7 @@ Увидеть все Новые релизы Новейшие подкасты + Плейлисты Каналы Увидеть все Радиостанции @@ -156,6 +163,7 @@ Добавить Добавить в плейлист Скачать все + Оценить альбом Скачать Все Загружено @@ -165,6 +173,9 @@ Трек Год Главная + Прошлая неделя + Прошлый месяц + Прошлый год Библиотека Поиск Настройки @@ -181,7 +192,11 @@ Убрать с главного экрана %1$.2fx Очистить очередь воспроизведения + Очередь сохранена Приоритет сервера + Неизвестный форма + Транскодирование + запрошено Каталог плейлистов Просмотр плейлистов Плейлисты не созданы @@ -271,6 +286,8 @@ Приоритет при перекодировании трека отдается серверу Стратегия буферизации Чтобы изменения вступили в силу, необходимо вручную перезапустить приложение. + Разрешить играть включать треки после окончания плейлиста + Продолжать играть Размер кэша обложек Чтобы сократить потребление данных, избегайте загрузки обложек. Ограничить использование мобильных данных @@ -295,6 +312,8 @@ Если включено, показывать раздел подкаста. Перезапустите приложение, чтобы оно вступило в силу. Показать качество звука (битрейт) Битрейт и аудиоформат будут показаны для каждой аудиодорожки. + Показать рейтинг трека + Если эта функция включена, будет отображаться пятизвездочный рейтинг трека на странице воспроизведения\n\n*Требует перезапуска приложения Показать рейтинг Если эта функция включена, будет отображаться рейтинг элемента и то, отмечен ли он как избранный. Таймер синхронизации @@ -309,18 +328,24 @@ Если этот параметр включен, задает угол кривизны для всех отображаемых обложек. Изменения вступят в силу при перезапуске. Сканировать библиотеку Включить скробблинг музыки Last.FM и т.д. + Язык системы Включить обмен музыкой + Размер кэша стриминга + Хранилище кэша стриминга Важно отметить, что скробблинг также зависит от того, настроен ли сервер для получения этих данных. При прослушивании радио исполнителя, мгновенном миксе или перемешивании всех, треки ниже определенного пользовательского рейтинга будут игнорироваться. Усиление воспроизведения — это функция, которая позволяет регулировать уровень громкости звуковых дорожек для обеспечения единообразного качества прослушивания. Этот параметр действует только в том случае, если трек содержит необходимые метаданные. Скробблинг — это функция, которая позволяет вашему устройству отправлять информацию о песнях, которые вы слушаете, на музыкальный сервер. Эта информация помогает создавать персональные рекомендации на основе ваших музыкальных предпочтений. Позволяет пользователю делиться музыкой по ссылке. Функциональность должна поддерживаться и включаться на стороне сервера и ограничивается отдельными треками, альбомами и плейлистами. Возвращает состояние очереди воспроизведения для этого пользователя. Сюда входят треки в очереди воспроизведения, воспроизводимый в данный момент трек и позиция внутри этого трека. Сервер должен поддерживать эту функцию. + %1$s \nСейчас используется: %2$s MiB Приоритет отдается режиму перекодирования. Если установлено «Прямое воспроизведение», битрейт файла не изменится. Загрузите перекодированные медиафайлы. Если этот параметр включен, будет использоваться не конечная точка загрузки, а следующие настройки. Если для параметра «Формат перекодирования для загрузки» установлено значение «Прямая загрузка», битрейт файла не изменится. Когда файл перекодируется на лету, клиент обычно не показывает длину трека. Можно запросить у серверов, поддерживающих данную функцию, оценку длительности воспроизводимого трека, но время ответа может занять больше времени. + Если этот параметр включен, помеченные альбомы будут загружены для использования в автономном режиме. + Синхронизировать помеченные альбомы для использования в автономном режиме. Если этот параметр включен, помеченные треки будут загружены для использования в автономном режиме. - Синхронизируйте помеченные треки для использования в автономном режиме. + Синхронизировать помеченные треки для использования в автономном режиме. Тема Данные Общий @@ -332,7 +357,7 @@ Поделиться Синхронизации Транскодирование - Транскодирование Скачать + Скачивание с транскодированием UI (Пользовательский интерфейс) Перекодированная загрузка Версия @@ -373,8 +398,16 @@ Продолжить и скачать Для скачивания рейтинговых треков может потребоваться большой объем данных. Синхронизировать отмеченные треки + Для скачивания рейтинговых альбомов может потребоваться большой объем данных. + Синхронизировать отмеченные альбомы + Чтобы изменения вступили в силу необходимо перезапустить приложение. + Изменение места сохранения кэшированных файлов с одного на другое может привести к удалению файлов в старом хранилище. + Выберите способ сохранения + Внешний + Внутренний Альбом Исполнитель + Разрядность Битрейт Тип содержимого OK @@ -383,6 +416,7 @@ Продолжительность Жанр Путь + Частота сэмплирования Размер Суффикс Файл был загружен с использованием API Subsonic. Кодек и битрейт файла остаются неизменными по сравнению с исходным файлом. @@ -399,4 +433,8 @@ Развернуть Особая благодарность — команде unDraw, без иллюстраций которой мы не смогли бы сделать это приложение красивее. https://undraw.co/ + + Альбомов для синхронизации: %d + Альбомов для синхронизации: %d + From e43a2b6fe56d558c5f45786e5b14874b201e6dd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Fri, 5 Sep 2025 04:46:01 +0200 Subject: [PATCH 006/112] fix: Resolve playback issues with live radio MPEG & HLS streams --- .../tempo/util/DownloadUtil.java | 54 +++++++------- .../service/DynamicMediaSourceFactory.kt | 73 +++++++++++++++++++ .../tempo/service/MediaService.kt | 7 +- 3 files changed, 103 insertions(+), 31 deletions(-) create mode 100644 app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java index a8cafc4a..3b6babfe 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.util; import android.app.Notification; import android.content.Context; +import android.net.Uri; import androidx.core.app.NotificationCompat; import androidx.media3.common.util.UnstableApi; @@ -78,35 +79,36 @@ public final class DownloadUtil { return httpDataSourceFactory; } - public static synchronized DataSource.Factory getDataSourceFactory(Context context) { - if (dataSourceFactory == null) { - context = context.getApplicationContext(); - - DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); - - if (Preferences.getStreamingCacheSize() > 0) { - CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() - .setCache(getStreamingCache(context)) - .setUpstreamDataSourceFactory(upstreamFactory); - - ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory( - new StreamingCacheDataSource.Factory(streamCacheFactory), - dataSpec -> { - DataSpec.Builder builder = dataSpec.buildUpon(); - builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN); - return builder.build(); - } - ); - - dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context)); - } else { - dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); - } - } - + public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) { + DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); return dataSourceFactory; } + public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) { + CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory() + .setCache(getStreamingCache(context)) + .setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context)); + + ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory( + new StreamingCacheDataSource.Factory(streamCacheFactory), + dataSpec -> { + DataSpec.Builder builder = dataSpec.buildUpon(); + builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN); + return builder.build(); + } + ); + dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context)); + return dataSourceFactory; + } + + public static boolean shouldBypassCache(Uri uri) { + if (uri == null) return true; + String url = uri.toString(); + String mainServer = Preferences.getServer(); + return mainServer != null && !url.startsWith(mainServer); + } + public static synchronized DownloadNotificationHelper getDownloadNotificationHelper(Context context) { if (downloadNotificationHelper == null) { downloadNotificationHelper = new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt new file mode 100644 index 00000000..8c8df4d3 --- /dev/null +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt @@ -0,0 +1,73 @@ +package com.cappielloantonio.tempo.service + +import android.content.Context +import android.net.Uri +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.drm.DrmSessionManagerProvider +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import androidx.media3.extractor.DefaultExtractorsFactory +import androidx.media3.extractor.ExtractorsFactory +import com.cappielloantonio.tempo.util.DownloadUtil +import com.cappielloantonio.tempo.util.Preferences + +@UnstableApi +class DynamicMediaSourceFactory( + private val context: Context +) : MediaSource.Factory { + + override fun createMediaSource(mediaItem: MediaItem): MediaSource { + val uri: Uri = mediaItem.localConfiguration?.uri ?: mediaItem.requestMetadata.mediaUri + ?: throw IllegalArgumentException("MediaItem must contain a valid URI") + + val streamingCacheSize = Preferences.getStreamingCacheSize() + val bypassCache = DownloadUtil.shouldBypassCache(uri) + + val useUpstream = when { + streamingCacheSize.toInt() == 0 -> true + streamingCacheSize > 0 && bypassCache -> true + streamingCacheSize > 0 && !bypassCache -> false + else -> true + } + + val dataSourceFactory: DataSource.Factory = if (useUpstream) { + DownloadUtil.getUpstreamDataSourceFactory(context) + } else { + DownloadUtil.getCacheDataSourceFactory(context) + } + + return when { + mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 || + uri.toString().endsWith(".m3u8") -> { + HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) + } + + else -> { + val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory() + ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(mediaItem) + } + } + } + + override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory { + TODO("Not yet implemented") + } + + override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory { + TODO("Not yet implemented") + } + + override fun getSupportedTypes(): IntArray { + return intArrayOf( + C.CONTENT_TYPE_HLS, + C.CONTENT_TYPE_OTHER + ) + } +} \ No newline at end of file diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index 92c4b9b3..c78b660c 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -10,7 +10,6 @@ import androidx.media3.common.* import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.trackselection.TrackSelectionArray import androidx.media3.session.* @@ -19,6 +18,7 @@ import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil +import com.cappielloantonio.tempo.service.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil import com.google.common.collect.ImmutableList @@ -186,7 +186,7 @@ class MediaService : MediaLibraryService() { private fun initializePlayer() { player = ExoPlayer.Builder(this) .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(getMediaSourceFactory()) + .setMediaSourceFactory(DynamicMediaSourceFactory(this)) .setAudioAttributes(AudioAttributes.DEFAULT, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) @@ -346,7 +346,4 @@ class MediaService : MediaLibraryService() { } private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) - - private fun getMediaSourceFactory() = - DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) } \ No newline at end of file From bd753f44897b676aa1356ea72c8c0cd6636a8caa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:19:47 +0200 Subject: [PATCH 007/112] fix: Use defined media type for live radio detection, relocate DynamicMediaSourceFactory --- .../cappielloantonio/tempo/util/DownloadUtil.java | 8 -------- .../tempo/util}/DynamicMediaSourceFactory.kt | 12 ++++-------- .../cappielloantonio/tempo/service/MediaService.kt | 2 +- 3 files changed, 5 insertions(+), 17 deletions(-) rename app/src/{notquitemy/java/com/cappielloantonio/tempo/service => main/java/com/cappielloantonio/tempo/util}/DynamicMediaSourceFactory.kt (83%) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java index 3b6babfe..238b4136 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -2,7 +2,6 @@ package com.cappielloantonio.tempo.util; import android.app.Notification; import android.content.Context; -import android.net.Uri; import androidx.core.app.NotificationCompat; import androidx.media3.common.util.UnstableApi; @@ -102,13 +101,6 @@ public final class DownloadUtil { return dataSourceFactory; } - public static boolean shouldBypassCache(Uri uri) { - if (uri == null) return true; - String url = uri.toString(); - String mainServer = Preferences.getServer(); - return mainServer != null && !url.startsWith(mainServer); - } - public static synchronized DownloadNotificationHelper getDownloadNotificationHelper(Context context) { if (downloadNotificationHelper == null) { downloadNotificationHelper = new DownloadNotificationHelper(context, DOWNLOAD_NOTIFICATION_CHANNEL_ID); diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt similarity index 83% rename from app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt rename to app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt index 8c8df4d3..575cf837 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/DynamicMediaSourceFactory.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt @@ -1,7 +1,6 @@ -package com.cappielloantonio.tempo.service +package com.cappielloantonio.tempo.util import android.content.Context -import android.net.Uri import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.MimeTypes @@ -14,8 +13,6 @@ import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy import androidx.media3.extractor.DefaultExtractorsFactory import androidx.media3.extractor.ExtractorsFactory -import com.cappielloantonio.tempo.util.DownloadUtil -import com.cappielloantonio.tempo.util.Preferences @UnstableApi class DynamicMediaSourceFactory( @@ -23,11 +20,10 @@ class DynamicMediaSourceFactory( ) : MediaSource.Factory { override fun createMediaSource(mediaItem: MediaItem): MediaSource { - val uri: Uri = mediaItem.localConfiguration?.uri ?: mediaItem.requestMetadata.mediaUri - ?: throw IllegalArgumentException("MediaItem must contain a valid URI") + val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "") val streamingCacheSize = Preferences.getStreamingCacheSize() - val bypassCache = DownloadUtil.shouldBypassCache(uri) + val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO val useUpstream = when { streamingCacheSize.toInt() == 0 -> true @@ -44,7 +40,7 @@ class DynamicMediaSourceFactory( return when { mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 || - uri.toString().endsWith(".m3u8") -> { + mediaItem.localConfiguration?.uri.toString().endsWith(".m3u8") -> { HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) } diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt index c78b660c..d595afe7 100644 --- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -18,7 +18,7 @@ import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil -import com.cappielloantonio.tempo.service.DynamicMediaSourceFactory +import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil import com.google.common.collect.ImmutableList From fab18c130e4e63f07a32358e591af25e06fd473b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Fri, 5 Sep 2025 21:29:11 +0200 Subject: [PATCH 008/112] fix: Support HLS (.m3u8) streams with parameters in URL --- .../cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt index 575cf837..31dc172a 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DynamicMediaSourceFactory.kt @@ -40,7 +40,7 @@ class DynamicMediaSourceFactory( return when { mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 || - mediaItem.localConfiguration?.uri.toString().endsWith(".m3u8") -> { + mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> { HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem) } From d4cb6c5c9a6a08ed7ad7d57b584671828294c106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Sat, 6 Sep 2025 00:20:35 +0200 Subject: [PATCH 009/112] fix: Update MediaService in all build variants --- .../com/cappielloantonio/tempo/service/MediaService.kt | 7 ++----- .../com/cappielloantonio/tempo/service/MediaService.kt | 7 ++----- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt index 2391a2bb..e46a140a 100644 --- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -14,13 +14,13 @@ import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.repository.AutomotiveRepository import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil +import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil import com.google.android.gms.cast.framework.CastContext @@ -74,7 +74,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializePlayer() { player = ExoPlayer.Builder(this) .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(getMediaSourceFactory()) + .setMediaSourceFactory(DynamicMediaSourceFactory(this)) .setAudioAttributes(AudioAttributes.DEFAULT, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) @@ -216,9 +216,6 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) - private fun getMediaSourceFactory() = - DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) - override fun onCastSessionAvailable() { setPlayer(player, castPlayer) } diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt index 2391a2bb..e46a140a 100644 --- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -14,13 +14,13 @@ import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.repository.AutomotiveRepository import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.DownloadUtil +import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.ReplayGainUtil import com.google.android.gms.cast.framework.CastContext @@ -74,7 +74,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun initializePlayer() { player = ExoPlayer.Builder(this) .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(getMediaSourceFactory()) + .setMediaSourceFactory(DynamicMediaSourceFactory(this)) .setAudioAttributes(AudioAttributes.DEFAULT, true) .setHandleAudioBecomingNoisy(true) .setWakeMode(C.WAKE_MODE_NETWORK) @@ -216,9 +216,6 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) - private fun getMediaSourceFactory() = - DefaultMediaSourceFactory(this).setDataSourceFactory(DownloadUtil.getDataSourceFactory(this)) - override fun onCastSessionAvailable() { setPlayer(player, castPlayer) } From 7c0d44680fdc4a2fb528cc1f1ae06ac8cc3d1c29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Mon, 8 Sep 2025 19:28:34 +0200 Subject: [PATCH 010/112] feat: Add audio equalizer with UI --- .../tempo/service/EqualizerManager.kt | 47 ++++ .../tempo/ui/fragment/EqualizerFragment.kt | 225 ++++++++++++++++++ .../ui/fragment/PlayerControllerFragment.java | 17 ++ .../tempo/ui/fragment/SettingsFragment.java | 27 ++- .../tempo/util/Preferences.kt | 30 ++- app/src/main/res/drawable/ic_eq.xml | 11 + .../main/res/drawable/ui_eq_not_supported.xml | 93 ++++++++ ...nner_fragment_player_controller_layout.xml | 14 +- .../main/res/layout/fragment_equalizer.xml | 105 ++++++++ ...nner_fragment_player_controller_layout.xml | 14 +- app/src/main/res/navigation/nav_graph.xml | 17 ++ app/src/main/res/values-de/strings.xml | 4 +- app/src/main/res/values-es-rES/strings.xml | 10 +- app/src/main/res/values-fr/strings.xml | 4 +- app/src/main/res/values-it/strings.xml | 4 +- app/src/main/res/values-ko/strings.xml | 4 +- app/src/main/res/values-pl/strings.xml | 4 +- app/src/main/res/values-pt/strings.xml | 4 +- app/src/main/res/values-ru/strings.xml | 4 +- app/src/main/res/values-tr/strings.xml | 4 +- app/src/main/res/values-zh/strings.xml | 4 +- app/src/main/res/values/strings.xml | 10 +- app/src/main/res/xml/global_preferences.xml | 11 +- .../tempo/service/MediaService.kt | 40 ++++ .../tempo/service/MediaService.kt | 43 ++++ .../tempo/service/MediaService.kt | 43 ++++ 26 files changed, 762 insertions(+), 31 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt create mode 100644 app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt create mode 100644 app/src/main/res/drawable/ic_eq.xml create mode 100644 app/src/main/res/drawable/ui_eq_not_supported.xml create mode 100644 app/src/main/res/layout/fragment_equalizer.xml diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt new file mode 100644 index 00000000..9d8489e3 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/service/EqualizerManager.kt @@ -0,0 +1,47 @@ +package com.cappielloantonio.tempo.service + +import android.media.audiofx.Equalizer + +class EqualizerManager { + + private var equalizer: Equalizer? = null + + fun attachToSession(audioSessionId: Int): Boolean { + release() + if (audioSessionId != 0 && audioSessionId != -1) { + try { + equalizer = Equalizer(0, audioSessionId).apply { + enabled = true + } + return true + } catch (e: Exception) { + // Some devices may not support Equalizer or audio session may be invalid + equalizer = null + } + } + return false + } + + fun setBandLevel(band: Short, level: Short) { + equalizer?.setBandLevel(band, level) + } + + fun getNumberOfBands(): Short = equalizer?.numberOfBands ?: 0 + + fun getBandLevelRange(): ShortArray? = equalizer?.bandLevelRange + + fun getCenterFreq(band: Short): Int? = + equalizer?.getCenterFreq(band)?.div(1000) + + fun getBandLevel(band: Short): Short? = + equalizer?.getBandLevel(band) + + fun setEnabled(enabled: Boolean) { + equalizer?.enabled = enabled + } + + fun release() { + equalizer?.release() + equalizer = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt new file mode 100644 index 00000000..16c58457 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt @@ -0,0 +1,225 @@ +package com.cappielloantonio.tempo.ui.fragment + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.* +import androidx.annotation.OptIn +import androidx.fragment.app.Fragment +import androidx.media3.common.util.UnstableApi +import com.cappielloantonio.tempo.R +import com.cappielloantonio.tempo.service.EqualizerManager +import com.cappielloantonio.tempo.service.MediaService +import com.cappielloantonio.tempo.util.Preferences + +class EqualizerFragment : Fragment() { + + private var equalizerManager: EqualizerManager? = null + private lateinit var eqBandsContainer: LinearLayout + private lateinit var eqSwitch: Switch + private lateinit var resetButton: Button + private lateinit var safeSpace: Space + private val bandSeekBars = mutableListOf() + + private val connection = object : ServiceConnection { + @OptIn(UnstableApi::class) + override fun onServiceConnected(className: ComponentName, service: IBinder) { + val binder = service as MediaService.LocalBinder + equalizerManager = binder.getEqualizerManager() + initUI() + restoreEqualizerPreferences() + } + + override fun onServiceDisconnected(arg0: ComponentName) { + equalizerManager = null + } + } + + @OptIn(UnstableApi::class) + override fun onStart() { + super.onStart() + Intent(requireContext(), MediaService::class.java).also { intent -> + intent.action = MediaService.ACTION_BIND_EQUALIZER + requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE) + } + } + + override fun onStop() { + super.onStop() + requireActivity().unbindService(connection) + equalizerManager = null + } + + override fun onCreateView( + inflater: LayoutInflater, container: ViewGroup?, + savedInstanceState: Bundle? + ): View? { + return inflater.inflate(R.layout.fragment_equalizer, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + eqBandsContainer = view.findViewById(R.id.eq_bands_container) + eqSwitch = view.findViewById(R.id.equalizer_switch) + resetButton = view.findViewById(R.id.equalizer_reset_button) + safeSpace = view.findViewById(R.id.equalizer_bottom_space) + } + + private fun initUI() { + val manager = equalizerManager + val notSupportedView = view?.findViewById(R.id.equalizer_not_supported_container) + val switchRow = view?.findViewById(R.id.equalizer_switch_row) + + if (manager == null || manager.getNumberOfBands().toInt() == 0) { + switchRow?.visibility = View.GONE + resetButton.visibility = View.GONE + eqBandsContainer.visibility = View.GONE + safeSpace.visibility = View.GONE + notSupportedView?.visibility = View.VISIBLE + return + } + + notSupportedView?.visibility = View.GONE + switchRow?.visibility = View.VISIBLE + resetButton.visibility = View.VISIBLE + eqBandsContainer.visibility = View.VISIBLE + safeSpace.visibility = View.VISIBLE + + eqSwitch.setOnCheckedChangeListener(null) + eqSwitch.isChecked = Preferences.isEqualizerEnabled() + updateUiEnabledState(eqSwitch.isChecked) + eqSwitch.setOnCheckedChangeListener { _, isChecked -> + manager.setEnabled(isChecked) + Preferences.setEqualizerEnabled(isChecked) + updateUiEnabledState(isChecked) + } + + createBandSliders() + + resetButton.setOnClickListener { + resetEqualizer() + saveBandLevelsToPreferences() + } + } + + private fun updateUiEnabledState(isEnabled: Boolean) { + resetButton.isEnabled = isEnabled + bandSeekBars.forEach { it.isEnabled = isEnabled } + } + + private fun createBandSliders() { + val manager = equalizerManager ?: return + eqBandsContainer.removeAllViews() + bandSeekBars.clear() + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevel = bandLevelRange[0].toInt() + val maxLevel = bandLevelRange[1].toInt() + + val savedLevels = Preferences.getEqualizerBandLevels(bands) + for (i in 0 until bands) { + val band = i.toShort() + val freq = manager.getCenterFreq(band) ?: 0 + + val row = LinearLayout(requireContext()).apply { + orientation = LinearLayout.HORIZONTAL + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ).apply { + topMargin = 24 + bottomMargin = 24 + } + setPadding(0, 8, 0, 8) + } + + val freqLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply { + text = if (freq >= 1000) { + if (freq % 1000 == 0) { + "${freq / 1000} kHz" + } else { + String.format("%.1f kHz", freq / 1000f) + } + } else { + "$freq Hz" + } + width = 120 + } + row.addView(freqLabel) + + val initialLevel = savedLevels.getOrNull(i) ?: (manager.getBandLevel(band)?.toInt() ?: 0) + val dbLabel = TextView(requireContext(), null, 0, R.style.LabelSmall).apply { + text = "${(initialLevel.toInt() / 100)} dB" + setPadding(12, 0, 0, 0) + width = 120 + gravity = Gravity.END + } + + val seekBar = SeekBar(requireContext()).apply { + max = maxLevel - minLevel + progress = initialLevel.toInt() - minLevel + layoutParams = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f) + setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + val thisLevel = (progress + minLevel).toShort() + if (fromUser) { + manager.setBandLevel(band, thisLevel) + saveBandLevelsToPreferences() + } + dbLabel.text = "${((progress + minLevel) / 100)} dB" + } + + override fun onStartTrackingTouch(seekBar: SeekBar) {} + override fun onStopTrackingTouch(seekBar: SeekBar) {} + }) + } + bandSeekBars.add(seekBar) + row.addView(seekBar) + row.addView(dbLabel) + eqBandsContainer.addView(row) + } + } + + private fun resetEqualizer() { + val manager = equalizerManager ?: return + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevel = bandLevelRange[0].toInt() + val midLevel = 0 + for (i in 0 until bands) { + manager.setBandLevel(i.toShort(), midLevel.toShort()) + bandSeekBars.getOrNull(i)?.progress = midLevel - minLevel + } + Preferences.setEqualizerBandLevels(ShortArray(bands.toInt())) + } + + private fun saveBandLevelsToPreferences() { + val manager = equalizerManager ?: return + val bands = manager.getNumberOfBands() + val levels = ShortArray(bands.toInt()) { i -> manager.getBandLevel(i.toShort()) ?: 0 } + Preferences.setEqualizerBandLevels(levels) + } + + private fun restoreEqualizerPreferences() { + val manager = equalizerManager ?: return + eqSwitch.isChecked = Preferences.isEqualizerEnabled() + updateUiEnabledState(eqSwitch.isChecked) + val bands = manager.getNumberOfBands() + val bandLevelRange = manager.getBandLevelRange() ?: shortArrayOf(-1500, 1500) + val minLevel = bandLevelRange[0].toInt() + val savedLevels = Preferences.getEqualizerBandLevels(bands) + if (savedLevels != null) { + for (i in 0 until bands) { + manager.setBandLevel(i.toShort(), savedLevels[i]) + bandSeekBars.getOrNull(i)?.progress = savedLevels[i] - minLevel + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java index 99f3c4ca..84871c78 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java @@ -24,6 +24,8 @@ import androidx.media3.common.util.RepeatModeUtil; import androidx.media3.common.util.UnstableApi; import androidx.media3.session.MediaBrowser; import androidx.media3.session.SessionToken; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; import androidx.navigation.fragment.NavHostFragment; import androidx.viewpager2.widget.ViewPager2; @@ -68,6 +70,7 @@ public class PlayerControllerFragment extends Fragment { private ImageButton playerOpenQueueButton; private ImageButton playerTrackInfo; private LinearLayout ratingContainer; + private ImageButton equalizerButton; private MainActivity activity; private PlayerBottomSheetViewModel playerBottomSheetViewModel; @@ -89,6 +92,7 @@ public class PlayerControllerFragment extends Fragment { initMediaListenable(); initMediaLabelButton(); initArtistLabelButton(); + initEqualizerButton(); return view; } @@ -126,6 +130,7 @@ public class PlayerControllerFragment extends Fragment { playerTrackInfo = bind.getRoot().findViewById(R.id.player_info_track); songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); ratingContainer = bind.getRoot().findViewById(R.id.rating_container); + equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); checkAndSetRatingContainerVisibility(); } @@ -426,6 +431,18 @@ public class PlayerControllerFragment extends Fragment { }); } + private void initEqualizerButton() { + equalizerButton.setOnClickListener(v -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + navController.navigate(R.id.equalizerFragment, null, navOptions); + if (activity != null) activity.collapseBottomSheetDelayed(); + }); + } + public void goToControllerPage() { playerMediaCoverViewPager.setCurrentItem(0, false); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index e8ddf2b2..09a50c86 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -18,6 +18,9 @@ import androidx.appcompat.app.AppCompatDelegate; import androidx.core.os.LocaleListCompat; import androidx.lifecycle.ViewModelProvider; import androidx.media3.common.util.UnstableApi; +import androidx.navigation.NavController; +import androidx.navigation.NavOptions; +import androidx.navigation.fragment.NavHostFragment; import androidx.preference.ListPreference; import androidx.preference.Preference; import androidx.preference.PreferenceFragmentCompat; @@ -86,7 +89,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { public void onResume() { super.onResume(); - checkEqualizer(); + checkSystemEqualizer(); checkCacheStorage(); checkStorage(); @@ -102,6 +105,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { actionChangeDownloadStorage(); actionDeleteDownloadStorage(); actionKeepScreenOn(); + actionAppEqualizer(); } @Override @@ -124,8 +128,8 @@ public class SettingsFragment extends PreferenceFragmentCompat { } } - private void checkEqualizer() { - Preference equalizer = findPreference("equalizer"); + private void checkSystemEqualizer() { + Preference equalizer = findPreference("system_equalizer"); if (equalizer == null) return; @@ -353,4 +357,21 @@ public class SettingsFragment extends PreferenceFragmentCompat { return true; }); } + + private void actionAppEqualizer() { + Preference appEqualizer = findPreference("app_equalizer"); + if (appEqualizer != null) { + appEqualizer.setOnPreferenceClickListener(preference -> { + NavController navController = NavHostFragment.findNavController(this); + NavOptions navOptions = new NavOptions.Builder() + .setLaunchSingleTop(true) + .setPopUpTo(R.id.equalizerFragment, true) + .build(); + activity.setBottomNavigationBarVisibility(true); + activity.setBottomSheetVisibility(true); + navController.navigate(R.id.equalizerFragment, null, navOptions); + return true; + }); + } + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 8c77ab13..92cb30cd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -69,7 +69,8 @@ object Preferences { private const val NEXT_UPDATE_CHECK = "next_update_check" private const val CONTINUOUS_PLAY = "continuous_play" private const val LAST_INSTANT_MIX = "last_instant_mix" - + private const val EQUALIZER_ENABLED = "equalizer_enabled" + private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels" @JvmStatic fun getServer(): String? { @@ -538,4 +539,31 @@ object Preferences { LAST_INSTANT_MIX, 0 ) + 5000 < System.currentTimeMillis() } + + @JvmStatic + fun setEqualizerEnabled(enabled: Boolean) { + App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply() + } + + @JvmStatic + fun isEqualizerEnabled(): Boolean { + return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false) + } + + @JvmStatic + fun setEqualizerBandLevels(bandLevels: ShortArray) { + val asString = bandLevels.joinToString(",") + App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply() + } + + @JvmStatic + fun getEqualizerBandLevels(bandCount: Short): ShortArray { + val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null) + if (str.isNullOrBlank()) { + return ShortArray(bandCount.toInt()) + } + val parts = str.split(",") + if (parts.size < bandCount) return ShortArray(bandCount.toInt()) + return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 } + } } \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_eq.xml b/app/src/main/res/drawable/ic_eq.xml new file mode 100644 index 00000000..5f3a8b46 --- /dev/null +++ b/app/src/main/res/drawable/ic_eq.xml @@ -0,0 +1,11 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/ui_eq_not_supported.xml b/app/src/main/res/drawable/ui_eq_not_supported.xml new file mode 100644 index 00000000..fc8a364b --- /dev/null +++ b/app/src/main/res/drawable/ui_eq_not_supported.xml @@ -0,0 +1,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml index 7ad0250e..cb3ae9c6 100644 --- a/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml +++ b/app/src/main/res/layout-land/inner_fragment_player_controller_layout.xml @@ -382,11 +382,23 @@ android:layout_height="wrap_content" android:padding="16dp" android:background="?attr/selectableItemBackgroundBorderless" - app:layout_constraintStart_toStartOf="parent" + app:layout_constraintStart_toEndOf="@+id/player_open_equalizer_button" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="parent" app:layout_constraintBottom_toBottomOf="parent" app:srcCompat="@drawable/ic_queue" /> + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_equalizer.xml b/app/src/main/res/layout/fragment_equalizer.xml new file mode 100644 index 00000000..dcf2191f --- /dev/null +++ b/app/src/main/res/layout/fragment_equalizer.xml @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + +