feat: auth screen with auto-redirect, sync favorites/history with backend

This commit is contained in:
nk
2026-06-02 19:12:07 +03:00
parent d4adb1e7be
commit a83672b455
2934 changed files with 97351 additions and 163 deletions

View File

@@ -1,13 +1,23 @@
package com.radiola.service
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.net.Uri
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.ForwardingPlayer
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.Metadata
import androidx.media3.common.Player
import android.util.Log
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.extractor.metadata.icy.IcyInfo
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.StateFlow
import javax.inject.Inject
import javax.inject.Singleton
@@ -22,7 +32,45 @@ class PlayerController @Inject constructor(
private val _currentStationPrefix = MutableStateFlow<String?>(null)
val currentStationPrefix: StateFlow<String?> = _currentStationPrefix
val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
private val _icyTitle = MutableStateFlow<String?>(null)
val icyTitle: StateFlow<String?> = _icyTitle.asStateFlow()
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
var onSkipToNext: (() -> Unit)? = null
var onSkipToPrevious: (() -> Unit)? = null
private val audioDeviceCallback = object : AudioDeviceCallback() {
override fun onAudioDevicesAdded(addedDevices: Array<out AudioDeviceInfo>?) {
val addedPlayback = addedDevices?.any { it.isPlaybackDevice() } == true
if (addedPlayback && _currentStationPrefix.value != null && !exoPlayer.isPlaying) {
Log.d("PlayerController", "Playback device connected → resume")
exoPlayer.play()
}
}
override fun onAudioDevicesRemoved(removedDevices: Array<out AudioDeviceInfo>?) {
val removedPlayback = removedDevices?.any { it.isPlaybackDevice() } == true
if (removedPlayback && exoPlayer.isPlaying) {
Log.d("PlayerController", "Playback device removed → pause")
exoPlayer.pause()
}
}
}
private fun AudioDeviceInfo.isPlaybackDevice(): Boolean {
return isSink && (
type == AudioDeviceInfo.TYPE_BLUETOOTH_A2DP ||
type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES ||
type == AudioDeviceInfo.TYPE_WIRED_HEADSET ||
type == AudioDeviceInfo.TYPE_USB_HEADSET ||
type == AudioDeviceInfo.TYPE_USB_DEVICE ||
type == AudioDeviceInfo.TYPE_BLE_HEADSET ||
type == AudioDeviceInfo.TYPE_BLE_SPEAKER
)
}
private val exoPlayer: ExoPlayer = ExoPlayer.Builder(context)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
@@ -37,27 +85,106 @@ class PlayerController @Inject constructor(
override fun onIsPlayingChanged(playing: Boolean) {
_isPlaying.value = playing
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
val title = mediaMetadata.title?.toString()
if (!title.isNullOrBlank()) {
Log.d("PlayerController", "MediaMetadata title: $title")
_icyTitle.value = title
}
}
override fun onMetadata(metadata: Metadata) {
Log.d("PlayerController", "onMetadata called, length=${metadata.length()}")
for (i in 0 until metadata.length()) {
val entry = metadata.get(i)
Log.d("PlayerController", "Metadata entry[$i]: ${entry::class.java.simpleName}")
when (entry) {
is IcyInfo -> {
Log.d("PlayerController", "IcyInfo title='${entry.title}', url='${entry.url}', raw='${entry.rawMetadata}'")
entry.title?.let {
if (it.isNotBlank()) {
_icyTitle.value = it
}
}
}
}
}
}
})
}
fun play(url: String, stationPrefix: String) {
val mediaItem = MediaItem.fromUri(url)
val player: Player = object : ForwardingPlayer(exoPlayer) {
override fun getAvailableCommands(): Player.Commands {
return super.getAvailableCommands()
.buildUpon()
.add(Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM)
.add(Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM)
.build()
}
override fun seekToNextMediaItem() {
onSkipToNext?.invoke() ?: super.seekToNextMediaItem()
}
override fun seekToPreviousMediaItem() {
onSkipToPrevious?.invoke() ?: super.seekToPreviousMediaItem()
}
}
init {
audioManager.registerAudioDeviceCallback(audioDeviceCallback, null)
}
fun play(url: String, stationPrefix: String, stationName: String) {
Log.d("PlayerController", "play() called with url=$url prefix=$stationPrefix")
_icyTitle.value = null
val mediaItem = MediaItem.Builder()
.setUri(url)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(stationName)
.setArtist("")
.build()
)
.build()
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
exoPlayer.play()
_currentStationPrefix.value = stationPrefix
}
fun updateMetadata(song: String, artist: String, coverUrl: String, stationName: String) {
val currentMediaItem = exoPlayer.currentMediaItem ?: return
val artworkUri = coverUrl.takeIf { it.isNotBlank() }?.let { Uri.parse(it) }
val updatedMediaItem = currentMediaItem.buildUpon()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(song)
.setArtist("$artist$stationName")
.setAlbumTitle(stationName)
.setArtworkUri(artworkUri)
.build()
)
.build()
exoPlayer.replaceMediaItem(0, updatedMediaItem)
}
fun pause() {
exoPlayer.pause()
}
fun play() {
exoPlayer.play()
}
fun stop() {
exoPlayer.stop()
_currentStationPrefix.value = null
}
fun release() {
audioManager.unregisterAudioDeviceCallback(audioDeviceCallback)
exoPlayer.release()
}
}

View File

@@ -20,7 +20,7 @@ class PlayerService : MediaSessionService() {
override fun onCreate() {
super.onCreate()
mediaSession = MediaSession.Builder(this, playerController.exoPlayer)
mediaSession = MediaSession.Builder(this, playerController.player)
.setSessionActivity(
PendingIntent.getActivity(
this,
@@ -41,10 +41,7 @@ class PlayerService : MediaSessionService() {
}
override fun onDestroy() {
mediaSession?.run {
player.release()
release()
}
mediaSession?.release()
mediaSession = null
super.onDestroy()
}

View File

@@ -0,0 +1,63 @@
package com.radiola.service
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.radiola.R
class RecordingService : Service() {
companion object {
const val CHANNEL_ID = "recording_channel"
const val NOTIFICATION_ID = 2
const val EXTRA_STATION_NAME = "station_name"
}
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val stationName = intent?.getStringExtra(EXTRA_STATION_NAME) ?: "Радио"
val notification = buildNotification(stationName)
startForeground(NOTIFICATION_ID, notification)
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
super.onDestroy()
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"Запись радио",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Уведомления о записи радиопотока"
setSound(null, null)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
private fun buildNotification(stationName: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Идёт запись")
.setContentText(stationName)
.setSmallIcon(R.drawable.ic_play)
.setOnlyAlertOnce(true)
.build()
}
}