package com.radiola.ui.player import android.util.Log import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.basicMarquee import androidx.compose.animation.Crossfade import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import coil.compose.AsyncImage import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.rememberModalBottomSheetState import com.composables.icons.lucide.Check import com.composables.icons.lucide.FileText import com.composables.icons.lucide.Heart import com.composables.icons.lucide.Lucide import com.composables.icons.lucide.Mic import com.composables.icons.lucide.MicOff import com.composables.icons.lucide.Moon import com.composables.icons.lucide.Music import com.composables.icons.lucide.Pause import com.composables.icons.lucide.Play import com.composables.icons.lucide.Radio import com.composables.icons.lucide.SkipBack import com.composables.icons.lucide.SkipForward import com.composables.icons.lucide.SlidersHorizontal import com.radiola.deeplink.DeeplinkNavigator import com.radiola.domain.model.DeeplinkService import com.radiola.domain.model.Station import com.radiola.domain.model.Track import com.radiola.ui.lyrics.LyricsSheet import com.radiola.ui.theme.LiveEqualizer import com.radiola.ui.theme.Motion import com.radiola.ui.theme.RadiolaTheme import com.radiola.ui.theme.pressScale @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun PlayerBottomSheet( station: Station?, track: Track?, isPlaying: Boolean, onPlayPause: () -> Unit, onNext: () -> Unit, onPrevious: () -> Unit, isFavorite: Boolean, onToggleFavorite: () -> Unit, isRecording: Boolean, onToggleRecording: () -> Unit, modifier: Modifier = Modifier, viewModel: PlayerViewModel = hiltViewModel() ) { val context = LocalContext.current val enabledServices by viewModel.enabledServices.collectAsState() val recognizing by viewModel.recognizing.collectAsState() val colors = RadiolaTheme.colors val haptics = LocalHapticFeedback.current LaunchedEffect(Unit) { viewModel.recognizeEvent.collect { msg -> android.widget.Toast.makeText(context, msg, android.widget.Toast.LENGTH_SHORT).show() } } var showLyrics by remember { mutableStateOf(false) } var showQuality by remember { mutableStateOf(false) } var showSleep by remember { mutableStateOf(false) } var selectedSound by remember { mutableStateOf(null) } val currentQuality by viewModel.currentQuality.collectAsState() val sleepRemainingMs by viewModel.sleepRemainingMs.collectAsState() val vizStyle by viewModel.visualizerStyle.collectAsState() val landscape = com.radiola.ui.util.isLandscape() // ── Секции плеера как лямбды: переиспользуются в портретной (колонка) // и альбомной (две панели) раскладках. ── val labelSection: @Composable () -> Unit = { // Метка «В ЭФИРЕ» + чип качества справа (если у станции есть варианты) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { Text( text = "В ЭФИРЕ", style = MaterialTheme.typography.labelSmall, color = colors.accent, letterSpacing = 2.sp, fontWeight = FontWeight.SemiBold ) val qualities = station?.qualities.orEmpty() if (qualities.size >= 2) { QualityChip( label = "${(currentQuality?.bitrate ?: qualities.first().bitrate)}k", onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) showQuality = true }, modifier = Modifier.align(Alignment.CenterEnd) ) } } } val nameSection: @Composable () -> Unit = { // Название радиостанции — под меткой, над обложкой Text( text = station?.name ?: "", style = MaterialTheme.typography.titleLarge, color = colors.textPrimary, fontWeight = FontWeight.SemiBold, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = androidx.compose.ui.text.style.TextAlign.Center, modifier = Modifier.basicMarquee() ) } val coverSection: @Composable (Dp) -> Unit = { coverSize -> // Обложка станции/трека Box( modifier = Modifier .size(coverSize) .clip(RoundedCornerShape(24.dp)) .background(colors.surface2), contentAlignment = Alignment.Center ) { val coverModel = track?.coverUrl ?: station?.coverUrl com.radiola.ui.components.FlipCover( model = coverModel, contentDescription = station?.name, modifier = Modifier.fillMaxSize() ) { Icon( imageVector = Lucide.Radio, contentDescription = null, tint = colors.textMuted, modifier = Modifier.size(56.dp) ) } } } val trackInfoSection: @Composable () -> Unit = { // Название трека и исполнитель с Crossfade при смене Crossfade( targetState = track?.song to track?.artist, animationSpec = tween(Motion.Medium), label = "trackInfo" ) { (song, artist) -> Column(horizontalAlignment = Alignment.CenterHorizontally) { Text( text = song ?: (station?.name ?: ""), style = MaterialTheme.typography.headlineLarge, color = colors.textPrimary, maxLines = 1, textAlign = androidx.compose.ui.text.style.TextAlign.Center, modifier = Modifier.basicMarquee() ) Spacer(Modifier.height(4.dp)) Text( text = artist ?: (station?.genre ?: ""), style = MaterialTheme.typography.bodyLarge, color = colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = androidx.compose.ui.text.style.TextAlign.Center ) } } } // Кнопка распознавания (Shazam) — только для музыкальных станций без РЕАЛЬНЫХ // метаданных эфира. «Безымянные» станции часто шлют ICY-строку (слоган/название) // без разделителя → parseIcyTitle делает трек с ПУСТЫМ исполнителем; такой трек // и есть «нет названия» → кнопку показываем. Настоящий «Исполнитель — Трек» // (artist и song заполнены) → кнопка скрыта. val recognizeSection: @Composable () -> Unit = { val noRealTrack = track == null || track.artist.isBlank() || track.song.isBlank() || track.song == station?.name val show = station != null && noRealTrack && com.radiola.domain.model.MusicGenres.isMusicStation(station.genre) if (show) { val interaction = remember { MutableInteractionSource() } Row( modifier = Modifier .clip(RoundedCornerShape(50)) .background(colors.accent.copy(alpha = 0.15f)) .pressScale(interactionSource = interaction) .clickable( interactionSource = interaction, indication = null, enabled = !recognizing ) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) viewModel.recognizeCurrentTrack() } .padding(horizontal = 18.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { if (recognizing) { CircularProgressIndicator( color = colors.accent, strokeWidth = 2.dp, modifier = Modifier.size(18.dp) ) } else { Icon( imageVector = Lucide.Mic, contentDescription = null, tint = colors.accent, modifier = Modifier.size(20.dp) ) } Text( text = if (recognizing) "Распознаём…" else "Распознать трек", color = colors.accent, fontSize = 15.sp, fontWeight = FontWeight.Medium ) } } } val visualizerSection: @Composable () -> Unit = { // Живой эквалайзер. Спектр (45/с) собирается ВНУТРИ VisualizerHost — // чтобы 45/с рекомпозиции не задевали весь плеер, только этот leaf. VisualizerHost( viewModel = viewModel, vizStyle = vizStyle, playing = isPlaying, color = colors.accent, modifier = Modifier .fillMaxWidth() .height(if (com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle) == com.radiola.ui.components.VisualizerStyle.RADIAL) 120.dp else 40.dp) ) } val controlsSection: @Composable () -> Unit = { // Управление воспроизведением Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically ) { // Кнопка избранного val heartTint by animateColorAsState( targetValue = if (isFavorite) colors.accent else colors.textSecondary, animationSpec = tween(Motion.Medium), label = "heartTint" ) PlayerIconBtn(size = 48.dp) { IconButton( onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) onToggleFavorite() }, modifier = Modifier.size(48.dp) ) { Icon(Lucide.Heart, "Избранное", tint = heartTint, modifier = Modifier.size(24.dp)) } } // Кнопка «предыдущая станция» PlayerIconBtn(size = 48.dp) { IconButton(onClick = onPrevious, modifier = Modifier.size(48.dp)) { Icon(Lucide.SkipBack, "Предыдущая", tint = colors.textPrimary, modifier = Modifier.size(24.dp)) } } // Главная кнопка play/pause val playInteraction = remember { MutableInteractionSource() } Box( modifier = Modifier .size(68.dp) .clip(CircleShape) .background(colors.accent) .pressScale(interactionSource = playInteraction) .clickable(interactionSource = playInteraction, indication = null) { haptics.performHapticFeedback(HapticFeedbackType.LongPress) onPlayPause() }, contentAlignment = Alignment.Center ) { Crossfade( targetState = isPlaying, animationSpec = tween(Motion.Fast), label = "playPause" ) { playing -> Icon( imageVector = if (playing) Lucide.Pause else Lucide.Play, contentDescription = if (playing) "Пауза" else "Воспроизвести", tint = colors.bgBase, modifier = Modifier.size(30.dp) ) } } // Кнопка «следующая станция» PlayerIconBtn(size = 48.dp) { IconButton(onClick = onNext, modifier = Modifier.size(48.dp)) { Icon(Lucide.SkipForward, "Следующая", tint = colors.textPrimary, modifier = Modifier.size(24.dp)) } } // Кнопка записи val recordTint by animateColorAsState( targetValue = if (isRecording) colors.live else colors.textSecondary, animationSpec = tween(Motion.Medium), label = "recordTint" ) PlayerIconBtn(size = 48.dp) { IconButton(onClick = onToggleRecording, modifier = Modifier.size(48.dp)) { Crossfade( targetState = isRecording, animationSpec = tween(Motion.Fast), label = "recordIcon" ) { recording -> Icon( imageVector = if (recording) Lucide.MicOff else Lucide.Mic, contentDescription = if (recording) "Остановить запись" else "Запись", tint = recordTint, modifier = Modifier.size(24.dp) ) } } } } } val servicesSection: @Composable () -> Unit = { // Ряд кнопок музыкальных сервисов if (enabledServices.isNotEmpty()) { LazyRow( horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth(), contentPadding = PaddingValues(horizontal = 4.dp) ) { items(enabledServices) { service -> ServiceDeeplinkBtn( service = service, onClick = { track?.let { t -> Log.d("PlayerBottomSheet", "Deeplink: ${t.artist} - ${t.song}") DeeplinkNavigator.openSearch(context, t, service) } ?: Log.d("PlayerBottomSheet", "Deeplink нажат, но трек null") } ) } } } } val lyricsSection: @Composable () -> Unit = { // Кнопка «Текст песни» — активна только когда играет трек. // Явная пилюля с фоном: на реальном телефоне мелкий TextButton почти не виден. if (track != null) { val lyricsInteraction = remember { MutableInteractionSource() } Row( modifier = Modifier .clip(RoundedCornerShape(50)) .background(colors.surface2) .pressScale(interactionSource = lyricsInteraction) .clickable(interactionSource = lyricsInteraction, indication = null) { showLyrics = true } .padding(horizontal = 18.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( imageVector = Lucide.FileText, contentDescription = null, tint = colors.accent, modifier = Modifier.size(20.dp) ) Text( text = "Текст песни", color = colors.accent, fontSize = 15.sp, fontWeight = FontWeight.Medium ) } } } // Кнопка таймера сна. Активен → подсветка акцентом + оставшееся время MM:SS. val sleepSection: @Composable () -> Unit = { val active = sleepRemainingMs != null val sleepInteraction = remember { MutableInteractionSource() } Row( modifier = Modifier .clip(RoundedCornerShape(50)) .background(if (active) colors.accent.copy(alpha = 0.15f) else colors.surface2) .pressScale(interactionSource = sleepInteraction) .clickable(interactionSource = sleepInteraction, indication = null) { showSleep = true } .padding(horizontal = 18.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { Icon( imageVector = Lucide.Moon, contentDescription = null, tint = if (active) colors.accent else colors.textSecondary, modifier = Modifier.size(20.dp) ) Text( text = sleepRemainingMs?.let { "Сон · ${formatSleep(it)}" } ?: "Таймер сна", color = if (active) colors.accent else colors.textSecondary, fontSize = 15.sp, fontWeight = FontWeight.Medium ) } } if (landscape) { // Альбом: слева обложка с названием станции, справа — трек, эквалайзер, // управление и сервисы (правая панель скроллится на низких экранах). Row( modifier = modifier .fillMaxWidth() .background(colors.bgBase) .navigationBarsPadding() .padding(horizontal = 24.dp, vertical = 16.dp), verticalAlignment = Alignment.CenterVertically ) { Column( modifier = Modifier.weight(0.42f), horizontalAlignment = Alignment.CenterHorizontally ) { labelSection() Spacer(Modifier.height(6.dp)) nameSection() Spacer(Modifier.height(14.dp)) coverSection(170.dp) } Spacer(Modifier.width(24.dp)) Column( modifier = Modifier .weight(0.58f) .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { trackInfoSection() recognizeSection() Spacer(Modifier.height(16.dp)) visualizerSection() Spacer(Modifier.height(16.dp)) controlsSection() Spacer(Modifier.height(16.dp)) servicesSection() Spacer(Modifier.height(12.dp)) sleepSection() if (track != null) { Spacer(Modifier.height(10.dp)) lyricsSection() } } } } else { Column( modifier = modifier .fillMaxWidth() .background(colors.bgBase) .navigationBarsPadding() // Скролл — чтобы на телефонах с меньшей высотой в dp (высокий dpi) // низ плеера (кнопка «Текст песни») не обрезался шторкой. .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { labelSection() Spacer(Modifier.height(6.dp)) nameSection() Spacer(Modifier.height(16.dp)) coverSection(190.dp) Spacer(Modifier.height(14.dp)) trackInfoSection() recognizeSection() Spacer(Modifier.height(20.dp)) visualizerSection() Spacer(Modifier.height(16.dp)) controlsSection() Spacer(Modifier.height(20.dp)) servicesSection() if (enabledServices.isNotEmpty()) Spacer(Modifier.height(12.dp)) sleepSection() Spacer(Modifier.height(10.dp)) lyricsSection() } } // Шторка выбора качества if (showQuality && station != null) { val qualities = station.qualities ModalBottomSheet( onDismissRequest = { showQuality = false }, containerColor = colors.bgBase, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { Column( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = 24.dp) .padding(bottom = 16.dp) ) { Text( text = "Качество звука", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 12.dp) ) qualities.forEach { q -> QualityRow( quality = q, selected = currentQuality?.bitrate == q.bitrate, onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) viewModel.selectQuality(q) showQuality = false } ) } } } } // Шторка текста песни if (showLyrics && track != null) { ModalBottomSheet( onDismissRequest = { showLyrics = false }, containerColor = colors.bgBase, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { LyricsSheet( artist = track.artist, song = track.song ) } } // Шторка таймера сна if (showSleep) { ModalBottomSheet( onDismissRequest = { showSleep = false }, containerColor = colors.bgBase, sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) { Column( modifier = Modifier .fillMaxWidth() .navigationBarsPadding() .padding(horizontal = 24.dp) .padding(bottom = 16.dp) ) { Text( text = "Таймер сна", style = MaterialTheme.typography.titleMedium, color = colors.textPrimary, fontWeight = FontWeight.SemiBold, modifier = Modifier.padding(vertical = 12.dp) ) Text( text = "Музыка плавно затихнет к концу. Можно мягко перейти на звук для сна.", style = MaterialTheme.typography.bodySmall, color = colors.textSecondary, modifier = Modifier.padding(bottom = 12.dp) ) // Выбор звука для сна: радио плавно перетечёт в выбранный шум. LazyRow( horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(bottom = 12.dp) ) { item { SoundChip("Без звука", selectedSound == null) { selectedSound = null } } items(com.radiola.service.SleepSound.entries) { snd -> SoundChip(snd.title, selectedSound == snd) { selectedSound = snd } } } // Если активен — показываем остаток и кнопку отмены if (sleepRemainingMs != null) { SleepRow( label = "Осталось ${formatSleep(sleepRemainingMs!!)}", selected = true, onClick = { viewModel.cancelSleepTimer() showSleep = false }, trailing = "Выключить" ) Spacer(Modifier.height(4.dp)) } listOf(15, 30, 45, 60, 90, 120).forEach { min -> SleepRow( label = "$min минут", selected = false, onClick = { haptics.performHapticFeedback(HapticFeedbackType.LongPress) viewModel.startSleepTimer(min, selectedSound) showSleep = false } ) } } } } } /** Строка выбора интервала таймера сна. */ @Composable private fun SleepRow( label: String, selected: Boolean, onClick: () -> Unit, trailing: String? = null ) { val colors = RadiolaTheme.colors Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .clickable(onClick = onClick) .padding(horizontal = 14.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { Icon( imageVector = Lucide.Moon, contentDescription = null, tint = if (selected) colors.accent else colors.textSecondary, modifier = Modifier.size(18.dp) ) Spacer(Modifier.width(12.dp)) Text( text = label, style = MaterialTheme.typography.bodyLarge, color = if (selected) colors.accent else colors.textPrimary, modifier = Modifier.weight(1f) ) if (trailing != null) { Text( text = trailing, style = MaterialTheme.typography.labelLarge, color = colors.live, fontWeight = FontWeight.SemiBold ) } } } /** * Leaf-обёртка эквалайзера: сама собирает спектр (обновляется ~45/с) и включает * расчёт FFT только пока скомпонована (открыт плеер) — это и изолирует частые * рекомпозиции от остального плеера, и гасит FFT в фоне (батарея). */ @Composable private fun VisualizerHost( viewModel: PlayerViewModel, vizStyle: String, playing: Boolean, color: Color, modifier: Modifier ) { val spectrum by viewModel.spectrum.collectAsState() DisposableEffect(Unit) { viewModel.setSpectrumActive(true) onDispose { viewModel.setSpectrumActive(false) } } com.radiola.ui.components.Visualizer( style = com.radiola.ui.components.VisualizerStyle.fromKey(vizStyle), levels = spectrum, playing = playing, color = color, modifier = modifier ) } /** Чип выбора звука для сна. */ @Composable private fun SoundChip(label: String, selected: Boolean, onClick: () -> Unit) { val colors = RadiolaTheme.colors Text( text = label, style = MaterialTheme.typography.labelLarge, color = if (selected) colors.bgBase else colors.textSecondary, fontWeight = FontWeight.Medium, modifier = Modifier .clip(RoundedCornerShape(50)) .background(if (selected) colors.accent else colors.surface2) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick ) .padding(horizontal = 14.dp, vertical = 9.dp) ) } /** Форматирует оставшееся время таймера сна в M:SS / MM:SS. */ private fun formatSleep(ms: Long): String { val total = (ms / 1000).coerceAtLeast(0) return "%d:%02d".format(total / 60, total % 60) } /** Компактный чип текущего качества звука. */ @Composable private fun QualityChip( label: String, onClick: () -> Unit, modifier: Modifier = Modifier ) { val colors = RadiolaTheme.colors val interaction = remember { MutableInteractionSource() } Row( modifier = modifier .clip(RoundedCornerShape(50)) .background(colors.surface2) .pressScale(interactionSource = interaction) .clickable(interactionSource = interaction, indication = null, onClick = onClick) .padding(horizontal = 10.dp, vertical = 5.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp) ) { Icon( imageVector = Lucide.SlidersHorizontal, contentDescription = "Качество", tint = colors.accent, modifier = Modifier.size(13.dp) ) Text( text = label, style = MaterialTheme.typography.labelMedium, color = colors.textPrimary, fontWeight = FontWeight.SemiBold ) } } /** Строка выбора одного качества в шторке. */ @Composable private fun QualityRow( quality: com.radiola.domain.model.StreamQuality, selected: Boolean, onClick: () -> Unit ) { val colors = RadiolaTheme.colors Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(14.dp)) .clickable(onClick = onClick) .padding(horizontal = 14.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically ) { Column(modifier = Modifier.weight(1f)) { Text( text = quality.tierLabel, style = MaterialTheme.typography.bodyLarge, color = if (selected) colors.accent else colors.textPrimary, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal ) Text( text = "${quality.bitrate} kbps · ${quality.type.uppercase()}", style = MaterialTheme.typography.bodySmall, color = colors.textSecondary ) } if (selected) { Icon( imageVector = Lucide.Check, contentDescription = "Выбрано", tint = colors.accent, modifier = Modifier.size(20.dp) ) } } } /** Обёртка для иконок-кнопок управления — прозрачный фон. */ @Composable private fun PlayerIconBtn( size: Dp, content: @Composable () -> Unit ) { Box(modifier = Modifier.size(size), contentAlignment = Alignment.Center) { content() } } /** Короткая подпись сервиса под кнопкой (без обрезки слов). */ private fun serviceShortName(service: DeeplinkService): String = when (service.serviceId) { "yandex" -> "Яндекс" "vk" -> "ВК Музыка" "boom" -> "BOOM" "spotify" -> "Spotify" "apple" -> "Apple Music" "youtube" -> "YT Music" "tidal" -> "Tidal" "deezer" -> "Deezer" else -> service.displayName } /** Монохромная кнопка сервиса для поиска трека. */ @Composable private fun ServiceDeeplinkBtn( service: DeeplinkService, onClick: () -> Unit ) { val colors = RadiolaTheme.colors val interaction = remember { MutableInteractionSource() } Column( modifier = Modifier .pressScale(interactionSource = interaction) .clickable(interactionSource = interaction, indication = null, onClick = onClick), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(4.dp) ) { Box( modifier = Modifier .size(44.dp) .clip(CircleShape) .background(colors.surface2), contentAlignment = Alignment.Center ) { val logoRes = com.radiola.ui.components.serviceLogoRes(service) if (logoRes != null) { Icon( painter = androidx.compose.ui.res.painterResource(logoRes), contentDescription = service.displayName, tint = colors.textSecondary, modifier = Modifier.size(22.dp) ) } else { Icon( imageVector = Lucide.Music, contentDescription = service.displayName, tint = colors.textSecondary, modifier = Modifier.size(20.dp) ) } } Text( text = serviceShortName(service), style = MaterialTheme.typography.labelSmall, color = colors.textSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis ) } }