From 5a8a63144956aea1c9adcc703cdd303545da26b3 Mon Sep 17 00:00:00 2001 From: pca006132 Date: Sun, 16 Nov 2025 14:19:58 +0800 Subject: [PATCH 1/9] fix shuffle --- .../tempo/service/MediaService.kt | 28 +++++++-- .../tempo/service/MediaManager.java | 61 ++++++++++++------- 2 files changed, 61 insertions(+), 28 deletions(-) diff --git a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt index 5c4e939c..d595a8a3 100644 --- a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -19,6 +19,7 @@ import androidx.media3.common.util.UnstableApi import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.media3.session.* import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.R @@ -39,6 +40,7 @@ import com.google.common.util.concurrent.ListenableFuture @UnstableApi class MediaService : MediaLibraryService() { + private val TAG = "MediaService" private val librarySessionCallback = CustomMediaLibrarySessionCallback() private lateinit var player: ExoPlayer @@ -86,7 +88,7 @@ class MediaService : MediaLibraryService() { } fun updateMediaItems() { - Log.d("MediaService", "update items"); + Log.d(TAG, "update items"); val n = player.mediaItemCount val k = player.currentMediaItemIndex val current = player.currentPosition @@ -349,14 +351,30 @@ class MediaService : MediaLibraryService() { } override fun onTracksChanged(tracks: Tracks) { + Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex); ReplayGainUtil.setReplayGain(player, tracks) val currentMediaItem = player.currentMediaItem - if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) { - MediaManager.scrobble(currentMediaItem, false) + if (currentMediaItem != null) { + val item = MappingUtil.mapMediaItem(currentMediaItem) + if (item.mediaMetadata.extras != null) + MediaManager.scrobble(item, false) + + if (player.nextMediaItemIndex == C.INDEX_UNSET) + MediaManager.continuousPlay(player.currentMediaItem) } - if (player.currentMediaItemIndex + 1 == player.mediaItemCount) - MediaManager.continuousPlay(player.currentMediaItem) + // https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs + if (MediaManager.justStarted.get()) { + Log.d(TAG, "update shuffle order") + MediaManager.justStarted.set(false) + val shuffledList = IntArray(player.mediaItemCount) { i -> i } + shuffledList.shuffle() + val index = shuffledList.indexOf(player.currentMediaItemIndex) + // swap current media index to the first index + if (index > -1 && shuffledList.isNotEmpty()) + run { val tmp = shuffledList[0]; shuffledList[0] = shuffledList[index]; shuffledList[index] = tmp} + player.shuffleOrder = DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong()) + } } override fun onIsPlayingChanged(isPlaying: Boolean) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java index f7cd8a38..62c6bccc 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java @@ -36,10 +36,12 @@ import com.google.common.util.concurrent.MoreExecutors; import java.lang.ref.WeakReference; import java.util.List; import java.util.concurrent.ExecutionException; +import java.util.concurrent.atomic.AtomicBoolean; public class MediaManager { private static final String TAG = "MediaManager"; private static WeakReference attachedBrowserRef = new WeakReference<>(null); + public static AtomicBoolean justStarted = new AtomicBoolean(false); public static void registerPlaybackObserver( ListenableFuture browserFuture, @@ -179,8 +181,8 @@ public class MediaManager { try { if (mediaBrowserListenableFuture.isDone()) { MediaBrowser browser = mediaBrowserListenableFuture.get(); - browser.clearMediaItems(); - browser.setMediaItems(MappingUtil.mapMediaItems(media)); + justStarted.set(true); + browser.setMediaItems(MappingUtil.mapMediaItems(media), startIndex, 0); browser.prepare(); Player.Listener timelineListener = new Player.Listener() { @@ -210,10 +212,11 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - mediaBrowserListenableFuture.get().clearMediaItems(); - mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(media)); - mediaBrowserListenableFuture.get().prepare(); - mediaBrowserListenableFuture.get().play(); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + justStarted.set(true); + browser.setMediaItem(MappingUtil.mapMediaItem(media)); + browser.prepare(); + browser.play(); enqueueDatabase(media, true, 0); } } catch (ExecutionException | InterruptedException e) { @@ -229,7 +232,7 @@ public class MediaManager { try { if (mediaBrowserListenableFuture.isDone()) { MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get(); - mediaBrowser.clearMediaItems(); + justStarted.set(true); mediaBrowser.setMediaItem(mediaItem); mediaBrowser.prepare(); mediaBrowser.play(); @@ -247,10 +250,11 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - mediaBrowserListenableFuture.get().clearMediaItems(); - mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation)); - mediaBrowserListenableFuture.get().prepare(); - mediaBrowserListenableFuture.get().play(); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + justStarted.set(true); + browser.setMediaItem(MappingUtil.mapInternetRadioStation(internetRadioStation)); + browser.prepare(); + browser.play(); } } catch (ExecutionException | InterruptedException e) { e.printStackTrace(); @@ -264,10 +268,11 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - mediaBrowserListenableFuture.get().clearMediaItems(); - mediaBrowserListenableFuture.get().setMediaItem(MappingUtil.mapMediaItem(podcastEpisode)); - mediaBrowserListenableFuture.get().prepare(); - mediaBrowserListenableFuture.get().play(); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + justStarted.set(true); + browser.setMediaItem(MappingUtil.mapMediaItem(podcastEpisode)); + browser.prepare(); + browser.play(); } } catch (ExecutionException | InterruptedException e) { e.printStackTrace(); @@ -281,9 +286,11 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) { - enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex()); - mediaBrowserListenableFuture.get().addMediaItems(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItems(media)); + Log.e(TAG, "enqueue"); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) { + enqueueDatabase(media, false, browser.getNextMediaItemIndex()); + browser.addMediaItems(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItems(media)); } else { enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount()); mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media)); @@ -301,9 +308,11 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - if (playImmediatelyAfter && mediaBrowserListenableFuture.get().getNextMediaItemIndex() != -1) { - enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getNextMediaItemIndex()); - mediaBrowserListenableFuture.get().addMediaItem(mediaBrowserListenableFuture.get().getNextMediaItemIndex(), MappingUtil.mapMediaItem(media)); + Log.e(TAG, "enqueue"); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + if (playImmediatelyAfter && browser.getNextMediaItemIndex() != -1) { + enqueueDatabase(media, false, browser.getNextMediaItemIndex()); + browser.addMediaItem(browser.getNextMediaItemIndex(), MappingUtil.mapMediaItem(media)); } else { enqueueDatabase(media, false, mediaBrowserListenableFuture.get().getMediaItemCount()); mediaBrowserListenableFuture.get().addMediaItem(MappingUtil.mapMediaItem(media)); @@ -321,8 +330,10 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { - mediaBrowserListenableFuture.get().removeMediaItems(startIndex, endIndex + 1); - mediaBrowserListenableFuture.get().addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1)); + Log.e(TAG, "shuffle"); + MediaBrowser browser = mediaBrowserListenableFuture.get(); + browser.removeMediaItems(startIndex, endIndex + 1); + browser.addMediaItems(MappingUtil.mapMediaItems(media).subList(startIndex, endIndex + 1)); swapDatabase(media); } } catch (ExecutionException | InterruptedException e) { @@ -337,6 +348,7 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { + Log.e(TAG, "swap"); mediaBrowserListenableFuture.get().moveMediaItem(from, to); swapDatabase(media); } @@ -352,6 +364,7 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { + Log.e(TAG, "remove"); if (mediaBrowserListenableFuture.get().getMediaItemCount() > 1 && mediaBrowserListenableFuture.get().getCurrentMediaItemIndex() != toRemove) { mediaBrowserListenableFuture.get().removeMediaItem(toRemove); removeDatabase(media, toRemove); @@ -371,6 +384,7 @@ public class MediaManager { mediaBrowserListenableFuture.addListener(() -> { try { if (mediaBrowserListenableFuture.isDone()) { + Log.e(TAG, "remove range"); mediaBrowserListenableFuture.get().removeMediaItems(fromItem, toItem); removeRangeDatabase(media, fromItem, toItem); } @@ -420,6 +434,7 @@ public class MediaManager { @Override public void onChanged(List media) { if (media != null) { + Log.e(TAG, "continuous play"); ListenableFuture mediaBrowserListenableFuture = new MediaBrowser.Builder( App.getContext(), new SessionToken(App.getContext(), new ComponentName(App.getContext(), MediaService.class)) From 7aa325f914e0bf47e4a575545d9f547645188bb2 Mon Sep 17 00:00:00 2001 From: pca006132 Date: Sun, 16 Nov 2025 17:19:01 +0800 Subject: [PATCH 2/9] refactor MediaService --- .../tempo/service/MediaService.kt | 575 +---------------- .../tempo/service/BaseMediaService.kt | 590 ++++++++++++++++++ .../tempo/ui/fragment/EqualizerFragment.kt | 9 +- .../tempo/util/ReplayGainUtil.java | 25 +- .../tempo/service/MediaService.kt | 455 +------------- 5 files changed, 633 insertions(+), 1021 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt diff --git a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt index d595a8a3..f1e68f5d 100644 --- a/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/degoogled/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -1,579 +1,6 @@ package com.cappielloantonio.tempo.service -import android.annotation.SuppressLint -import android.app.PendingIntent.FLAG_IMMUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT -import android.app.TaskStackBuilder -import android.content.Intent -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Binder -import android.os.Bundle -import android.os.IBinder -import android.os.Handler -import android.os.Looper -import android.util.Log -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.MediaSource -import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder -import androidx.media3.session.* -import androidx.media3.session.MediaSession.ControllerInfo -import com.cappielloantonio.tempo.R -import com.cappielloantonio.tempo.repository.QueueRepository -import com.cappielloantonio.tempo.ui.activity.MainActivity -import com.cappielloantonio.tempo.util.AssetLinkUtil -import com.cappielloantonio.tempo.util.Constants -import com.cappielloantonio.tempo.util.DownloadUtil -import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory -import com.cappielloantonio.tempo.util.MappingUtil -import com.cappielloantonio.tempo.util.Preferences -import com.cappielloantonio.tempo.util.ReplayGainUtil -import com.cappielloantonio.tempo.widget.WidgetUpdateManager -import com.google.common.collect.ImmutableList -import com.google.common.util.concurrent.Futures -import com.google.common.util.concurrent.ListenableFuture - @UnstableApi -class MediaService : MediaLibraryService() { - private val TAG = "MediaService" - private val librarySessionCallback = CustomMediaLibrarySessionCallback() - - private lateinit var player: ExoPlayer - private lateinit var mediaLibrarySession: MediaLibrarySession - private lateinit var shuffleCommands: List - private lateinit var repeatCommands: List - private lateinit var networkCallback: CustomNetworkCallback - lateinit var equalizerManager: EqualizerManager - - private var customLayout = ImmutableList.of() - private val widgetUpdateHandler = Handler(Looper.getMainLooper()) - private var widgetUpdateScheduled = false - private val widgetUpdateRunnable = object : Runnable { - override fun run() { - if (!player.isPlaying) { - widgetUpdateScheduled = false - return - } - updateWidget() - widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) - } - } - - inner class LocalBinder : Binder() { - fun getEqualizerManager(): EqualizerManager { - return this@MediaService.equalizerManager - } - } - - private val binder = LocalBinder() - - companion object { - private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = - "android.media3.session.demo.SHUFFLE_ON" - private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = - "android.media3.session.demo.SHUFFLE_OFF" - private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = - "android.media3.session.demo.REPEAT_OFF" - private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = - "android.media3.session.demo.REPEAT_ONE" - private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = - "android.media3.session.demo.REPEAT_ALL" - const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" - const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED" - } - - fun updateMediaItems() { - Log.d(TAG, "update items"); - val n = player.mediaItemCount - val k = player.currentMediaItemIndex - val current = player.currentPosition - val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))} - player.clearMediaItems() - player.setMediaItems(items, k, current) - } - - inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { - var wasWifi = false - - init { - val manager = getSystemService(ConnectivityManager::class.java) - val network = manager.activeNetwork - val capabilities = manager.getNetworkCapabilities(network) - if (capabilities != null) - wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - } - - override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) { - val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - if (isWifi != wasWifi) { - wasWifi = isWifi - widgetUpdateHandler.post(Runnable { - updateMediaItems() - }) - } - } - } - - override fun onCreate() { - super.onCreate() - - initializeCustomCommands() - initializePlayer() - initializeMediaLibrarySession() - restorePlayerFromQueue() - initializePlayerListener() - initializeEqualizerManager() - initializeNetworkListener() - - setPlayer(player) - } - - override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession { - return mediaLibrarySession - } - - override fun onDestroy() { - releaseNetworkCallback() - equalizerManager.release() - stopWidgetUpdates() - releasePlayer() - super.onDestroy() - } - - override fun onBind(intent: Intent?): IBinder? { - // Check if the intent is for our custom equalizer binder - if (intent?.action == ACTION_BIND_EQUALIZER) { - return binder - } - // Otherwise, handle it as a normal MediaLibraryService connection - return super.onBind(intent) - } - - private inner class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback { - - override fun onConnect( - session: MediaSession, - controller: ControllerInfo - ): MediaSession.ConnectionResult { - val connectionResult = super.onConnect(session, controller) - val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() - - (shuffleCommands + repeatCommands).forEach { commandButton -> - commandButton.sessionCommand?.let { availableSessionCommands.add(it) } - } - - customLayout = buildCustomLayout(session.player) - - return MediaSession.ConnectionResult.AcceptedResultBuilder(session) - .setAvailableSessionCommands(availableSessionCommands.build()) - .setAvailablePlayerCommands(connectionResult.availablePlayerCommands) - .setCustomLayout(customLayout) - .build() - } - - override fun onPostConnect(session: MediaSession, controller: ControllerInfo) { - if (!customLayout.isEmpty() && controller.controllerVersion != 0) { - ignoreFuture(mediaLibrarySession.setCustomLayout(controller, customLayout)) - } - } - - fun buildCustomLayout(player: Player): ImmutableList { - val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0] - val repeat = when (player.repeatMode) { - Player.REPEAT_MODE_ONE -> repeatCommands[1] - Player.REPEAT_MODE_ALL -> repeatCommands[2] - else -> repeatCommands[0] - } - return ImmutableList.of(shuffle, repeat) - } - - override fun onCustomCommand( - session: MediaSession, - controller: ControllerInfo, - customCommand: SessionCommand, - args: Bundle - ): ListenableFuture { - when (customCommand.customAction) { - CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> player.shuffleModeEnabled = true - CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> player.shuffleModeEnabled = false - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> { - val nextMode = when (player.repeatMode) { - Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL - Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE - else -> Player.REPEAT_MODE_OFF - } - player.repeatMode = nextMode - } - } - - customLayout = librarySessionCallback.buildCustomLayout(player) - session.setCustomLayout(customLayout) - - return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) - } - - override fun onAddMediaItems( - mediaSession: MediaSession, - controller: ControllerInfo, - mediaItems: List - ): ListenableFuture> { - val updatedMediaItems = mediaItems.map { mediaItem -> - val mediaMetadata = mediaItem.mediaMetadata - - val newMetadata = mediaMetadata.buildUpon() - .setArtist( - if (mediaMetadata.artist != null) mediaMetadata.artist - else mediaMetadata.extras?.getString("uri") ?: "" - ) - .build() - - mediaItem.buildUpon() - .setUri(mediaItem.requestMetadata.mediaUri) - .setMediaMetadata(newMetadata) - .setMimeType(MimeTypes.BASE_TYPE_AUDIO) - .build() - } - return Futures.immediateFuture(updatedMediaItems) - } - } - - private fun initializeCustomCommands() { - shuffleCommands = listOf( - getShuffleCommandButton( - SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY) - ), - getShuffleCommandButton( - SessionCommand(CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY) - ) - ) - - repeatCommands = listOf( - getRepeatCommandButton( - SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY) - ), - getRepeatCommandButton( - SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY) - ), - getRepeatCommandButton( - SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY) - ) - ) - - customLayout = ImmutableList.of(shuffleCommands[0], repeatCommands[0]) - } - - 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() - - player.shuffleModeEnabled = Preferences.isShuffleModeEnabled() - player.repeatMode = Preferences.getRepeatMode() - } - - private fun initializeEqualizerManager() { - equalizerManager = EqualizerManager() - val audioSessionId = player.audioSessionId - attachEqualizerIfPossible(audioSessionId) - } - - private fun initializeMediaLibrarySession() { - val sessionActivityPendingIntent = - TaskStackBuilder.create(this).run { - addNextIntent(Intent(this@MediaService, MainActivity::class.java)) - getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) - } - - mediaLibrarySession = - MediaLibrarySession.Builder(this, player, librarySessionCallback) - .setSessionActivity(sessionActivityPendingIntent) - .build() - - if (!customLayout.isEmpty()) { - mediaLibrarySession.setCustomLayout(customLayout) - } - } - - private fun initializeNetworkListener() { - networkCallback = CustomNetworkCallback() - getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback) - updateMediaItems() - } - - private fun restorePlayerFromQueue() { - if (player.mediaItemCount > 0) return - - val queueRepository = QueueRepository() - val storedQueue = queueRepository.media - if (storedQueue.isNullOrEmpty()) return - - val mediaItems = MappingUtil.mapMediaItems(storedQueue) - if (mediaItems.isEmpty()) return - - val lastIndex = try { - queueRepository.lastPlayedMediaIndex - } catch (_: Exception) { - 0 - }.coerceIn(0, mediaItems.size - 1) - - val lastPosition = try { - queueRepository.lastPlayedMediaTimestamp - } catch (_: Exception) { - 0L - }.let { if (it < 0L) 0L else it } - - player.setMediaItems(mediaItems, lastIndex, lastPosition) - player.prepare() - updateWidget() - } - - private fun initializePlayerListener() { - player.addListener(object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (mediaItem == null) return - - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { - MediaManager.setLastPlayedTimestamp(mediaItem) - } - updateWidget() - } - - override fun onTracksChanged(tracks: Tracks) { - Log.d(TAG, "onTracksChanged " + player.currentMediaItemIndex); - ReplayGainUtil.setReplayGain(player, tracks) - val currentMediaItem = player.currentMediaItem - if (currentMediaItem != null) { - val item = MappingUtil.mapMediaItem(currentMediaItem) - if (item.mediaMetadata.extras != null) - MediaManager.scrobble(item, false) - - if (player.nextMediaItemIndex == C.INDEX_UNSET) - MediaManager.continuousPlay(player.currentMediaItem) - } - - // https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs - if (MediaManager.justStarted.get()) { - Log.d(TAG, "update shuffle order") - MediaManager.justStarted.set(false) - val shuffledList = IntArray(player.mediaItemCount) { i -> i } - shuffledList.shuffle() - val index = shuffledList.indexOf(player.currentMediaItemIndex) - // swap current media index to the first index - if (index > -1 && shuffledList.isNotEmpty()) - run { val tmp = shuffledList[0]; shuffledList[0] = shuffledList[index]; shuffledList[index] = tmp} - player.shuffleOrder = DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong()) - } - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (!isPlaying) { - MediaManager.setPlayingPausedTimestamp( - player.currentMediaItem, - player.currentPosition - ) - } else { - MediaManager.scrobble(player.currentMediaItem, false) - } - if (isPlaying) { - scheduleWidgetUpdates() - } else { - stopWidgetUpdates() - } - updateWidget() - } - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - if (!player.hasNextMediaItem() && - playbackState == Player.STATE_ENDED && - player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC - ) { - MediaManager.scrobble(player.currentMediaItem, true) - MediaManager.saveChronology(player.currentMediaItem) - } - updateWidget() - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - super.onPositionDiscontinuity(oldPosition, newPosition, reason) - - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { - MediaManager.scrobble(oldPosition.mediaItem, true) - MediaManager.saveChronology(oldPosition.mediaItem) - } - - if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { - MediaManager.setLastPlayedTimestamp(newPosition.mediaItem) - } - } - } - - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - Preferences.setShuffleModeEnabled(shuffleModeEnabled) - customLayout = librarySessionCallback.buildCustomLayout(player) - mediaLibrarySession.setCustomLayout(customLayout) - } - - override fun onRepeatModeChanged(repeatMode: Int) { - Preferences.setRepeatMode(repeatMode) - customLayout = librarySessionCallback.buildCustomLayout(player) - mediaLibrarySession.setCustomLayout(customLayout) - } - - override fun onAudioSessionIdChanged(audioSessionId: Int) { - attachEqualizerIfPossible(audioSessionId) - } - }) - if (player.isPlaying) { - scheduleWidgetUpdates() - } - } - - private fun setPlayer(player: Player) { - mediaLibrarySession.player = player - } - - private fun releasePlayer() { - player.release() - mediaLibrarySession.release() - } - - private fun releaseNetworkCallback() { - getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) - } - - @SuppressLint("PrivateResource") - private fun getShuffleCommandButton(sessionCommand: SessionCommand): CommandButton { - val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON - return CommandButton.Builder() - .setDisplayName( - getString( - if (isOn) R.string.exo_controls_shuffle_on_description - else R.string.exo_controls_shuffle_off_description - ) - ) - .setSessionCommand(sessionCommand) - .setIconResId(if (isOn) R.drawable.exo_icon_shuffle_off else R.drawable.exo_icon_shuffle_on) - .build() - } - - @SuppressLint("PrivateResource") - private fun getRepeatCommandButton(sessionCommand: SessionCommand): CommandButton { - val icon = when (sessionCommand.customAction) { - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.drawable.exo_icon_repeat_one - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.drawable.exo_icon_repeat_all - else -> R.drawable.exo_icon_repeat_off - } - val description = when (sessionCommand.customAction) { - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description - CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description - else -> R.string.exo_controls_repeat_off_description - } - return CommandButton.Builder() - .setDisplayName(getString(description)) - .setSessionCommand(sessionCommand) - .setIconResId(icon) - .build() - } - - private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture) { - /* Do nothing. */ - } - - 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() - } - - private fun updateWidget() { - val mi = player.currentMediaItem - val title = mi?.mediaMetadata?.title?.toString() - ?: mi?.mediaMetadata?.extras?.getString("title") - val artist = mi?.mediaMetadata?.artist?.toString() - ?: mi?.mediaMetadata?.extras?.getString("artist") - val album = mi?.mediaMetadata?.albumTitle?.toString() - ?: mi?.mediaMetadata?.extras?.getString("album") - val extras = mi?.mediaMetadata?.extras - val coverId = extras?.getString("coverArtId") - val songLink = extras?.getString("assetLinkSong") - ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id")) - val albumLink = extras?.getString("assetLinkAlbum") - ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId")) - val artistLink = extras?.getString("assetLinkArtist") - ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId")) - val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L - val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L - WidgetUpdateManager.updateFromState( - this, - title ?: "", - artist ?: "", - album ?: "", - coverId, - player.isPlaying, - player.shuffleModeEnabled, - player.repeatMode, - position, - duration, - songLink, - albumLink, - artistLink - ) - } - - private fun scheduleWidgetUpdates() { - if (widgetUpdateScheduled) return - widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) - widgetUpdateScheduled = true - } - - private fun stopWidgetUpdates() { - if (!widgetUpdateScheduled) return - widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) - widgetUpdateScheduled = false - } - - private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean { - if (audioSessionId == 0 || audioSessionId == -1) return false - val attached = equalizerManager.attachToSession(audioSessionId) - if (attached) { - val enabled = Preferences.isEqualizerEnabled() - equalizerManager.setEnabled(enabled) - val bands = equalizerManager.getNumberOfBands() - val savedLevels = Preferences.getEqualizerBandLevels(bands) - for (i in 0 until bands) { - equalizerManager.setBandLevel(i.toShort(), savedLevels[i]) - } - sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED)) - } - return attached - } - - private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) - - private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this) -} - -private const val WIDGET_UPDATE_INTERVAL_MS = 1000L +class MediaService : BaseMediaService() diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt new file mode 100644 index 00000000..6f5fc52d --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/service/BaseMediaService.kt @@ -0,0 +1,590 @@ +package com.cappielloantonio.tempo.service + +import android.annotation.SuppressLint +import android.app.PendingIntent.FLAG_IMMUTABLE +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.app.TaskStackBuilder +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.os.Binder +import android.os.Bundle +import android.os.IBinder +import android.os.Handler +import android.os.Looper +import android.util.Log +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.MediaSource +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.media3.session.* +import androidx.media3.session.MediaSession.ControllerInfo +import com.cappielloantonio.tempo.R +import com.cappielloantonio.tempo.repository.QueueRepository +import com.cappielloantonio.tempo.ui.activity.MainActivity +import com.cappielloantonio.tempo.util.* +import com.cappielloantonio.tempo.widget.WidgetUpdateManager +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture + +@UnstableApi +open class BaseMediaService : MediaLibraryService() { + companion object { + private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = + "android.media3.session.demo.SHUFFLE_ON" + private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = + "android.media3.session.demo.SHUFFLE_OFF" + private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = + "android.media3.session.demo.REPEAT_OFF" + private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = + "android.media3.session.demo.REPEAT_ONE" + private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = + "android.media3.session.demo.REPEAT_ALL" + const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" + const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED" + } + + protected lateinit var exoplayer: ExoPlayer + protected lateinit var mediaLibrarySession: MediaLibrarySession + private lateinit var networkCallback: CustomNetworkCallback + private lateinit var equalizerManager: EqualizerManager + private val widgetUpdateHandler = Handler(Looper.getMainLooper()) + private var widgetUpdateScheduled = false + private val widgetUpdateRunnable = object : Runnable { + override fun run() { + val player = mediaLibrarySession.player + if (!player.isPlaying) { + widgetUpdateScheduled = false + return + } + updateWidget(player) + widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) + } + } + + private val binder = LocalBinder() + + open fun playerInitHook() { + initializeExoPlayer() + initializeMediaLibrarySession(exoplayer) + initializePlayerListener(exoplayer) + setPlayer(null, exoplayer) + } + + open fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback { + return CustomMediaLibrarySessionCallback(baseContext) + } + + fun updateMediaItems(player: Player) { + Log.d(javaClass.toString(), "update items") + val n = player.mediaItemCount + val k = player.currentMediaItemIndex + val current = player.currentPosition + val items = (0..n - 1).map { MappingUtil.mapMediaItem(player.getMediaItemAt(it)) } + player.clearMediaItems() + player.setMediaItems(items, k, current) + } + + fun restorePlayerFromQueue(player: Player) { + if (player.mediaItemCount > 0) return + + val queueRepository = QueueRepository() + val storedQueue = queueRepository.media + if (storedQueue.isNullOrEmpty()) return + + val mediaItems = MappingUtil.mapMediaItems(storedQueue) + if (mediaItems.isEmpty()) return + + val lastIndex = try { + queueRepository.lastPlayedMediaIndex + } catch (_: Exception) { + 0 + }.coerceIn(0, mediaItems.size - 1) + + val lastPosition = try { + queueRepository.lastPlayedMediaTimestamp + } catch (_: Exception) { + 0L + }.let { if (it < 0L) 0L else it } + + player.setMediaItems(mediaItems, lastIndex, lastPosition) + player.prepare() + updateWidget(player) + } + + fun initializePlayerListener(player: Player) { + player.addListener(object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + Log.d(javaClass.toString(), "onMediaItemTransition" + player.currentMediaItemIndex) + if (mediaItem == null) return + + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { + MediaManager.setLastPlayedTimestamp(mediaItem) + } + updateWidget(player) + } + + override fun onTracksChanged(tracks: Tracks) { + Log.d(javaClass.toString(), "onTracksChanged " + player.currentMediaItemIndex) + ReplayGainUtil.setReplayGain(player, tracks) + val currentMediaItem = player.currentMediaItem + if (currentMediaItem != null) { + val item = MappingUtil.mapMediaItem(currentMediaItem) + if (item.mediaMetadata.extras != null) + MediaManager.scrobble(item, false) + + if (player.nextMediaItemIndex == C.INDEX_UNSET) + MediaManager.continuousPlay(player.currentMediaItem) + } + + if (player is ExoPlayer) { + // https://stackoverflow.com/questions/56937283/exoplayer-shuffle-doesnt-reproduce-all-the-songs + if (MediaManager.justStarted.get()) { + Log.d(javaClass.toString(), "update shuffle order") + MediaManager.justStarted.set(false) + val shuffledList = IntArray(player.mediaItemCount) { i -> i } + shuffledList.shuffle() + val index = shuffledList.indexOf(player.currentMediaItemIndex) + // swap current media index to the first index + if (index > -1 && shuffledList.isNotEmpty()) { + val tmp = shuffledList[0] + shuffledList[0] = shuffledList[index] + shuffledList[index] = tmp + } + player.shuffleOrder = + DefaultShuffleOrder(shuffledList, kotlin.random.Random.nextLong()) + } + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(javaClass.toString(), "onIsPlayingChanged " + player.currentMediaItemIndex) + if (!isPlaying) { + MediaManager.setPlayingPausedTimestamp( + player.currentMediaItem, + player.currentPosition + ) + } else { + MediaManager.scrobble(player.currentMediaItem, false) + } + if (isPlaying) { + scheduleWidgetUpdates() + } else { + stopWidgetUpdates() + } + updateWidget(player) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + Log.d(javaClass.toString(), "onPlaybackStateChanged") + super.onPlaybackStateChanged(playbackState) + if (!player.hasNextMediaItem() && + playbackState == Player.STATE_ENDED && + player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC + ) { + MediaManager.scrobble(player.currentMediaItem, true) + MediaManager.saveChronology(player.currentMediaItem) + } + updateWidget(player) + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + Log.d(javaClass.toString(), "onPositionDiscontinuity") + super.onPositionDiscontinuity(oldPosition, newPosition, reason) + + if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { + if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { + MediaManager.scrobble(oldPosition.mediaItem, true) + MediaManager.saveChronology(oldPosition.mediaItem) + } + + if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { + MediaManager.setLastPlayedTimestamp(newPosition.mediaItem) + } + } + } + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + Preferences.setShuffleModeEnabled(shuffleModeEnabled) + } + + override fun onRepeatModeChanged(repeatMode: Int) { + Preferences.setRepeatMode(repeatMode) + } + + override fun onAudioSessionIdChanged(audioSessionId: Int) { + Log.d(javaClass.toString(), "onAudioSessionIdChanged") + attachEqualizerIfPossible(audioSessionId) + } + }) + if (player.isPlaying) { + scheduleWidgetUpdates() + } + } + + fun setPlayer(oldPlayer: Player?, newPlayer: Player) { + if (oldPlayer === newPlayer) return + if (oldPlayer != null) { + val currentQueue = getQueueFromPlayer(oldPlayer) + val currentIndex = oldPlayer.currentMediaItemIndex + val currentPosition = oldPlayer.currentPosition + val isPlaying = oldPlayer.playWhenReady + oldPlayer.stop() + newPlayer.setMediaItems(currentQueue, currentIndex, currentPosition) + newPlayer.playWhenReady = isPlaying + newPlayer.prepare() + } + mediaLibrarySession.player = newPlayer + } + + open fun releasePlayers() { + exoplayer.release() + } + + fun getQueueFromPlayer(player: Player): List { + return (0..player.mediaItemCount - 1).map(player::getMediaItemAt) + } + + override fun onTaskRemoved(rootIntent: Intent?) { + val player = mediaLibrarySession.player + + if (!player.playWhenReady || player.mediaItemCount == 0) { + stopSelf() + } + } + + override fun onCreate() { + super.onCreate() + + playerInitHook() + initializeEqualizerManager() + initializeNetworkListener() + restorePlayerFromQueue(mediaLibrarySession.player) + } + + override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession { + return mediaLibrarySession + } + + override fun onDestroy() { + releaseNetworkCallback() + equalizerManager.release() + stopWidgetUpdates() + releasePlayers() + mediaLibrarySession.release() + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? { + // Check if the intent is for our custom equalizer binder + if (intent?.action == ACTION_BIND_EQUALIZER) { + return binder + } + // Otherwise, handle it as a normal MediaLibraryService connection + return super.onBind(intent) + } + + private fun initializeExoPlayer() { + exoplayer = ExoPlayer.Builder(this) + .setRenderersFactory(getRenderersFactory()) + .setMediaSourceFactory(getMediaSourceFactory()) + .setAudioAttributes(AudioAttributes.DEFAULT, true) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setLoadControl(initializeLoadControl()) + .build() + + exoplayer.shuffleModeEnabled = Preferences.isShuffleModeEnabled() + exoplayer.repeatMode = Preferences.getRepeatMode() + } + + private fun initializeEqualizerManager() { + equalizerManager = EqualizerManager() + val audioSessionId = exoplayer.audioSessionId + attachEqualizerIfPossible(audioSessionId) + } + + private fun initializeMediaLibrarySession(player: Player) { + Log.d(javaClass.toString(), "initializeMediaLibrarySession") + val sessionActivityPendingIntent = + TaskStackBuilder.create(this).run { + addNextIntent(Intent(baseContext, MainActivity::class.java)) + getPendingIntent(0, FLAG_IMMUTABLE or FLAG_UPDATE_CURRENT) + } + + mediaLibrarySession = + MediaLibrarySession.Builder(this, player, getMediaLibrarySessionCallback()) + .setSessionActivity(sessionActivityPendingIntent) + .build() + } + + private fun initializeNetworkListener() { + networkCallback = CustomNetworkCallback() + getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback( + networkCallback + ) + updateMediaItems(mediaLibrarySession.player) + } + + 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() + } + + private fun releaseNetworkCallback() { + getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) + } + + private fun updateWidget(player: Player) { + val mi = player.currentMediaItem + val title = mi?.mediaMetadata?.title?.toString() + ?: mi?.mediaMetadata?.extras?.getString("title") + val artist = mi?.mediaMetadata?.artist?.toString() + ?: mi?.mediaMetadata?.extras?.getString("artist") + val album = mi?.mediaMetadata?.albumTitle?.toString() + ?: mi?.mediaMetadata?.extras?.getString("album") + val extras = mi?.mediaMetadata?.extras + val coverId = extras?.getString("coverArtId") + val songLink = extras?.getString("assetLinkSong") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id")) + val albumLink = extras?.getString("assetLinkAlbum") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId")) + val artistLink = extras?.getString("assetLinkArtist") + ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId")) + val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L + val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L + WidgetUpdateManager.updateFromState( + this, + title ?: "", + artist ?: "", + album ?: "", + coverId, + player.isPlaying, + player.shuffleModeEnabled, + player.repeatMode, + position, + duration, + songLink, + albumLink, + artistLink + ) + } + + private fun scheduleWidgetUpdates() { + if (widgetUpdateScheduled) return + widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) + widgetUpdateScheduled = true + } + + private fun stopWidgetUpdates() { + if (!widgetUpdateScheduled) return + widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) + widgetUpdateScheduled = false + } + + private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean { + if (audioSessionId == 0 || audioSessionId == -1) return false + val attached = equalizerManager.attachToSession(audioSessionId) + if (attached) { + val enabled = Preferences.isEqualizerEnabled() + equalizerManager.setEnabled(enabled) + val bands = equalizerManager.getNumberOfBands() + val savedLevels = Preferences.getEqualizerBandLevels(bands) + for (i in 0 until bands) { + equalizerManager.setBandLevel(i.toShort(), savedLevels[i]) + } + sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED)) + } + return attached + } + + private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) + + private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this) + + @UnstableApi + private class CustomMediaLibrarySessionCallback : MediaLibrarySession.Callback { + private val shuffleCommands: List + private val repeatCommands: List + + constructor(ctx: Context) { + shuffleCommands = listOf( + CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, + CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF + ) + .map { getShuffleCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) } + repeatCommands = listOf( + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL + ) + .map { getRepeatCommandButton(SessionCommand(it, Bundle.EMPTY), ctx) } + } + + override fun onConnect( + session: MediaSession, + controller: ControllerInfo + ): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() + + (shuffleCommands + repeatCommands).forEach { commandButton -> + commandButton.sessionCommand?.let { availableSessionCommands.add(it) } + } + + val result = MediaSession.ConnectionResult.AcceptedResultBuilder(session) + .setAvailableSessionCommands(availableSessionCommands.build()) + .setAvailablePlayerCommands(connectionResult.availablePlayerCommands) + .setMediaButtonPreferences(buildCustomLayout(session.player)) + .build() + return result + } + + override fun onCustomCommand( + session: MediaSession, + controller: ControllerInfo, + customCommand: SessionCommand, + args: Bundle + ): ListenableFuture { + Log.d(javaClass.toString(), "onCustomCommand") + when (customCommand.customAction) { + CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true + CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> { + val nextMode = when (session.player.repeatMode) { + Player.REPEAT_MODE_ONE -> Player.REPEAT_MODE_ALL + Player.REPEAT_MODE_OFF -> Player.REPEAT_MODE_ONE + else -> Player.REPEAT_MODE_OFF + } + session.player.repeatMode = nextMode + } + } + + session.setMediaButtonPreferences(buildCustomLayout(session.player)) + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) + } + + override fun onAddMediaItems( + mediaSession: MediaSession, + controller: ControllerInfo, + mediaItems: List + ): ListenableFuture> { + Log.d(javaClass.toString(), "onAddMediaItems") + val updatedMediaItems = mediaItems.map { mediaItem -> + val mediaMetadata = mediaItem.mediaMetadata + val newMetadata = mediaMetadata.buildUpon() + .setArtist( + if (mediaMetadata.artist != null) mediaMetadata.artist + else mediaMetadata.extras?.getString("uri") ?: "" + ) + .build() + + mediaItem.buildUpon() + .setUri(mediaItem.requestMetadata.mediaUri) + .setMediaMetadata(newMetadata) + .setMimeType(MimeTypes.BASE_TYPE_AUDIO) + .build() + } + return Futures.immediateFuture(updatedMediaItems) + } + + @SuppressLint("PrivateResource") + private fun getShuffleCommandButton( + sessionCommand: SessionCommand, + ctx: Context + ): CommandButton { + val isOn = sessionCommand.customAction == CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON + return CommandButton.Builder(if (isOn) CommandButton.ICON_SHUFFLE_OFF else CommandButton.ICON_SHUFFLE_ON) + .setSessionCommand(sessionCommand) + .setDisplayName( + ctx.getString( + if (isOn) R.string.exo_controls_shuffle_on_description + else R.string.exo_controls_shuffle_off_description + ) + ) + .build() + } + + @SuppressLint("PrivateResource") + private fun getRepeatCommandButton( + sessionCommand: SessionCommand, + ctx: Context + ): CommandButton { + val icon = when (sessionCommand.customAction) { + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> CommandButton.ICON_REPEAT_ONE + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> CommandButton.ICON_REPEAT_ALL + else -> CommandButton.ICON_REPEAT_OFF + } + val description = when (sessionCommand.customAction) { + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> R.string.exo_controls_repeat_one_description + CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL -> R.string.exo_controls_repeat_all_description + else -> R.string.exo_controls_repeat_off_description + } + return CommandButton.Builder(icon) + .setSessionCommand(sessionCommand) + .setDisplayName(ctx.getString(description)) + .build() + } + + private fun buildCustomLayout(player: Player): ImmutableList { + val shuffle = shuffleCommands[if (player.shuffleModeEnabled) 1 else 0] + val repeat = when (player.repeatMode) { + Player.REPEAT_MODE_ONE -> repeatCommands[1] + Player.REPEAT_MODE_ALL -> repeatCommands[2] + else -> repeatCommands[0] + } + return ImmutableList.of(shuffle, repeat) + } + } + + private inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { + var wasWifi = false + + init { + val manager = getSystemService(ConnectivityManager::class.java) + val network = manager.activeNetwork + val capabilities = manager.getNetworkCapabilities(network) + if (capabilities != null) + wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + } + + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + if (isWifi != wasWifi) { + wasWifi = isWifi + widgetUpdateHandler.post { + updateMediaItems(mediaLibrarySession.player) + } + } + } + } + + inner class LocalBinder : Binder() { + fun getEqualizerManager(): EqualizerManager { + return equalizerManager + } + } +} + +private const val WIDGET_UPDATE_INTERVAL_MS = 1000L + 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 index 0b97d516..ac6608c7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/EqualizerFragment.kt @@ -19,6 +19,7 @@ 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.BaseMediaService import com.cappielloantonio.tempo.service.MediaService import com.cappielloantonio.tempo.util.Preferences @@ -35,7 +36,7 @@ class EqualizerFragment : Fragment() { private val equalizerUpdatedReceiver = object : BroadcastReceiver() { @OptIn(UnstableApi::class) override fun onReceive(context: Context?, intent: Intent?) { - if (intent?.action == MediaService.ACTION_EQUALIZER_UPDATED) { + if (intent?.action == BaseMediaService.ACTION_EQUALIZER_UPDATED) { initUI() restoreEqualizerPreferences() } @@ -45,7 +46,7 @@ class EqualizerFragment : Fragment() { private val connection = object : ServiceConnection { @OptIn(UnstableApi::class) override fun onServiceConnected(className: ComponentName, service: IBinder) { - val binder = service as MediaService.LocalBinder + val binder = service as BaseMediaService.LocalBinder equalizerManager = binder.getEqualizerManager() initUI() restoreEqualizerPreferences() @@ -60,14 +61,14 @@ class EqualizerFragment : Fragment() { override fun onStart() { super.onStart() Intent(requireContext(), MediaService::class.java).also { intent -> - intent.action = MediaService.ACTION_BIND_EQUALIZER + intent.action = BaseMediaService.ACTION_BIND_EQUALIZER requireActivity().bindService(intent, connection, Context.BIND_AUTO_CREATE) } if (!receiverRegistered) { ContextCompat.registerReceiver( requireContext(), equalizerUpdatedReceiver, - IntentFilter(MediaService.ACTION_EQUALIZER_UPDATED), + IntentFilter(BaseMediaService.ACTION_EQUALIZER_UPDATED), ContextCompat.RECEIVER_NOT_EXPORTED ) receiverRegistered = true diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java index 510673cb..48d727ee 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ReplayGainUtil.java @@ -1,11 +1,12 @@ package com.cappielloantonio.tempo.util; import androidx.annotation.OptIn; +import androidx.media3.common.C; import androidx.media3.common.MediaItem; import androidx.media3.common.Metadata; import androidx.media3.common.Tracks; import androidx.media3.common.util.UnstableApi; -import androidx.media3.exoplayer.ExoPlayer; +import androidx.media3.common.Player; import com.cappielloantonio.tempo.model.ReplayGain; @@ -17,7 +18,7 @@ import java.util.Objects; public class ReplayGainUtil { private static final String[] tags = {"REPLAYGAIN_TRACK_GAIN", "REPLAYGAIN_ALBUM_GAIN", "R128_TRACK_GAIN", "R128_ALBUM_GAIN"}; - public static void setReplayGain(ExoPlayer player, Tracks tracks) { + public static void setReplayGain(Player player, Tracks tracks) { List metadata = getMetadata(tracks); List gains = getReplayGains(metadata); @@ -62,7 +63,7 @@ public class ReplayGainUtil { } } - if (gains.size() == 0) gains.add(0, new ReplayGain()); + if (gains.isEmpty()) gains.add(0, new ReplayGain()); if (gains.size() == 1) gains.add(1, new ReplayGain()); return gains; @@ -108,7 +109,7 @@ public class ReplayGainUtil { } } - private static void applyReplayGain(ExoPlayer player, List gains) { + private static void applyReplayGain(Player player, List gains) { if (Objects.equals(Preferences.getReplayGainMode(), "disabled") || gains == null || gains.isEmpty()) { setNoReplayGain(player); return; @@ -137,33 +138,33 @@ public class ReplayGainUtil { setNoReplayGain(player); } - private static void setNoReplayGain(ExoPlayer player) { + private static void setNoReplayGain(Player player) { setReplayGain(player, 0f); } - private static void setTrackReplayGain(ExoPlayer player, List gains) { + private static void setTrackReplayGain(Player player, List gains) { float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain(); setReplayGain(player, trackGain != 0f ? trackGain : 0f); } - private static void setAlbumReplayGain(ExoPlayer player, List gains) { + private static void setAlbumReplayGain(Player player, List gains) { float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain(); setReplayGain(player, albumGain != 0f ? albumGain : 0f); } - private static void setAutoReplayGain(ExoPlayer player, List gains) { + private static void setAutoReplayGain(Player player, List gains) { float albumGain = gains.get(0).getAlbumGain() != 0f ? gains.get(0).getAlbumGain() : gains.get(1).getAlbumGain(); float trackGain = gains.get(0).getTrackGain() != 0f ? gains.get(0).getTrackGain() : gains.get(1).getTrackGain(); setReplayGain(player, albumGain != 0f ? albumGain : trackGain); } - private static boolean areTracksConsecutive(ExoPlayer player) { + private static boolean areTracksConsecutive(Player player) { MediaItem currentMediaItem = player.getCurrentMediaItem(); - int currentMediaItemIndex = player.getCurrentMediaItemIndex(); - MediaItem pastMediaItem = currentMediaItemIndex > 0 ? player.getMediaItemAt(currentMediaItemIndex - 1) : null; + int prevMediaItemIndex = player.getPreviousMediaItemIndex(); + MediaItem pastMediaItem = prevMediaItemIndex == C.INDEX_UNSET ? null : player.getMediaItemAt(prevMediaItemIndex); return currentMediaItem != null && pastMediaItem != null && @@ -172,7 +173,7 @@ public class ReplayGainUtil { pastMediaItem.mediaMetadata.albumTitle.toString().equals(currentMediaItem.mediaMetadata.albumTitle.toString()); } - private static void setReplayGain(ExoPlayer player, float gain) { + private static void setReplayGain(Player player, float gain) { player.setVolume((float) Math.pow(10f, gain / 20f)); } } diff --git a/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt index 52fa8c1d..784b1986 100644 --- a/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt +++ b/app/src/tempus/java/com/cappielloantonio/tempo/service/MediaService.kt @@ -1,182 +1,18 @@ package com.cappielloantonio.tempo.service -import android.app.PendingIntent.FLAG_IMMUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT -import android.app.TaskStackBuilder -import android.content.Intent -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.os.Binder -import android.os.IBinder -import android.os.Handler -import android.os.Looper -import android.util.Log import androidx.core.content.ContextCompat import androidx.media3.cast.CastPlayer import androidx.media3.cast.SessionAvailabilityListener -import androidx.media3.common.AudioAttributes -import androidx.media3.common.C -import androidx.media3.common.MediaItem -import androidx.media3.common.Player -import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi -import androidx.media3.exoplayer.DefaultLoadControl -import androidx.media3.exoplayer.ExoPlayer -import androidx.media3.session.MediaLibraryService -import androidx.media3.session.MediaSession.ControllerInfo import com.cappielloantonio.tempo.repository.AutomotiveRepository -import com.cappielloantonio.tempo.repository.QueueRepository -import com.cappielloantonio.tempo.ui.activity.MainActivity -import com.cappielloantonio.tempo.util.AssetLinkUtil -import com.cappielloantonio.tempo.util.Constants -import com.cappielloantonio.tempo.util.DownloadUtil -import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory -import com.cappielloantonio.tempo.util.MappingUtil -import com.cappielloantonio.tempo.util.Preferences -import com.cappielloantonio.tempo.util.ReplayGainUtil -import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.google.android.gms.cast.framework.CastContext import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability @UnstableApi -class MediaService : MediaLibraryService(), SessionAvailabilityListener { - private lateinit var automotiveRepository: AutomotiveRepository - private lateinit var player: ExoPlayer +class MediaService : BaseMediaService(), SessionAvailabilityListener { + private val automotiveRepository = AutomotiveRepository() private lateinit var castPlayer: CastPlayer - private lateinit var mediaLibrarySession: MediaLibrarySession - private lateinit var librarySessionCallback: MediaLibrarySessionCallback - private lateinit var networkCallback: CustomNetworkCallback - lateinit var equalizerManager: EqualizerManager - - inner class LocalBinder : Binder() { - fun getEqualizerManager(): EqualizerManager { - return this@MediaService.equalizerManager - } - } - - private val binder = LocalBinder() - - companion object { - const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER" - const val ACTION_EQUALIZER_UPDATED = "com.cappielloantonio.tempo.service.EQUALIZER_UPDATED" - } - private val widgetUpdateHandler = Handler(Looper.getMainLooper()) - private var widgetUpdateScheduled = false - private val widgetUpdateRunnable = object : Runnable { - override fun run() { - if (!player.isPlaying) { - widgetUpdateScheduled = false - return - } - updateWidget() - widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS) - } - } - - fun updateMediaItems() { - Log.d("MediaService", "update items"); - val n = player.mediaItemCount - val k = player.currentMediaItemIndex - val current = player.currentPosition - val items = (0 .. n-1).map{i -> MappingUtil.mapMediaItem(player.getMediaItemAt(i))} - player.clearMediaItems() - player.setMediaItems(items, k, current) - } - - inner class CustomNetworkCallback : ConnectivityManager.NetworkCallback() { - var wasWifi = false - - init { - val manager = getSystemService(ConnectivityManager::class.java) - val network = manager.activeNetwork - val capabilities = manager.getNetworkCapabilities(network) - if (capabilities != null) - wasWifi = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - } - - override fun onCapabilitiesChanged(network : Network, networkCapabilities : NetworkCapabilities) { - val isWifi = networkCapabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) - if (isWifi != wasWifi) { - wasWifi = isWifi - widgetUpdateHandler.post(Runnable { - updateMediaItems() - }) - } - } - } - - override fun onCreate() { - super.onCreate() - - initializeRepository() - initializePlayer() - initializeMediaLibrarySession() - restorePlayerFromQueue() - initializePlayerListener() - initializeCastPlayer() - initializeEqualizerManager() - initializeNetworkListener() - - setPlayer( - null, - if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) castPlayer else player - ) - } - - override fun onGetSession(controllerInfo: ControllerInfo): MediaLibrarySession { - return mediaLibrarySession - } - - override fun onTaskRemoved(rootIntent: Intent?) { - val player = mediaLibrarySession.player - - if (!player.playWhenReady || player.mediaItemCount == 0) { - stopSelf() - } - } - - override fun onDestroy() { - releaseNetworkCallback() - equalizerManager.release() - stopWidgetUpdates() - releasePlayer() - super.onDestroy() - } - - override fun onBind(intent: Intent?): IBinder? { - // Check if the intent is for our custom equalizer binder - if (intent?.action == ACTION_BIND_EQUALIZER) { - return binder - } - // Otherwise, handle it as a normal MediaLibraryService connection - return super.onBind(intent) - } - - private fun initializeRepository() { - automotiveRepository = AutomotiveRepository() - } - - private fun initializeEqualizerManager() { - equalizerManager = EqualizerManager() - val audioSessionId = player.audioSessionId - attachEqualizerIfPossible(audioSessionId) - } - - private fun initializePlayer() { - player = ExoPlayer.Builder(this) - .setRenderersFactory(getRenderersFactory()) - .setMediaSourceFactory(DynamicMediaSourceFactory(this)) - .setAudioAttributes(AudioAttributes.DEFAULT, true) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(C.WAKE_MODE_NETWORK) - .setLoadControl(initializeLoadControl()) - .build() - - player.shuffleModeEnabled = Preferences.isShuffleModeEnabled() - player.repeatMode = Preferences.getRepeatMode() - } @Suppress("DEPRECATION") private fun initializeCastPlayer() { @@ -184,284 +20,41 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener { .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS ) { CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this)) - .addOnSuccessListener { castContext -> - castPlayer = CastPlayer(castContext) - castPlayer.setSessionAvailabilityListener(this@MediaService) - - if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) { - setPlayer(player, castPlayer) - } - } + .addOnSuccessListener { castContext -> + castPlayer = CastPlayer(castContext) + castPlayer.setSessionAvailabilityListener(this@MediaService) + initializePlayerListener(castPlayer) + if (castPlayer.isCastSessionAvailable) + setPlayer(mediaLibrarySession.player, castPlayer) + } } } - private fun initializeMediaLibrarySession() { - val sessionActivityPendingIntent = - 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() - } - - private fun initializeNetworkListener() { - networkCallback = CustomNetworkCallback() - getSystemService(ConnectivityManager::class.java).registerDefaultNetworkCallback(networkCallback) - updateMediaItems() - } - - private fun restorePlayerFromQueue() { - if (player.mediaItemCount > 0) return - - val queueRepository = QueueRepository() - val storedQueue = queueRepository.media - if (storedQueue.isNullOrEmpty()) return - - val mediaItems = MappingUtil.mapMediaItems(storedQueue) - if (mediaItems.isEmpty()) return - - val lastIndex = try { - queueRepository.lastPlayedMediaIndex - } catch (_: Exception) { - 0 - }.coerceIn(0, mediaItems.size - 1) - - val lastPosition = try { - queueRepository.lastPlayedMediaTimestamp - } catch (_: Exception) { - 0L - }.let { if (it < 0L) 0L else it } - - player.setMediaItems(mediaItems, lastIndex, lastPosition) - player.prepare() - updateWidget() - } - - private fun createLibrarySessionCallback(): MediaLibrarySessionCallback { + override fun getMediaLibrarySessionCallback(): MediaLibrarySession.Callback { return MediaLibrarySessionCallback(this, automotiveRepository) } - private fun initializePlayerListener() { - player.addListener(object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (mediaItem == null) return + override fun playerInitHook() { + super.playerInitHook() + initializeCastPlayer() + if (this::castPlayer.isInitialized && castPlayer.isCastSessionAvailable) + setPlayer(null, castPlayer) + } - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) { - MediaManager.setLastPlayedTimestamp(mediaItem) - } - updateWidget() - } - - override fun onTracksChanged(tracks: Tracks) { - ReplayGainUtil.setReplayGain(player, tracks) - - val currentMediaItem = player.currentMediaItem - if (currentMediaItem != null && currentMediaItem.mediaMetadata.extras != null) { - MediaManager.scrobble(currentMediaItem, false) - } - - if (player.currentMediaItemIndex + 1 == player.mediaItemCount) - MediaManager.continuousPlay(player.currentMediaItem) - } - - override fun onIsPlayingChanged(isPlaying: Boolean) { - if (!isPlaying) { - MediaManager.setPlayingPausedTimestamp( - player.currentMediaItem, - player.currentPosition - ) - } else { - MediaManager.scrobble(player.currentMediaItem, false) - } - if (isPlaying) { - scheduleWidgetUpdates() - } else { - stopWidgetUpdates() - } - updateWidget() - } - - override fun onPlaybackStateChanged(playbackState: Int) { - super.onPlaybackStateChanged(playbackState) - - if (!player.hasNextMediaItem() && - playbackState == Player.STATE_ENDED && - player.mediaMetadata.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC - ) { - MediaManager.scrobble(player.currentMediaItem, true) - MediaManager.saveChronology(player.currentMediaItem) - } - updateWidget() - } - - override fun onPositionDiscontinuity( - oldPosition: Player.PositionInfo, - newPosition: Player.PositionInfo, - reason: Int - ) { - super.onPositionDiscontinuity(oldPosition, newPosition, reason) - - if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION) { - if (oldPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { - MediaManager.scrobble(oldPosition.mediaItem, true) - MediaManager.saveChronology(oldPosition.mediaItem) - } - - if (newPosition.mediaItem?.mediaMetadata?.extras?.getString("type") == Constants.MEDIA_TYPE_MUSIC) { - MediaManager.setLastPlayedTimestamp(newPosition.mediaItem) - } - } - } - - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - Preferences.setShuffleModeEnabled(shuffleModeEnabled) - } - - override fun onRepeatModeChanged(repeatMode: Int) { - Preferences.setRepeatMode(repeatMode) - } - - override fun onAudioSessionIdChanged(audioSessionId: Int) { - attachEqualizerIfPossible(audioSessionId) - } - }) - if (player.isPlaying) { - scheduleWidgetUpdates() + override fun releasePlayers() { + if (this::castPlayer.isInitialized) { + castPlayer.setSessionAvailabilityListener(null) + castPlayer.release() } - } - - private fun updateWidget() { - val mi = player.currentMediaItem - val title = mi?.mediaMetadata?.title?.toString() - ?: mi?.mediaMetadata?.extras?.getString("title") - val artist = mi?.mediaMetadata?.artist?.toString() - ?: mi?.mediaMetadata?.extras?.getString("artist") - val album = mi?.mediaMetadata?.albumTitle?.toString() - ?: mi?.mediaMetadata?.extras?.getString("album") - val extras = mi?.mediaMetadata?.extras - val coverId = extras?.getString("coverArtId") - val songLink = extras?.getString("assetLinkSong") - ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id")) - val albumLink = extras?.getString("assetLinkAlbum") - ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId")) - val artistLink = extras?.getString("assetLinkArtist") - ?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId")) - val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L - val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L - WidgetUpdateManager.updateFromState( - this, - title ?: "", - artist ?: "", - album ?: "", - coverId, - player.isPlaying, - player.shuffleModeEnabled, - player.repeatMode, - position, - duration, - songLink, - albumLink, - artistLink - ) - } - - private fun scheduleWidgetUpdates() { - if (widgetUpdateScheduled) return - widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS) - widgetUpdateScheduled = true - } - - private fun stopWidgetUpdates() { - if (!widgetUpdateScheduled) return - widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable) - widgetUpdateScheduled = false - } - - 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() - } - - private fun getQueueFromPlayer(player: Player): List { - val queue = mutableListOf() - for (i in 0 until player.mediaItemCount) { - queue.add(player.getMediaItemAt(i)) - } - return queue - } - - private fun setPlayer(oldPlayer: Player?, newPlayer: Player) { - if (oldPlayer === newPlayer) return - oldPlayer?.stop() - mediaLibrarySession.player = newPlayer - } - - private fun releasePlayer() { - if (this::castPlayer.isInitialized) castPlayer.setSessionAvailabilityListener(null) - if (this::castPlayer.isInitialized) castPlayer.release() - player.release() - mediaLibrarySession.release() automotiveRepository.deleteMetadata() + super.releasePlayers() } - private fun releaseNetworkCallback() { - getSystemService(ConnectivityManager::class.java).unregisterNetworkCallback(networkCallback) - } - - private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) - override fun onCastSessionAvailable() { - val currentQueue = getQueueFromPlayer(player) - val currentIndex = player.currentMediaItemIndex - val currentPosition = player.currentPosition - val isPlaying = player.playWhenReady - - setPlayer(player, castPlayer) - - castPlayer.setMediaItems(currentQueue, currentIndex, currentPosition) - castPlayer.playWhenReady = isPlaying - castPlayer.prepare() + setPlayer(exoplayer, castPlayer) } override fun onCastSessionUnavailable() { - val currentQueue = getQueueFromPlayer(castPlayer) - val currentIndex = castPlayer.currentMediaItemIndex - val currentPosition = castPlayer.currentPosition - val isPlaying = castPlayer.playWhenReady - - setPlayer(castPlayer, player) - - player.setMediaItems(currentQueue, currentIndex, currentPosition) - player.playWhenReady = isPlaying - player.prepare() + setPlayer(castPlayer, exoplayer) } - - private fun attachEqualizerIfPossible(audioSessionId: Int): Boolean { - if (audioSessionId == 0 || audioSessionId == -1) return false - val attached = equalizerManager.attachToSession(audioSessionId) - if (attached) { - val enabled = Preferences.isEqualizerEnabled() - equalizerManager.setEnabled(enabled) - val bands = equalizerManager.getNumberOfBands() - val savedLevels = Preferences.getEqualizerBandLevels(bands) - for (i in 0 until bands) { - equalizerManager.setBandLevel(i.toShort(), savedLevels[i]) - } - sendBroadcast(Intent(ACTION_EQUALIZER_UPDATED)) - } - return attached - } -} - -private const val WIDGET_UPDATE_INTERVAL_MS = 1000L +} \ No newline at end of file From 26d1b144e41c992a0179a11f56b27dbb29d77828 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:48:51 +0100 Subject: [PATCH 3/9] chore(i18n): Update Spanish translation --- app/src/main/res/values-es-rES/strings.xml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index cef6b661..faa0ac9a 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -177,6 +177,7 @@ Descargado Álbum Artista + Comprobar actualizaciones en GitHub Escaneo: hay %1$d pistas Soporte al usuario Resolución de la imagen @@ -185,7 +186,7 @@ Cerrar sesión https://github.com/eddyizm/tempus Siga el desarrollo - Github + GitHub Género Pista Año @@ -199,6 +200,7 @@ Artista Nombre Aleatorio + Número de álbumes Añadido recientemente Reproducido recientemente Lo más reproducido @@ -481,6 +483,7 @@ Letras descargadas para uso sin conexión Letra guardada para uso sin conexión Permitir añadir pistas repetidas a la lista + Si se usa la versión de GitHub, la app comprobará nuevas actualizaciones del APK. Participa en las discusiones y el soporte de la comunidad Mostrar el botón «Aleatorio» Descargar automáticamente las letras @@ -505,4 +508,7 @@ No se ha podido abrir el álbum No se ha podido abrir el artista No se ha podido abrir la lista de reproducción + Actualizaciones + Ordenar artistas por número de álbumes + Ordena los artistas por número de álbums si la opción está habilitada. Si no, los ordena por nombre. \ No newline at end of file From a11fbfa82953055ad6f4ec634c59815912d617a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaime=20Garc=C3=ADa?= <55400857+jaime-grj@users.noreply.github.com> Date: Sun, 16 Nov 2025 17:50:45 +0100 Subject: [PATCH 4/9] chore(i18n): Update Spanish translation --- app/src/main/res/values-es-rES/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-es-rES/strings.xml b/app/src/main/res/values-es-rES/strings.xml index faa0ac9a..cfc643e4 100644 --- a/app/src/main/res/values-es-rES/strings.xml +++ b/app/src/main/res/values-es-rES/strings.xml @@ -510,5 +510,5 @@ No se ha podido abrir la lista de reproducción Actualizaciones Ordenar artistas por número de álbumes - Ordena los artistas por número de álbums si la opción está habilitada. Si no, los ordena por nombre. + Ordena los artistas por número de álbumes si la opción está habilitada. Si no, los ordena por nombre. \ No newline at end of file From f5b381eb3570ebcc748698c06c76391001dffd1b Mon Sep 17 00:00:00 2001 From: bunz <66bunz@gmail.com> Date: Fri, 21 Nov 2025 13:36:44 +0100 Subject: [PATCH 5/9] chore(i18n): Update Italian translation --- app/src/main/res/values-it/arrays.xml | 2 +- app/src/main/res/values-it/strings.xml | 939 ++++++++++++++----------- 2 files changed, 530 insertions(+), 411 deletions(-) diff --git a/app/src/main/res/values-it/arrays.xml b/app/src/main/res/values-it/arrays.xml index e0520bf3..2a63156f 100644 --- a/app/src/main/res/values-it/arrays.xml +++ b/app/src/main/res/values-it/arrays.xml @@ -254,4 +254,4 @@ 3 4 - + \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 96a0c30c..b74e28f8 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,417 +1,536 @@ - Se hai problemi, visita https://dontkillmyapp.com. Qui trovi istruzioni dettagliate su come disabilitare le funzionalità di risparmio energetico che potrebbero influire sulle prestazioni dell\'app. - Per favore, disabilita le ottimizzazioni della batteria per la riproduzione multimediale quando lo schermo è spento. - Ottimizzazioni della Batteria - Modalità offline + Se hai problemi, visita https://dontkillmyapp.com. Qui trovi istruzioni dettagliate su come disabilitare le funzionalità di risparmio energetico che potrebbero influire sulle prestazioni dell\'app. + Disattiva le ottimizzazioni della batteria per la riproduzione multimediale quando lo schermo è spento. + Ottimizzazioni della Batteria + Modalità offline Aggiungi alla playlist Aggiungi alla coda - Scarica tutto - Vai all\'artista - Mix istantaneo - Riproduci successivo - Rimuovi tutto - Condividi - Riproduzione casuale - Album - Sfoglia Album - Errore nel recupero dell\'artista - Album scaricati - Album più riprodotti - Nuove uscite - Album aggiunti di recente - Album riprodotti di recente - Album preferiti - Album - Simili a questo - Riproduci - Rilasciato il %1$s - Rilasciato il %1$s, originariamente il %2$s - Riproduzione casuale - %1$d brani • %2$d minuti - Tempus - Ricerca in corso… - Mix istantaneo - Riproduzione casuale - Artisti - Sfoglia Artisti - Errore nel recupero della radio dell\'artista - Errore nel recupero dei brani dell\'artista - Artisti scaricati - Artisti preferiti - Artisti - Radio - Riproduzione casuale - Cambia layout - Simili a questo - Album - Altro - Biografia - Brani più ascoltati - Vedi tutto - Ignora - Non chiedere di nuovo - Disabilita - Annulla - Attiva risparmio dati - OK - L\'accesso al server Subsonic è stato limitato alle connessioni Wi-Fi. Per evitare che questo avviso riappaia, disabilita il controllo connessione nelle impostazioni dell\'app. - Wi-Fi non connesso - Riproduzione casuale - Annulla - Continua - Attenzione, procedendo questa azione eliminerà definitivamente tutti gli elementi scaricati da tutti i server. - Elimina elementi salvati - Descrizione non disponibile - Disco %1$s - %2$s - Disco %1$s - Annulla - Scarica - Tutti i brani in questa cartella verranno scaricati. I brani nelle sottocartelle non verranno scaricati. - Scarica i brani - Una volta scaricato un brano, lo troverai qui - Nessun download ancora! - %1$s • %2$s elementi - %1$s elementi - Riproduzione casuale di tutto - Per rendere effettive le modifiche, riavvia l\'app. - Cambiare la destinazione dei file scaricati da una memoria all\'altra eliminerà immediatamente tutti i file scaricati precedentemente nella vecchia memoria. - Seleziona opzione di memoria - Esterna - Interna - Download - Aggiungi alla coda - Riproduci successivo - Rimuovi - Rimuovi tutto - Riproduzione casuale - - Obbligatorio - Prefisso http o https richiesto - Download - Seleziona due o più filtri - Filtro - Filtra Generi - Catalogo dei Generi - Sfoglia Generi - Ricordamelo più tardi - Supportami - Scarica ora - È disponibile una nuova versione dell\'app su Github. - Aggiornamento disponibile - Annulla - Reimposta - Salva - Riorganizza home - Si prega di notare che per rendere effettive le modifiche è necessario riavviare l\'applicazione. - Le migliori canzoni dei tuoi artisti preferiti - Inizia un mix da una canzone che ti è piaciuta - Aggiungi una nuova radio - Aggiungi un nuovo canale podcast - Annulla - Scarica - Scaricare questi brani potrebbe comportare un uso significativo di dati - Sembra che ci siano brani da sincronizzare con una stella - Il meglio di - Scoperta - Mescola tutto - Flashback - Stazioni radio internet - Ultimi ascolti - Vedi tutto - La scorsa settimana - Il mese scorso - L\'anno scorso - Fatto per te - Più ascoltati - Vedi tutto - Nuove uscite - Podcast più recenti - Playlist - Canali - Vedi tutto - Stazioni radio - Aggiunti di recente - Vedi tutto - Condivisioni - ★ Album con stella - Vedi tutto - ★ Artisti con stella - Vedi tutto - ★ Brani con stella - Vedi tutto - I tuoi migliori brani - Riorganizza - - -- - Album - Vedi tutto - Artisti - Vedi tutto - Generi - Vedi tutto - Cartelle musicali - Playlist - Vedi tutto - Nessun server aggiunto - Server Subsonic - Server Subsonic - Trasmetti - Aggiungi + Scarica tutto + Vai all\'artista + Mix istantaneo + Riproduci dopo + Rimuovi tutto + Condividi + Riproduzione casuale + Album + Sfoglia Album + Errore nel recupero dell\'artista + Album scaricati + Album più riprodotti + Nuove uscite + Album aggiunti di recente + Album riprodotti di recente + Album preferiti + Album + Altri simili + Riproduci + Rilasciato il %1$s + Rilasciato il %1$s, originariamente il %2$s + Riproduzione casuale + %1$d brani • %2$d minuti + Tempus + Cercando… + Mix istantaneo + Riproduzione casuale + Artisti + Sfoglia Artisti + Errore nel recupero della radio dell\'artista + Errore nel recupero dei brani dell\'artista + Artisti scaricati + Artisti preferiti + Artisti + Radio + Riproduzione casuale + Cambia layout + Altri simili + Album + Altro + Biografia + Brani più ascoltati + Vedi tutto + Ignora + Non chiedere di nuovo + Disabilita + Annulla + Attiva risparmio dati + OK + L\'accesso al server Subsonic è stato limitato alle connessioni Wi-Fi. Per evitare che questo avviso riappaia, disabilita il controllo connessione nelle impostazioni dell\'app. + Wi-Fi non connesso + Riproduzione casuale + Annulla + Continua + Attenzione, procedendo questa azione eliminerà definitivamente tutti gli elementi scaricati da tutti i server. + Elimina elementi salvati + Descrizione non disponibile + Disco %1$s - %2$s + Disco %1$s + Annulla + Scarica + Tutti i brani in questa cartella verranno scaricati. I brani nelle sottocartelle non verranno scaricati. + Scarica i brani + Imposta dove scaricare la musica + Una volta scaricato un brano, lo troverai qui + Ancora nessun download! + %1$s • %2$s elementi + %1$s elementi + Riproduzione casuale di tutto + Per rendere effettive le modifiche, riavvia l\'app. + Cambiare la destinazione dei file scaricati da una memoria all\'altra eliminerà immediatamente tutti i file scaricati precedentemente nella vecchia memoria. + Seleziona opzione di memoria + Esterna + Interna + Cartella + Scarica + Imposta una cartella di download per aggiornare i tuoi download. + Nessun download mancante trovato. + + Rimosso %d download mancante. + Rimossi %d download mancanti. + + Aggiorna gli elementi scaricati + Aggiungi alla coda + Riproduci dopo + Rimuovi + Rimuovi tutto + Riproduzione casuale + + Obbligatorio + Prefisso http o https richiesto + Download + Aggiungi ai preferiti + Rimuovi dai preferiti + Caricamento… + Seleziona due o più filtri + Filtro + Filtra artisti + Filtra Generi + (%1$d) + (+%1$d) + Catalogo dei Generi + Sfoglia Generi + Ricordamelo più tardi + Supportami + Scarica ora + È disponibile una nuova versione dell\'app su Github. + Aggiornamento disponibile + Annulla + Ripristina + Salva + Riorganizza home + Per rendere effettive le modifiche è necessario riavviare l\'applicazione. + Musica + Podcast + Radio + Le migliori canzoni dei tuoi artisti preferiti + Inizia un mix da una canzone che ti è piaciuta + Aggiungi una nuova radio + Aggiungi un nuovo canale podcast + Annulla + Scarica + Scaricare questi brani potrebbe comportare un uso significativo di dati + Sembra che ci siano alcuni brani preferiti da sincronizzare + Sincronizza Album Preferiti + Gli album preferiti saranno disponibili offline + Sincronizza Artisti Preferiti + Hai artisti preferiti con musica non scaricata + Il meglio di + Scopri + Mescola tutto + Flashback + Stazioni internet-radio + Ultimi ascolti + Vedi tutto + La scorsa settimana + Il mese scorso + L\'anno scorso + Fatto per te + Più ascoltati + Vedi tutto + Nuove uscite + Podcast più recenti + Playlist + Canali + Vedi tutto + Stazioni radio + Aggiunti di recente + Vedi tutto + Condivisioni + ★ Album preferiti + Vedi tutto + ★ Artisti preferiti + Vedi tutto + ★ Brani preferiti + Vedi tutto + I tuoi migliori brani + Riorganizza + + -- + Album + Vedi tutto + Artisti + Vedi tutto + Generi + Vedi tutto + Cartelle della musica + Playlist + Vedi tutto + Nessun server aggiunto + Server Subsonic + Server Subsonic + Trasmetti + Aggiungi Aggiungi alla playlist Scarica tutto - Scarica - Tutti - Scaricati - Album - Artista - Genere - Brano - Anno - Home - La scorsa settimana - Il mese scorso - L\'anno scorso - Libreria - Cerca - Impostazioni - Artista - Nome - Casuale - Aggiunti di recente - Aggiungi alla schermata home - Rimuovi dalla schermata home - Anno - %1$.2fx - Svuota coda di riproduzione + Valuta l\'album + Scarica + Tutti + Scaricati + Album + Artista + Genere + Brano + Anno + Home + La scorsa settimana + Il mese scorso + L\'anno scorso + Libreria + Cerca + Impostazioni + Artista + Nome + Casuale + Numero di Album + Aggiunti di recente + Riprodotti di recente + Più riprodotti + Preferiti più recentemente + Preferiti meno recentemente + Aggiungi alla schermata home + Rimuovi dalla schermata home + Anno + %1$.2fx + Svuota coda di riproduzione Salvato - Priorità server - Catalogo playlist - Sfoglia le playlist - Nessuna playlist creata - Annulla - Crea - Aggiungi a una playlist - Aggiunta di un brano alla playlist - Impossibile aggiungere un brano alla playlist - %1$d brani • %2$s - Durata • %1$s - Premi a lungo per eliminare - Nome della playlist - Annulla - Elimina - Salva - Modifica playlist - Riproduci - Mescola - Playlist • %1$d brani - Aggiungi alla coda - Elimina - Scarica - Vai al canale - Riproduci dopo - Rimuovi - Canali - Sfoglia Canali - URL RSS - Canale Podcast - Descrizione - Episodi - Nessun episodio disponibile - La tua richiesta è stata inviata al server - Clicca per nascondere la sezione\nGli effetti saranno visibili al riavvio - Una volta aggiunto un canale, lo troverai qui - Nessun podcast trovato! - %1$s • %2$s - URL Homepage Radio - Nome Radio - URL Stream Radio - Annulla - Elimina - Salva - Stazione Radio Internet - Clicca per nascondere la sezione\nGli effetti saranno visibili al riavvio - Una volta aggiunta una stazione radio, la troverai qui - Nessuna stazione trovata! - Annulla - Salva - Valuta - Cerca titolo, artisti o album - Inserisci almeno tre caratteri - Album - Artisti - Brani - Bassa sicurezza - Premi a lungo per eliminare - URL locale - Nome Server - Password - URL Server - Nome utente - Annulla - Elimina - Salva - Aggiungi server - Annulla - Vai al login - Continua comunque - Il server richiesto non è disponibile. Se scegli di continuare, questo messaggio non apparirà per la prossima ora. - Server irraggiungibile - Tempus è un client musicale open source e leggero per Subsonic, progettato e costruito nativamente per Android. - Informazioni - Sempre attivo - Formato transcodifica - Se abilitato, Tempus non forzerà il download del brano con le impostazioni di transcodifica sottostanti. - Dare priorità alle impostazioni del server per lo streaming nei download - Se abilitato, Tempus scaricherà i brani transcodificati. - Scarica brani transcodificati - Se abilitato, verrà richiesto al server di fornire la durata stimata del brano. - Stima della lunghezza del contenuto - Formato transcodifica per download - Formato transcodifica su mobile - Formato transcodifica su Wi-Fi - Se abilitato, Tempus non forzerà lo streaming del brano con le impostazioni di transcodifica sottostanti. - Dare priorità alle impostazioni di transcodifica del server - Priorità di transcodifica del brano assegnata al server - Strategia di buffering - Perché la modifica abbia effetto è necessario riavviare manualmente l\'app. - Consente alla musica di continuare a suonare dopo la fine di una playlist, riproducendo brani simili - Riproduzione continua - Dimensione della cache delle copertine - Per ridurre il consumo di dati, evita di scaricare le copertine. - Limita utilizzo dei dati mobili - Continuando, tutti gli elementi salvati verranno eliminati in modo irreversibile. - Elimina elementi salvati - Archivio download - Regola le impostazioni audio - Equalizzatore di sistema - https://github.com/eddyizm/tempus - Segui lo sviluppo - Github - Imposta risoluzione delle immagini - Lingua - Esci - Bitrate per download - Bitrate su mobile - Bitrate su Wi-Fi - Dimensione della cache dei file multimediali - Mostra directory musicali - Se abilitato, mostra la sezione delle directory musicali. Nota che per la navigazione nelle cartelle è necessario che il server supporti questa funzionalità. - Mostra podcast - Se abilitato, mostra la sezione podcast. Riavvia l\'app per rendere effettive le modifiche. - Mostra qualità audio - Il bitrate e il formato audio saranno mostrati per ogni traccia. - Mostra valutazione - Se abilitato, verrà mostrata la valutazione dell\'elemento e se è contrassegnato come preferito. - Timer sincronizzazione - Se abilitato, l\'utente avrà la possibilità di salvare la propria coda di riproduzione e potrà caricare lo stato all\'apertura dell\'applicazione. - Sincronizza coda di riproduzione per questo utente - Mostra radio - Se abilitato, mostra la sezione radio. Riavvia l\'app per applicare completamente le modifiche. - Imposta modalità di guadagno di riproduzione - Angoli arrotondati - Dimensione angoli - Imposta la magnitudine dell\'angolo di curvatura. - Se abilitato, imposta un angolo di curvatura per tutte le copertine visualizzate. Le modifiche avranno effetto al riavvio. - Scansiona libreria - Abilita scrobbling musicale - Abilita condivisione musicale - Dimensione cache streaming - Archiviazione cache streaming - È importante notare che lo scrobbling si basa anche sul fatto che il server sia abilitato a ricevere questi dati. - Quando si ascolta la radio di un artista, un mix istantaneo o quando si mescolano tutti i brani, i brani sotto una certa valutazione dell\'utente verranno ignorati. - Il guadagno di riproduzione è una funzionalità che consente di regolare il livello del volume delle tracce audio per un\'esperienza di ascolto coerente. Questa impostazione è efficace solo se la traccia contiene i metadati necessari. - Lo scrobbling è una funzionalità che consente al tuo dispositivo di inviare informazioni sulle canzoni che ascolti al server musicale. Queste informazioni aiutano a creare raccomandazioni personalizzate in base alle tue preferenze musicali. - Permette all\'utente di condividere musica tramite un link. La funzionalità deve essere supportata e abilitata sul server ed è limitata a brani, album e playlist singoli. - Restituisce lo stato della coda di riproduzione per questo utente. Ciò include i brani nella coda di riproduzione, il brano attualmente in riproduzione e la posizione all\'interno di questo brano. Il server deve supportare questa funzionalità. - %1$s \nAttualmente in uso: %2$s MiB - Priorità data alla modalità di transcoding. Se impostato su "Riproduzione diretta", il bitrate del file non verrà modificato. - Scarica media transcodificati. Se abilitato, l\'endpoint di download non verrà utilizzato, ma le impostazioni seguenti. \n\n Se "Formato di transcodifica per i download" è impostato su "Download diretto", il bitrate del file non verrà modificato. - Quando il file viene transcodificato al volo, il client di solito non mostra la lunghezza della traccia. È possibile richiedere ai server che supportano la funzionalità di stimare la durata della traccia in riproduzione, ma i tempi di risposta possono essere più lunghi. - Se abilitato, le tracce contrassegnate verranno scaricate per l\'uso offline. - Sincronizza tracce contrassegnate per uso offline - Tema - Dati - Generale - Valutazione - Guadagno di riproduzione - Scrobble - Ignora brani in base alla valutazione - Brani con una valutazione di: - Condividi - Sincronizzazione - Transcoding - Download di Transcoding - Interfaccia utente - Download transcodificato - 3.1.0 - Versione - Chiedi conferma all\'utente prima di effettuare streaming su rete mobile. - Streaming solo tramite Wi-Fi avviso - Copia link - Elimina condivisione - Aggiorna condivisione - Data di scadenza: %1$s - La condivisione non è supportata o non è abilitata - Descrizione - Data di scadenza - Annulla - Salva - Condividi - Aggiungi alla playlist - Aggiungi alla coda - Scarica - Errore nel recupero dell\'album - Errore nel recupero dell\'artista - Vai all\'album - Vai all\'artista - Mix istantaneo - Riproduci dopo - Valuta - Rimuovi - Condividi - Scaricato - Tracce più riprodotte - Tracce aggiunte di recente - Tracce riprodotte di recente - Tracce contrassegnate - Le migliori tracce di %1$s - Anno %1$d - %1$s • %2$s %3$s - Annulla - Continua - Continua e scarica - Il download delle tracce contrassegnate potrebbe richiedere una grande quantità di dati. - Sincronizza tracce contrassegnate - Per rendere effettive le modifiche, riavvia l\'app. - Cambiare la destinazione dei file memorizzati nella cache da un\'unità di archiviazione a un\'altra può comportare la cancellazione di eventuali file memorizzati nella cache in precedenza nell\'altra unità di archiviazione. - Seleziona opzione di archiviazione - Esterno - Interno - https://buymeacoffee.com/a.cappiello - Album - Artista - Bitrate - Tipo di contenuto - OK - Info traccia - Numero del disco - Durata - Genere - Percorso - Dimensione - Suffisso - Il file è stato scaricato utilizzando le API Subsonic. Il codec e il bitrate del file rimangono invariati rispetto al file sorgente. - L\'applicazione richiederà al server di transcodedare il file e modificare il suo bitrate. Il codec richiesto dall\'utente è %1$s, con un bitrate di %2$s. Eventuali modifiche al codec e al bitrate del file nel formato scelto saranno gestite dal server, che potrebbe o meno supportare l\'operazione. - L\'applicazione leggerà solo il file originale fornito dal server. L\'app richiederà esplicitamente al server il file non transcodedato con il bitrate della sorgente originale. - La qualità del file da riprodurre è lasciata alla decisione del server. L\'app non imporrà la scelta di codec e bitrate per eventuali transcoding. - L\'applicazione richiederà al server di modificare il bitrate del file. L\'utente ha richiesto un bitrate di %1$s, mentre il codec del file sorgente rimarrà lo stesso. Eventuali modifiche al bitrate del file nel formato scelto saranno effettuate dal server, che potrebbe o meno supportare l\'operazione. - L\'applicazione richiederà al server di transcodedare il file. Il codec richiesto dall\'utente è %1$s, mentre il bitrate sarà lo stesso del file sorgente. L\'eventuale transcoding del file nel formato scelto dipende dal server, in quanto potrebbe o meno supportare l\'operazione. - Titolo - Numero traccia - Tipo di contenuto transcodedato - Suffisso transcodedato - Anno - unDraw - Un ringraziamento speciale va a unDraw, senza le cui illustrazioni non avremmo potuto rendere questa applicazione più bella. - https://undraw.co/ + Scarica i testi delle canzoni per riprodurli offline + Testi scaricati per la riproduzione offline + Testi salvati per la riproduzione offline. + I testi non sono disponibili per il download. + Priorità server + Formato sconosciuto + Transcodifica + richiesto + Catalogo playlist + Sfoglia le playlist + Nessuna playlist creata + Annulla + Crea + Aggiungi a una playlist + Aggiunta di un brano alla playlist + Impossibile aggiungere un brano alla playlist + Tutte le canzoni sono state saltate perché duplicate + %1$d brani • %2$s + Durata • %1$s + Premi a lungo per eliminare + Nome della playlist + Annulla + Elimina + Salva + Modifica playlist + Riproduci + Mescola + Playlist • %1$d brani + Aggiungi alla coda + Elimina + Scarica + Vai al canale + Riproduci dopo + Rimuovi + Canali + Sfoglia Canali + URL RSS + Canale Podcast + Descrizione + Episodi + Nessun episodio disponibile + La tua richiesta è stata inviata al server + Clicca per nascondere la sezione\nGli effetti saranno visibili al riavvio + Una volta aggiunto un canale, lo troverai qui + Nessun podcast trovato! + %1$s • %2$s + URL Homepage Radio + Nome Radio + URL Stream Radio + Annulla + Elimina + Salva + Stazione Internet-Radio + Clicca per nascondere la sezione\nGli effetti saranno visibili al riavvio + Una volta aggiunta una stazione radio, la troverai qui + Nessuna stazione trovata! + Annulla + Salva + Valuta + Cerca titolo, artisti o album + Inserisci almeno tre caratteri + Album + Artisti + Brani + Bassa sicurezza + Premi a lungo per eliminare + URL locale + Nome Server + Password + URL Server + Nome utente + Annulla + Elimina + Salva + Aggiungi server + Annulla + Vai al login + Continua comunque + Il server richiesto non è disponibile. Se scegli di continuare, questo messaggio non apparirà per la prossima ora. + Server irraggiungibile + Tempus è un client musicale open source e leggero per Subsonic, progettato e costruito nativamente per Android. + Informazioni + Sempre attivo + Allow adding duplicates to playlist + If enabled, duplicates won\'t be checked while adding to a playlist. + Formato transcodifica + Se abilitato, Tempus non forzerà il download del brano con le impostazioni di transcodifica sottostanti. + Dare priorità alle impostazioni del server per lo streaming nei download + Se abilitato, Tempus scaricherà i brani transcodificati. + Scarica brani transcodificati + Se abilitato, verrà richiesto al server di fornire la durata stimata del brano. + Stima della lunghezza del contenuto + Formato transcodifica per download + Formato transcodifica su mobile + Formato transcodifica su Wi-Fi + Se abilitato, Tempus non forzerà lo streaming del brano con le impostazioni di transcodifica sottostanti. + Dare priorità alle impostazioni di transcodifica del server + Priorità di transcodifica del brano assegnata al server + Strategia di buffering + Perché la modifica abbia effetto è necessario riavviare manualmente l\'app. + Scegli una cartella dove scaricare la musica + Svuota la cartella di download + Consente alla musica di continuare a suonare dopo la fine di una playlist, riproducendo brani simili + Riproduzione continua + Dimensione della cache delle copertine + Per ridurre il consumo di dati, evita di scaricare le copertine. + Limita utilizzo dei dati mobili + Continuando, tutti gli elementi salvati verranno eliminati in modo irreversibile. + Elimina elementi salvati + Archivio download + Cartella di download svuotata. + Cartella di download impostata + Imposta cartella di download + Regola le impostazioni audio + Equalizzatore di sistema + https://github.com/eddyizm/tempus + Segui lo sviluppo + Github + https://github.com/eddyizm/tempus/discussions + Aggiornamenti + Controlla GitHub per aggiornamenti + Se si utilizza la versione GitHub, per impostazione predefinita l\'app controllerà la presenza di nuove versioni. Disattiva per disabilitare i controlli automatici su GitHub + Partecipa alle discussioni della community e al supporto + Supporto utenti + Scansione: conteggio di %1$d brani + Imposta risoluzione delle immagini + Lingua + Esci + Bitrate per download + Bitrate su mobile + Bitrate su Wi-Fi + Dimensione della cache dei file multimediali + Mostra directory musicali + Se abilitato, mostra la sezione delle directory musicali. Nota che per la navigazione nelle cartelle è necessario che il server supporti questa funzionalità. + Mostra podcast + Se abilitato, mostra la sezione podcast. Riavvia l\'app per rendere effettive le modifiche. + Mostra qualità audio + Il bitrate e il formato audio saranno mostrati per ogni traccia. + Mostra valutazione della canzone + Se abilitato, mostra la valutazione a 5 stelle per la traccia nella pagina della canzone\n\n*Richiede il riavvio dell'app + Mostra valutazione + Se abilitato, verrà mostrata la valutazione dell\'elemento e se è contrassegnato come preferito. + Timer sincronizzazione + Se abilitato, l\'utente avrà la possibilità di salvare la propria coda di riproduzione e potrà caricare lo stato all\'apertura dell\'applicazione. + Sincronizza coda di riproduzione per questo utente [Not Fully Baked] + Mostra il pulsante di riproduzione casuale + Se abilitato, mostra il pulsante di riproduzione casuale, rimuove il cuore nel mini player + Mostra radio + Se abilitato, mostra la sezione radio. Riavvia l\'app per applicare completamente le modifiche. + Scarica automaticamente i testi + Salva automaticamente i testi quando sono disponibili in modo che possano essere mostrati offline. + Imposta modalità di guadagno di riproduzione + Angoli arrotondati + Dimensione angoli + Imposta la grandezza dell\'angolo di curvatura. + Se abilitato, imposta un angolo di curvatura per tutte le copertine visualizzate. Le modifiche avranno effetto al riavvio. + Scansiona libreria + Abilita scrobbling musicale + Lingua di sistema + Abilita condivisione musicale + Dimensione cache streaming + Archiviazione cache streaming + È importante notare che lo scrobbling si basa anche sul fatto che il server sia abilitato a ricevere questi dati. + Quando si ascolta la radio di un artista, un mix istantaneo o quando si mescolano tutti i brani, i brani sotto una certa valutazione dell\'utente verranno ignorati. + Il guadagno di riproduzione è una funzionalità che consente di regolare il livello del volume delle tracce audio per un\'esperienza di ascolto coerente. Questa impostazione è efficace solo se la traccia contiene i metadati necessari. + Lo scrobbling è una funzionalità che consente al tuo dispositivo di inviare informazioni sulle canzoni che ascolti al server musicale. Queste informazioni aiutano a creare raccomandazioni personalizzate in base alle tue preferenze musicali. + Permette all\'utente di condividere musica tramite un link. La funzionalità deve essere supportata e abilitata sul server ed è limitata a brani, album e playlist singoli. + Restituisce lo stato della coda di riproduzione per questo utente. Ciò include i brani nella coda di riproduzione, il brano attualmente in riproduzione e la posizione all\'interno di questo brano. Il server deve supportare questa funzionalità.\n*This setting is not 100% working on all servers/devices. + %1$s \nAttualmente in uso: %2$s MiB + Priorità data alla modalità di transcoding. Se impostato su "Riproduzione diretta", il bitrate del file non verrà modificato. + Scarica media transcodificati. Se abilitato, l\'endpoint di download non verrà utilizzato, ma le impostazioni seguenti. \n\n Se "Formato di transcodifica per i download" è impostato su "Download diretto", il bitrate del file non verrà modificato. + Quando il file viene transcodificato al volo, il client di solito non mostra la lunghezza della traccia. È possibile richiedere ai server che supportano la funzionalità di stimare la durata della traccia in riproduzione, ma i tempi di risposta possono essere più lunghi. + Se abilitato, gli artisti preferiti verranno scaricati per l\'uso offline. + Sincronizza artisti preferiti per uso offline + Se abilitato, gli album preferiti verranno scaricati per l\'uso offline. + Sincronizza album preferiti per uso offline + Se abilitato, le tracce preferite verranno scaricate per l\'uso offline. + Sincronizza tracce preferite per uso offline + Tema + Dati + Generale + Playlist + Valutazione + Guadagno di riproduzione + Scrobble + Ignora brani in base alla valutazione + Brani con una valutazione di: + Condividi + Sincronizzazione + Transcodifica + Transcodifica dei Download + Interfaccia utente + Download transcodificato + 3.1.0 + Versione + Chiedi conferma all\'utente prima di effettuare streaming su rete mobile. + Streaming solo tramite Wi-Fi avviso + Copia link + Elimina condivisione + Aggiorna condivisione + Data di scadenza: %1$s + Mai + La condivisione non è supportata o non è abilitata + Link asset Tempus + UID Canzone + UID Album + UID Artista + UID Playlist + UID Genere + UID Anno + UID Asset + Link asset non supportato + Impossibile aprire la canzone + Impossibile aprire l\'album + Impossibile aprire l\'artista + Impossibile aprire la playlist + %1$s • %2$s + Copiato %1$s negli appunti + Link asset: %1$s + Descrizione + Data di scadenza + Annulla + Salva + Condividi + Aggiungi alla playlist + Aggiungi alla coda + Scarica + Errore nel recupero dell\'album + Errore nel recupero dell\'artista + Vai all\'album + Vai all\'artista + Mix istantaneo + Riproduci dopo + Valuta + Rimuovi + Condividi + Scaricato + Tracce più riprodotte + Tracce aggiunte di recente + Tracce riprodotte di recente + Tracce contrassegnate + Le migliori tracce di %1$s + Anno %1$d + %1$s • %2$s %3$s + Annulla + Continua + Continua e scarica + Il download delle tracce contrassegnate potrebbe richiedere una grande quantità di dati. + Sincronizza tracce contrassegnate + Scaricare gli artisti preferiti potrebbe richiedere una grande quantità di dati. + Sincronizza artisti preferiti + Scaricare gli album preferiti potrebbe richiedere una grande quantità di dati. + Sincronizza album preferiti + Per rendere effettive le modifiche, riavvia l\'app. + Cambiare la destinazione dei file memorizzati nella cache da un\'unità di archiviazione a un\'altra può comportare la cancellazione di eventuali file memorizzati nella cache in precedenza nell\'altra unità di archiviazione. + Seleziona opzione di archiviazione + Esterno + Interno + https://ko-fi.com/eddyizm + Album + Artista + Profondità bit + Bitrate + Tipo di contenuto + OK + Info traccia + Numero del disco + Durata + Genere + Percorso + Frequenza di campionamento + Dimensione + Suffisso + Il file è stato scaricato utilizzando le API Subsonic. Il codec e il bitrate del file rimangono invariati rispetto al file sorgente. + L\'applicazione richiederà al server di transcodedare il file e modificare il suo bitrate. Il codec richiesto dall\'utente è %1$s, con un bitrate di %2$s. Eventuali modifiche al codec e al bitrate del file nel formato scelto saranno gestite dal server, che potrebbe o meno supportare l\'operazione. + L\'applicazione leggerà solo il file originale fornito dal server. L\'app richiederà esplicitamente al server il file non transcodedato con il bitrate della sorgente originale. + La qualità del file da riprodurre è lasciata alla decisione del server. L\'app non imporrà la scelta di codec e bitrate per eventuali transcoding. + L\'applicazione richiederà al server di modificare il bitrate del file. L\'utente ha richiesto un bitrate di %1$s, mentre il codec del file sorgente rimarrà lo stesso. Eventuali modifiche al bitrate del file nel formato scelto saranno effettuate dal server, che potrebbe o meno supportare l\'operazione. + L\'applicazione richiederà al server di transcodedare il file. Il codec richiesto dall\'utente è %1$s, mentre il bitrate sarà lo stesso del file sorgente. L\'eventuale transcoding del file nel formato scelto dipende dal server, in quanto potrebbe o meno supportare l\'operazione. + Titolo + Numero traccia + Tipo di contenuto transcodificato + Suffisso transcodificato + Anno + unDraw + Un ringraziamento speciale va a unDraw, senza le cui illustrazioni non avremmo potuto rendere questa applicazione più bella. + https://undraw.co/ + Widget Tempus + Non in riproduzione + Apri Tempus + 0:00 + 0:00 + Immagine dell\'album + Riproduci o metti in pausa + Traccia successiva + Traccia precedente + Attiva/disattiva riproduzione casuale + Cambia modalità di ripetizione + + %d album da sincronizzare + %d album da sincronizzare + + + %d artista da sincronizzare + %d artisti da sincronizzare + + + Scaricando %d canzone + Scaricando %d canzoni + + Equalizzatore + Reimposta + Abilita + Non supportato su questo dispositivo + Equalizzatore + Apri l\'equalizzatore integrato + + Mostra dettagli album + Se abilitato, mostra i dettagli dell\'album come genere, numero di canzoni, ecc. nella pagina dell\'album + Ordina artisti per numero di album + Se abilitato, ordina gli artisti per numero di album. Ordina per nome se disabilitato. From 31219ea754a2b64202056d9e0d62b56b56f596c5 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sat, 22 Nov 2025 09:07:49 -0800 Subject: [PATCH 6/9] fix: it strings needed an single quote escape --- app/src/main/res/values-it/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b74e28f8..ac0a4086 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -351,7 +351,7 @@ Mostra qualità audio Il bitrate e il formato audio saranno mostrati per ogni traccia. Mostra valutazione della canzone - Se abilitato, mostra la valutazione a 5 stelle per la traccia nella pagina della canzone\n\n*Richiede il riavvio dell'app + Se abilitato, mostra la valutazione a 5 stelle per la traccia nella pagina della canzone\n\n*Richiede il riavvio dell\'app Mostra valutazione Se abilitato, verrà mostrata la valutazione dell\'elemento e se è contrassegnato come preferito. Timer sincronizzazione From 72d560e4eb0eed477c372acea2ab6200b3d9ce35 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sat, 22 Nov 2025 09:22:13 -0800 Subject: [PATCH 7/9] chore: updated changelog, bumped version for release --- CHANGELOG.md | 16 ++++++++++++++-- app/build.gradle | 4 ++-- fastlane/metadata/android/en-US/changelogs/6.txt | 10 +++++----- fastlane/metadata/android/en-US/changelogs/7.txt | 5 +++++ 4 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/7.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8965ea8e..803cdccb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,20 @@ # Changelog ## Pending release... -* feat: Add Catalan i18n (#268) -* Fix player queue soft-lock (#266) + +## [4.2.6](https://github.com/eddyizm/tempo/releases/tag/v4.2.6) (2025-11-22) +## What's Changed +* fix: Fix player queue soft-lock by @shrapnelnet in https://github.com/eddyizm/tempus/pull/266 +* chore: Add Catalan i18n by @marcriera in https://github.com/eddyizm/tempus/pull/268 +* chore: Refactor MediaService by @pca006132 in https://github.com/eddyizm/tempus/pull/267 +* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempus/pull/272 +* chore(i18n): Update Italian translation by @66Bunz in https://github.com/eddyizm/tempus/pull/278 + +## New Contributors +* @marcriera made their first contribution in https://github.com/eddyizm/tempus/pull/268 +* @66Bunz made their first contribution in https://github.com/eddyizm/tempus/pull/278 + +**Full Changelog**: https://github.com/eddyizm/tempus/compare/v4.2.4...v4.2.6 ## [4.2.4](https://github.com/eddyizm/tempo/releases/tag/v4.2.4) (2025-11-15) ## What's Changed diff --git a/app/build.gradle b/app/build.gradle index 3f3f5771..4c6b8e45 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 6 - versionName '4.2.4' + versionCode 7 + versionName '4.2.6' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { diff --git a/fastlane/metadata/android/en-US/changelogs/6.txt b/fastlane/metadata/android/en-US/changelogs/6.txt index 40098bea..90aaa56c 100644 --- a/fastlane/metadata/android/en-US/changelogs/6.txt +++ b/fastlane/metadata/android/en-US/changelogs/6.txt @@ -1,5 +1,5 @@ -Update russian strings.xml by @Sevinfolds in https://github.com/eddyizm/tempus/pull/249 -Disallow duplicate songs in queue by @eddyizm in https://github.com/eddyizm/tempus/pull/252 -Fixed crash when viewing share by @drakeerv in https://github.com/eddyizm/tempus/pull/255 -Update Polish translation by @skajmer in https://github.com/eddyizm/tempus/pull/257 -Add podcast channel visible when empty podcasts by @eddyizm in https://github.com/eddyizm/tempus/pull/260 +Update russian strings.xml +Disallow duplicate songs in queue +Fixed crash when viewing share +Update Polish translation +Add podcast channel visible when empty podcasts diff --git a/fastlane/metadata/android/en-US/changelogs/7.txt b/fastlane/metadata/android/en-US/changelogs/7.txt new file mode 100644 index 00000000..9b19077a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/7.txt @@ -0,0 +1,5 @@ +* fix: Fix player queue soft-lock +* chore: Add Catalan i18n +* chore: Refactor MediaService +* chore(i18n): Update Spanish translation +* chore(i18n): Update Italian translation From 8aaa6b207e0eeec96d31247b9d5b201f322314d4 Mon Sep 17 00:00:00 2001 From: eddyizm Date: Sat, 22 Nov 2025 09:30:01 -0800 Subject: [PATCH 8/9] chore: added reproducible build badge --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 874a718e..5b5aba1e 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@
- +[RB Status](https://shields.rbtlog.dev/com.eddyizm.degoogled.tempus)
From eb089847e08649a976003fde6ca3758f43c98497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikael=20D=C3=BAi=20Bolinder?= <3706841+mikaeldui@users.noreply.github.com> Date: Sat, 22 Nov 2025 21:29:04 +0200 Subject: [PATCH 9/9] Add Obtainium badge to README --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 5b5aba1e..a84b03e7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@

+