feat: implement station navigation and update to SDK 37

- Implement `seekToNext` and `seekToPrevious` in `PlayerService` to allow switching between radio stations.
- Update `compileSdk` and `targetSdk` to 37 and bump version to 14.6.
- Modify `PlayerService` to remain active while paused to improve playback resumption.
- Modernize media resumption logic using Media3 `isRecent` check instead of legacy extras.
- Refactor `DefaultRenderersFactory` to use non-nullable `AudioSink` return types.
- General code cleanup using KTX extensions (e.g., `View.isEmpty()`) and Kotlin property access syntax.
This commit is contained in:
2026-05-07 13:40:35 +02:00
parent 0faeea7631
commit 7d6b0fe136
7 changed files with 53 additions and 26 deletions
+4 -4
View File
@@ -5,14 +5,14 @@ plugins {
android { android {
namespace = "com.michatec.radio" namespace = "com.michatec.radio"
compileSdk = 36 compileSdk = 37
defaultConfig { defaultConfig {
applicationId = "com.michatec.radio" applicationId = "com.michatec.radio"
minSdk = 28 minSdk = 28
targetSdk = 36 targetSdk = 37
versionCode = 145 versionCode = 146
versionName = "14.5" versionName = "14.6"
} }
compileOptions { compileOptions {
@@ -120,7 +120,7 @@ class AddStationFragment : Fragment(),
if (searchEditText != null) { if (searchEditText != null) {
searchEditText.requestFocus() searchEditText.requestFocus()
val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager val imm = requireContext().getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
imm.showSoftInput(searchEditText, InputMethodManager.SHOW_IMPLICIT) imm.showSoftInput(searchEditText, 0)
} }
} }
} }
@@ -9,7 +9,6 @@ import android.os.Bundle
import android.os.CountDownTimer import android.os.CountDownTimer
import android.util.Log import android.util.Log
import androidx.localbroadcastmanager.content.LocalBroadcastManager import androidx.localbroadcastmanager.content.LocalBroadcastManager
import androidx.media.MediaBrowserServiceCompat.BrowserRoot.EXTRA_RECENT
import androidx.media3.cast.CastPlayer import androidx.media3.cast.CastPlayer
import androidx.media3.common.* import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
@@ -29,6 +28,7 @@ import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.ListenableFuture
import com.michatec.radio.core.Collection import com.michatec.radio.core.Collection
import com.michatec.radio.core.Station
import com.michatec.radio.helpers.* import com.michatec.radio.helpers.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.Dispatchers.Main import kotlinx.coroutines.Dispatchers.Main
@@ -132,7 +132,7 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
context: Context, context: Context,
enableFloatOutput: Boolean, enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean enableAudioTrackPlaybackParams: Boolean
): AudioSink? { ): AudioSink {
return DefaultAudioSink.Builder(context) return DefaultAudioSink.Builder(context)
.setAudioProcessors(arrayOf(nativeAudioProcessor)) .setAudioProcessors(arrayOf(nativeAudioProcessor))
.build() .build()
@@ -170,6 +170,14 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
override fun getDuration(): Long { override fun getDuration(): Long {
return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification return C.TIME_UNSET // this will hide progress bar for HLS stations in the notification
} }
override fun seekToNext() {
playNextStation()
}
override fun seekToPrevious() {
playPreviousStation()
}
} }
player.addListener(playerListener) player.addListener(playerListener)
} }
@@ -347,6 +355,37 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
} }
/* Switches to the next radio station in collection */
private fun playNextStation() {
val currentMediaId = player.currentMediaItem?.mediaId ?: PreferencesHelper.loadLastPlayedStationUuid()
val currentPosition = CollectionHelper.getStationPosition(collection, currentMediaId)
if (currentPosition != -1) {
val nextPosition = if (currentPosition < collection.stations.size - 1) currentPosition + 1 else 0
playStation(collection.stations[nextPosition])
}
}
/* Switches to the previous radio station in collection */
private fun playPreviousStation() {
val currentMediaId = player.currentMediaItem?.mediaId ?: PreferencesHelper.loadLastPlayedStationUuid()
val currentPosition = CollectionHelper.getStationPosition(collection, currentMediaId)
if (currentPosition != -1) {
val previousPosition = if (currentPosition > 0) currentPosition - 1 else collection.stations.size - 1
playStation(collection.stations[previousPosition])
}
}
/* Starts playback of a radio station */
private fun playStation(station: Station) {
val mediaItem = CollectionHelper.buildMediaItem(this, station)
player.setMediaItem(mediaItem)
player.prepare()
player.play()
}
/* /*
* Custom MediaSession Callback that handles player commands * Custom MediaSession Callback that handles player commands
*/ */
@@ -407,8 +446,8 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
browser: MediaSession.ControllerInfo, browser: MediaSession.ControllerInfo,
params: LibraryParams? params: LibraryParams?
): ListenableFuture<LibraryResult<MediaItem>> { ): ListenableFuture<LibraryResult<MediaItem>> {
return if (params?.extras?.containsKey(EXTRA_RECENT) == true) { return if (params?.isRecent == true) {
// special case: system requested media resumption via EXTRA_RECENT // special case: system requested media resumption via isRecent
playLastStation = true playLastStation = true
Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRecent(this@PlayerService, collection), params)) Futures.immediateFuture(LibraryResult.ofItem(CollectionHelper.getRecent(this@PlayerService, collection), params))
} else { } else {
@@ -552,8 +591,7 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
stopSelf() stopSelf()
} }
Player.STATE_READY -> { Player.STATE_READY -> {
// Playback is paused. For radio, we can stop the service to remove the notification. // Playback is paused. For radio, we keep the service running to allow resumption from headphones.
stopSelf()
} }
Player.STATE_BUFFERING -> { Player.STATE_BUFFERING -> {
// DO NOT stop the service while buffering (especially important for Cast) // DO NOT stop the service while buffering (especially important for Cast)
@@ -562,17 +600,6 @@ class PlayerService : MediaLibraryService(), SharedPreferences.OnSharedPreferenc
} }
} }
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
super.onPlayWhenReadyChanged(playWhenReady, reason)
if (!playWhenReady) {
// Only stop if not buffering and not ready to play (i.e. truly stopped/paused)
if (player.playbackState != Player.STATE_BUFFERING) {
stopSelf()
}
}
}
override fun onPlayerError(error: PlaybackException) { override fun onPlayerError(error: PlaybackException) {
super.onPlayerError(error) super.onPlayerError(error)
Log.d(TAG, "PlayerError occurred: ${error.errorCodeName}") Log.d(TAG, "PlayerError occurred: ${error.errorCodeName}")
@@ -11,6 +11,7 @@ import android.widget.FrameLayout
import androidx.preference.Preference import androidx.preference.Preference
import androidx.preference.PreferenceViewHolder import androidx.preference.PreferenceViewHolder
import com.michatec.radio.R import com.michatec.radio.R
import androidx.core.view.isEmpty
class ExtrasHelper { class ExtrasHelper {
companion object { companion object {
@@ -69,7 +70,7 @@ class ExtrasHelper {
if (currentParent != container) { if (currentParent != container) {
currentParent?.removeView(visualizerView) currentParent?.removeView(visualizerView)
// If we injected into a standard preference, don't clear everything, just add // If we injected into a standard preference, don't clear everything, just add
if (container is FrameLayout || container.childCount == 0) { if (container is FrameLayout || container.isEmpty()) {
container.removeAllViews() container.removeAllViews()
} }
container.addView(visualizerView) container.addView(visualizerView)
@@ -2,7 +2,6 @@ package com.michatec.radio.helpers
import android.app.Activity import android.app.Activity
import android.content.Context import android.content.Context
import android.content.res.Configuration
import android.util.Log import android.util.Log
import com.michatec.radio.R import com.michatec.radio.R
import java.util.Locale import java.util.Locale
@@ -16,7 +16,7 @@ class MarqueeSwitchPreference(context: Context) : SwitchPreferenceCompat(context
val title = holder.findViewById(android.R.id.title) as? TextView val title = holder.findViewById(android.R.id.title) as? TextView
title?.apply { title?.apply {
ellipsize = TextUtils.TruncateAt.MARQUEE ellipsize = TextUtils.TruncateAt.MARQUEE
setSingleLine(true) isSingleLine = true
marqueeRepeatLimit = -1 // Repeat indefinitely marqueeRepeatLimit = -1 // Repeat indefinitely
isSelected = true // Required for marquee to start isSelected = true // Required for marquee to start
setHorizontallyScrolling(true) setHorizontallyScrolling(true)
@@ -171,7 +171,7 @@ class SearchResultAdapter(
context: Context, context: Context,
enableFloatOutput: Boolean, enableFloatOutput: Boolean,
enableAudioTrackPlaybackParams: Boolean enableAudioTrackPlaybackParams: Boolean
): AudioSink? { ): AudioSink {
return DefaultAudioSink.Builder(context) return DefaultAudioSink.Builder(context)
.setAudioProcessors(arrayOf(nativeAudioProcessor)) .setAudioProcessors(arrayOf(nativeAudioProcessor))
.build() .build()