@file:Suppress("TooManyFunctions") // Compose screen + private helper composables

package com.fabledsword.minstrel.player.ui

import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.Velocity
import androidx.compose.material3.Scaffold
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Slider
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import com.composables.icons.lucide.ChevronDown
import com.composables.icons.lucide.ListMusic
import com.composables.icons.lucide.Lucide
import com.composables.icons.lucide.Music
import com.composables.icons.lucide.Pause
import com.composables.icons.lucide.Play
import com.composables.icons.lucide.Repeat
import com.composables.icons.lucide.Repeat1
import com.composables.icons.lucide.Shuffle
import com.composables.icons.lucide.SkipBack
import com.composables.icons.lucide.SkipForward
import com.fabledsword.minstrel.player.RepeatMode
import com.fabledsword.minstrel.player.output.ActiveUpnp
import com.fabledsword.minstrel.player.output.DeviceChip
import com.fabledsword.minstrel.player.output.OutputPickerSheet
import com.fabledsword.minstrel.player.output.OutputPickerViewModel
import com.fabledsword.minstrel.player.output.OutputRoute
import com.fabledsword.minstrel.player.output.RouteSnapshot
import kotlinx.coroutines.launch
import com.fabledsword.minstrel.nav.AlbumDetail
import com.fabledsword.minstrel.nav.ArtistDetail
import com.fabledsword.minstrel.nav.HERO_KEY_NOW_PLAYING_COVER
import com.fabledsword.minstrel.nav.LocalAnimatedContentScope
import com.fabledsword.minstrel.nav.LocalSharedTransitionScope
import com.fabledsword.minstrel.nav.NowPlaying
import com.fabledsword.minstrel.nav.Queue
import com.fabledsword.minstrel.shared.formatDuration
import com.fabledsword.minstrel.shared.millisToSeconds
import com.fabledsword.minstrel.shared.widgets.LikeButton
import com.fabledsword.minstrel.shared.widgets.ServerImage
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsButton
import com.fabledsword.minstrel.shared.widgets.trackactions.TrackActionsViewModel
import com.fabledsword.minstrel.theme.FabledSwordFlatTokens
import com.fabledsword.minstrel.theme.LocalActionColors

private const val COVER_MAX_WIDTH_DP = 320
private const val TRANSPORT_ICON_DP = 36
private const val PLAY_PAUSE_ICON_DP = 56
private const val POP_GRACE_MS = 500L
private val SCRUB_TRACK_HEIGHT_DP = 4.dp
private val SCRUB_TRACK_CORNER_DP = 2.dp
private val SCRUB_THUMB_WIDTH_DP = 4.dp
private val SCRUB_THUMB_HEIGHT_DP = 18.dp

// Vertical drag-down threshold (in pixels) past which the gesture
// pops the player. Matches Flutter's 80px threshold in spirit;
// using pointer-input pixels not dp because gesture velocity also
// participates in the dismiss decision.
private const val DRAG_DISMISS_THRESHOLD_PX = 200f

/**
 * Full-screen player. Shows the cover (placeholder until AlbumRef join
 * lands), title + artist + album, a scrubber, and a transport row
 * (prev / play-pause / next). Renders the EmptyState widget when no
 * track is loaded.
 */
@Composable
fun NowPlayingScreen(
    navController: NavHostController,
    viewModel: PlayerViewModel = hiltViewModel(),
    trackActionsViewModel: TrackActionsViewModel = hiltViewModel(),
) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }
    LaunchedEffect(Unit) {
        trackActionsViewModel.transientMessages.collect { msg ->
            snackbarHostState.showSnackbar(msg)
        }
    }
    LaunchedEffect(Unit) {
        viewModel.dropEvents.collect { snackbarHostState.showSnackbar("Disconnected from $it") }
    }
    val track = state.currentTrack
    if (track == null) {
        NowPlayingNullTrackGuard(navController, viewModel)
        return
    }
    val outputViewModel: OutputPickerViewModel = hiltViewModel()
    val activeUpnp by outputViewModel.activeUpnp.collectAsStateWithLifecycle()
    val onKeyEvent = rememberUpnpVolumeKeyHandler(activeUpnp)
    val focusRequester = remember { FocusRequester() }
    LaunchedEffect(activeUpnp) { if (activeUpnp != null) focusRequester.requestFocus() }
    val dominant = rememberDominantColor(track.coverUrl)
    val dismissConnection = rememberDragDismissConnection(
        onDismiss = { navController.popBackStack() },
    )
    Scaffold(
        modifier = Modifier
            .fillMaxSize()
            .nestedScroll(dismissConnection)
            .focusRequester(focusRequester)
            .focusable()
            .onKeyEvent(onKeyEvent),
        topBar = { NowPlayingTopBar(onClose = { navController.popBackStack() }) },
        snackbarHost = { SnackbarHost(snackbarHostState) },
        containerColor = Color.Transparent,
    ) { inner ->
        Box(
            modifier = Modifier
                .fillMaxSize()
                .background(dominantGradient(dominant)),
        ) {
            NowPlayingBody(
                inner = inner,
                state = state,
                track = track,
                navController = navController,
                viewModel = viewModel,
                trackActionsViewModel = trackActionsViewModel,
                outputViewModel = outputViewModel,
            )
        }
    }
}

/**
 * Null-track guard extracted from [NowPlayingScreen] to keep that
 * function under detekt's LongMethod ceiling. Session torn down
 * (queue finished + auto-stop, or user cleared the queue from
 * elsewhere). Pops back after a short grace delay so a momentary
 * null during MediaController IPC bind on cold-mount doesn't flash.
 */
@Composable
private fun NowPlayingNullTrackGuard(
    navController: NavHostController,
    viewModel: PlayerViewModel,
) {
    LaunchedEffect(Unit) {
        kotlinx.coroutines.delay(POP_GRACE_MS)
        if (viewModel.uiState.value.currentTrack == null) {
            navController.popBackStack()
        }
    }
}

@Composable
private fun dominantGradient(top: Color): Brush {
    val base = MaterialTheme.colorScheme.background
    return Brush.verticalGradient(
        0f to top.copy(alpha = DOMINANT_TINT_TOP),
        DOMINANT_TINT_MID_STOP to top.copy(alpha = DOMINANT_TINT_MID),
        1f to base,
    )
}

private const val DOMINANT_TINT_TOP = 0.55f
private const val DOMINANT_TINT_MID = 0.18f
private const val DOMINANT_TINT_MID_STOP = 0.45f

/**
 * Drag-down to dismiss. Mirrors Flutter's modal-page gesture: a
 * downward over-scroll past the threshold pops the screen.
 *
 * Implementation note: the body Column uses verticalScroll, which
 * eats every vertical drag delta before a top-level
 * detectVerticalDragGestures can see it. NestedScroll is the
 * right primitive here — verticalScroll offers its over-scroll
 * deltas to the nearest ancestor connection, so we accumulate
 * downward over-scroll (available.y > 0 means the scrollable
 * couldn't consume it because it's at the top) and pop on
 * threshold. The Slider keeps its own pointerInput and is
 * unaffected.
 */
@Composable
private fun rememberDragDismissConnection(
    onDismiss: () -> Unit,
    thresholdPx: Float = DRAG_DISMISS_THRESHOLD_PX,
): NestedScrollConnection = remember(onDismiss, thresholdPx) {
    object : NestedScrollConnection {
        private var accumulated = 0f

        // One-shot latch. popBackStack is async — between the first
        // dismissal and the screen actually leaving the composition,
        // the user's finger is still down and more onPostScroll
        // frames arrive. Without this guard the accumulator rebuilds
        // and onDismiss() fires a second time, popping the screen
        // BENEATH NowPlaying. If that leaves the back stack empty
        // the NavHost renders nothing → black screen on resume.
        private var dismissed = false

        override fun onPostScroll(
            consumed: Offset,
            available: Offset,
            source: NestedScrollSource,
        ): Offset {
            // After dismissal, eat all remaining drag so the
            // scrollable doesn't paint over-scroll deltas during the
            // pop transition. Source guard folded into the `else if`
            // chain so the function stays under detekt's ReturnCount
            // ceiling of 2.
            if (dismissed) return available
            return if (source != NestedScrollSource.UserInput) {
                Offset.Zero
            } else if (available.y > 0f) {
                accumulated += available.y
                if (accumulated >= thresholdPx) {
                    dismissed = true
                    onDismiss()
                }
                Offset(0f, available.y)
            } else {
                if (consumed.y != 0f || available.y < 0f) accumulated = 0f
                Offset.Zero
            }
        }

        override suspend fun onPreFling(available: Velocity): Velocity {
            if (dismissed) return available
            accumulated = 0f
            return Velocity.Zero
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun NowPlayingTopBar(onClose: () -> Unit) {
    TopAppBar(
        title = {},
        navigationIcon = {
            IconButton(onClick = onClose) {
                Icon(
                    imageVector = Lucide.ChevronDown,
                    contentDescription = "Close player",
                    tint = MaterialTheme.colorScheme.onSurface,
                )
            }
        },
        colors = TopAppBarDefaults.topAppBarColors(
            containerColor = Color.Transparent,
        ),
    )
}

@Suppress("LongParameterList") // Compose screen wiring — layout args, not logic
@Composable
private fun NowPlayingBody(
    inner: androidx.compose.foundation.layout.PaddingValues,
    state: com.fabledsword.minstrel.player.PlayerUiState,
    track: com.fabledsword.minstrel.models.TrackRef,
    navController: NavHostController,
    viewModel: PlayerViewModel,
    trackActionsViewModel: TrackActionsViewModel,
    outputViewModel: OutputPickerViewModel,
) {
    val isLiked by trackActionsViewModel.isLikedFlow(track.id)
        .collectAsStateWithLifecycle(initialValue = false)
    val routes by outputViewModel.routes.collectAsStateWithLifecycle()
    val sheetVisible by outputViewModel.sheetVisible.collectAsStateWithLifecycle()
    val permissionDenied = rememberBluetoothPermissionState(sheetVisible)
    NowPlayingContent(
        inner = inner,
        state = state,
        track = track,
        navController = navController,
        viewModel = viewModel,
        trackActionsViewModel = trackActionsViewModel,
        routes = routes,
        isLiked = isLiked,
        onChipTapped = outputViewModel::onChipTapped,
    )
    if (sheetVisible) {
        OutputPickerSheet(
            snapshot = routes,
            permissionDenied = permissionDenied,
            onRouteSelected = outputViewModel::onRouteSelected,
            onDismiss = outputViewModel::onSheetDismissed,
        )
    }
}

@Composable
@Suppress("LongParameterList") // mirrors NowPlayingBody — pure layout wiring
private fun NowPlayingContent(
    inner: androidx.compose.foundation.layout.PaddingValues,
    state: com.fabledsword.minstrel.player.PlayerUiState,
    track: com.fabledsword.minstrel.models.TrackRef,
    navController: NavHostController,
    viewModel: PlayerViewModel,
    trackActionsViewModel: TrackActionsViewModel,
    routes: RouteSnapshot,
    isLiked: Boolean,
    onChipTapped: () -> Unit,
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(inner)
            .verticalScroll(rememberScrollState())
            .padding(horizontal = 24.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally,
    ) {
        NowPlayingCover(coverUrl = track.coverUrl, contentDescription = track.title)
        Spacer(Modifier.height(24.dp))
        TrackHeader(title = track.title, artist = track.artistName, album = track.albumTitle)
        Spacer(Modifier.height(24.dp))
        // Action row (like, shuffle, repeat, queue, kebab) sits ABOVE the
        // scrubber — Flutter's _SecondaryControls placement
        // (now_playing_screen.dart:464). Android previously had it below
        // the transport row.
        BottomActionsRow(
            navController = navController,
            track = track,
            shuffleEnabled = state.shuffleEnabled,
            repeatMode = state.repeatMode,
            isLiked = isLiked,
            onToggleLike = { trackActionsViewModel.toggleLike(track.id, isLiked) },
            onToggleShuffle = viewModel::toggleShuffle,
            onCycleRepeat = viewModel::cycleRepeat,
        )
        // Spec visibility rule: hide the chip when the only route is the
        // built-in speaker — no picker is useful with one option. Chip
        // reappears the moment a Bluetooth pair or wired plug arrives.
        if (shouldShowChip(routes)) {
            Spacer(Modifier.height(8.dp))
            DeviceChip(
                route = routes.current,
                onClick = onChipTapped,
                modifier = Modifier.align(Alignment.CenterHorizontally),
            )
        }
        Spacer(Modifier.height(4.dp))
        PlaybackControlsBlock(state = state, viewModel = viewModel)
    }
}

/**
 * Scrubber + transport pair, sharing the smoothed playback position.
 * Extracted from [NowPlayingContent] to keep that body under detekt's
 * LongMethod ceiling — the two rows belong together (the scrubber's
 * smoothed position would otherwise need to be hoisted into the
 * caller just to thread it into the row below).
 */
@Composable
private fun PlaybackControlsBlock(
    state: com.fabledsword.minstrel.player.PlayerUiState,
    viewModel: PlayerViewModel,
) {
    val smoothPositionMs by rememberSmoothPositionMs(
        positionMs = state.positionMs,
        durationMs = state.durationMs,
        isPlaying = state.isPlaying,
    )
    ScrubberRow(
        positionMs = smoothPositionMs,
        durationMs = state.durationMs,
        onSeek = viewModel::seekTo,
    )
    Spacer(Modifier.height(4.dp))
    TransportRow(
        isPlaying = state.isPlaying,
        isUpnpLoading = state.isUpnpLoading,
        onPrev = viewModel::skipToPrevious,
        onPlayPause = { if (state.isPlaying) viewModel.pause() else viewModel.play() },
        onNext = viewModel::skipToNext,
    )
}

/**
 * One-shot BLUETOOTH_CONNECT permission flow tied to picker-sheet
 * visibility. The launcher is remembered across recompositions; the
 * LaunchedEffect fires when the sheet opens and the permission is not
 * already granted. Subsequent opens are no-ops once the user has
 * answered — the system remembers their choice. Returns whether the
 * user explicitly denied, so the sheet can surface the rationale row.
 *
 * Extracted from [NowPlayingBody] to keep that body under detekt's
 * LongMethod ceiling — the permission plumbing is incidental to the
 * player layout.
 */
@Composable
private fun rememberBluetoothPermissionState(sheetVisible: Boolean): Boolean {
    val context = LocalContext.current
    var permissionDenied by remember { mutableStateOf(false) }
    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission(),
    ) { granted ->
        permissionDenied = !granted
    }
    LaunchedEffect(sheetVisible) {
        if (sheetVisible &&
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.BLUETOOTH_CONNECT,
            ) != PackageManager.PERMISSION_GRANTED
        ) {
            permissionLauncher.launch(Manifest.permission.BLUETOOTH_CONNECT)
        }
    }
    return permissionDenied
}

private fun shouldShowChip(snapshot: RouteSnapshot): Boolean {
    val onlyBuiltIn = snapshot.available.size == 1 &&
        snapshot.available.first().kind == OutputRoute.Kind.BuiltIn
    return !onlyBuiltIn
}

@Composable
private fun BottomActionsRow(
    navController: NavHostController,
    track: com.fabledsword.minstrel.models.TrackRef,
    shuffleEnabled: Boolean,
    repeatMode: RepeatMode,
    isLiked: Boolean,
    onToggleLike: () -> Unit,
    onToggleShuffle: () -> Unit,
    onCycleRepeat: () -> Unit,
) {
    val accent = MaterialTheme.colorScheme.primary
    val muted = MaterialTheme.colorScheme.onSurfaceVariant
    Row(
        horizontalArrangement = Arrangement.spacedBy(8.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        LikeButton(liked = isLiked, onToggle = onToggleLike)
        IconButton(onClick = onToggleShuffle) {
            Icon(
                imageVector = Lucide.Shuffle,
                contentDescription = if (shuffleEnabled) "Shuffle on" else "Shuffle off",
                tint = if (shuffleEnabled) accent else muted,
            )
        }
        IconButton(onClick = onCycleRepeat) {
            Icon(
                imageVector = if (repeatMode == RepeatMode.ONE) Lucide.Repeat1 else Lucide.Repeat,
                contentDescription = when (repeatMode) {
                    RepeatMode.OFF -> "Repeat off"
                    RepeatMode.ALL -> "Repeat all"
                    RepeatMode.ONE -> "Repeat one"
                },
                tint = if (repeatMode == RepeatMode.OFF) muted else accent,
            )
        }
        ViewQueueButton(onClick = { navController.navigate(Queue) })
        TrackActionsButton(
            track = track,
            hideQueueActions = true,
            // Single atomic transaction — navigate to the detail while
            // popping NowPlaying off the back stack, so there's no
            // intermediate frame on the shell tab underneath (Bug-5).
            onNavigateToAlbum = { id ->
                navController.navigate(AlbumDetail(id)) {
                    popUpTo(NowPlaying) { inclusive = true }
                }
            },
            onNavigateToArtist = { id ->
                navController.navigate(ArtistDetail(id)) {
                    popUpTo(NowPlaying) { inclusive = true }
                }
            },
        )
    }
}

@Composable
private fun ViewQueueButton(onClick: () -> Unit) {
    IconButton(onClick = onClick) {
        Icon(
            imageVector = Lucide.ListMusic,
            contentDescription = "View queue",
            tint = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

@OptIn(ExperimentalSharedTransitionApi::class)
@Composable
private fun NowPlayingCover(coverUrl: String, contentDescription: String) {
    val sharedScope = LocalSharedTransitionScope.current
    val animatedScope = LocalAnimatedContentScope.current
    val heroModifier = if (sharedScope != null && animatedScope != null) {
        with(sharedScope) {
            Modifier.sharedElement(
                sharedContentState = rememberSharedContentState(
                    key = HERO_KEY_NOW_PLAYING_COVER,
                ),
                animatedVisibilityScope = animatedScope,
            )
        }
    } else {
        Modifier
    }
    Box(
        modifier = heroModifier
            .widthIn(max = COVER_MAX_WIDTH_DP.dp)
            .fillMaxWidth()
            .aspectRatio(1f)
            .clip(RoundedCornerShape(FabledSwordFlatTokens.radiusLg))
            .background(MaterialTheme.colorScheme.surfaceVariant),
        contentAlignment = Alignment.Center,
    ) {
        ServerImage(
            url = coverUrl,
            contentDescription = contentDescription,
            modifier = Modifier.fillMaxSize(),
        ) {
            Icon(
                imageVector = Lucide.Music,
                contentDescription = null,
                tint = MaterialTheme.colorScheme.onSurfaceVariant,
                modifier = Modifier.size(96.dp),
            )
        }
    }
}

@Composable
private fun TrackHeader(title: String, artist: String, album: String) {
    Text(
        text = title,
        style = MaterialTheme.typography.headlineMedium,
        color = MaterialTheme.colorScheme.onBackground,
        textAlign = TextAlign.Center,
        maxLines = 2,
        overflow = TextOverflow.Ellipsis,
    )
    Text(
        text = artist,
        style = MaterialTheme.typography.titleMedium,
        color = MaterialTheme.colorScheme.onSurfaceVariant,
        textAlign = TextAlign.Center,
        maxLines = 1,
        overflow = TextOverflow.Ellipsis,
    )
    if (album.isNotEmpty()) {
        Text(
            text = album,
            style = MaterialTheme.typography.bodyMedium,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
            textAlign = TextAlign.Center,
            maxLines = 1,
            overflow = TextOverflow.Ellipsis,
        )
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ScrubberRow(positionMs: Long, durationMs: Long, onSeek: (Long) -> Unit) {
    val accent = MaterialTheme.colorScheme.primary
    val fraction = if (durationMs > 0) {
        (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
    } else {
        0f
    }
    val interactionSource = remember { MutableInteractionSource() }
    val sliderColors = SliderDefaults.colors(
        thumbColor = accent,
        activeTrackColor = accent,
        // M3 inactive default is secondaryContainer (purple baseline);
        // pin to surfaceVariant (= slate) for Flutter parity.
        inactiveTrackColor = MaterialTheme.colorScheme.surfaceVariant,
    )
    Slider(
        value = fraction,
        onValueChange = { newFraction ->
            if (durationMs > 0) onSeek((newFraction * durationMs).toLong())
        },
        modifier = Modifier.fillMaxWidth(),
        colors = sliderColors,
        interactionSource = interactionSource,
        // Thin vertical pill (4dp wide × 18dp tall, fully rounded).
        // A small circle on a thin horizontal bar reads as visually
        // off-center even when geometrically aligned — the eye
        // expects the bar to bisect the thumb but a 14dp circle's
        // mass extends above and below in equal amounts that the
        // brain perceives as offset. A vertical pill the same width
        // as the track removes the ambiguity: the bar passes through
        // the pill's horizontal axis cleanly. Also matches M3's
        // expressive-slider handle direction. State-layer halo is
        // still dropped for visual parity with the web scrubber.
        // Slider's 48dp hit slop still applies, so tapability is
        // unchanged.
        thumb = {
            Box(
                modifier = Modifier
                    .size(width = SCRUB_THUMB_WIDTH_DP, height = SCRUB_THUMB_HEIGHT_DP)
                    .clip(CircleShape)
                    .background(accent),
            )
        },
        // Custom 4dp rounded track — see ScrubTrack for rationale.
        track = { _ -> ScrubTrack(fraction = fraction, accent = accent) },
    )
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceBetween,
    ) {
        Text(
            text = formatDuration(positionMs.millisToSeconds()),
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
        Text(
            text = formatDuration(durationMs.millisToSeconds()),
            style = MaterialTheme.typography.labelSmall,
            color = MaterialTheme.colorScheme.onSurfaceVariant,
        )
    }
}

/**
 * Custom 4dp rounded scrubber track. M3's default Track is 16dp tall
 * and reads as a heavy pill rather than a measurement line; the
 * pill-thumb-on-thin-track pairing restores the "handle on a string"
 * cue your eye reads as "tool, draggable." Also drops M3's stop
 * indicator dot, which the web scrubber doesn't have. Extracted so
 * [ScrubberRow] stays under detekt's LongMethod ceiling.
 */
@Composable
private fun ScrubTrack(fraction: Float, accent: Color) {
    Box(modifier = Modifier.fillMaxWidth().height(SCRUB_TRACK_HEIGHT_DP)) {
        Box(
            modifier = Modifier
                .matchParentSize()
                .clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
                .background(MaterialTheme.colorScheme.surfaceVariant),
        )
        Box(
            modifier = Modifier
                .fillMaxWidth(fraction)
                .fillMaxHeight()
                .clip(RoundedCornerShape(SCRUB_TRACK_CORNER_DP))
                .background(accent),
        )
    }
}

@Composable
private fun TransportRow(
    isPlaying: Boolean,
    isUpnpLoading: Boolean,
    onPrev: () -> Unit,
    onPlayPause: () -> Unit,
    onNext: () -> Unit,
) {
    val actionColors = LocalActionColors.current
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically,
    ) {
        IconButton(onClick = onPrev) {
            Icon(
                imageVector = Lucide.SkipBack,
                contentDescription = "Previous",
                tint = MaterialTheme.colorScheme.onBackground,
                modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
            )
        }
        IconButton(onClick = onPlayPause, enabled = !isUpnpLoading) {
            if (isUpnpLoading) {
                CircularProgressIndicator(
                    modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
                    strokeWidth = 3.dp,
                )
            } else {
                Icon(
                    imageVector = if (isPlaying) Lucide.Pause else Lucide.Play,
                    contentDescription = if (isPlaying) "Pause" else "Play",
                    tint = actionColors.primary,
                    modifier = Modifier.size(PLAY_PAUSE_ICON_DP.dp),
                )
            }
        }
        IconButton(onClick = onNext) {
            Icon(
                imageVector = Lucide.SkipForward,
                contentDescription = "Next",
                tint = MaterialTheme.colorScheme.onBackground,
                modifier = Modifier.size(TRANSPORT_ICON_DP.dp),
            )
        }
    }
}

/**
 * Returns a key-event handler that intercepts volume-up/down when a UPnP
 * route is active and routes the step through [ActiveUpnp.rendering].
 * Volume is cached locally so rapid key presses don't each wait on a
 * getVolume() round-trip. Returns false (not consumed) for every event
 * when no UPnP route is active so the system handles volume normally.
 */
@Composable
private fun rememberUpnpVolumeKeyHandler(activeUpnp: ActiveUpnp?): (KeyEvent) -> Boolean {
    val scope = rememberCoroutineScope()
    val cache = remember(activeUpnp?.routeId) { VolumeCache() }
    return remember(activeUpnp) {
        handler@{ event: KeyEvent ->
            val rc = activeUpnp?.rendering ?: return@handler false
            if (event.type != KeyEventType.KeyDown) return@handler false
            val delta = when (event.key) {
                Key.VolumeUp -> VOLUME_KEY_STEP
                Key.VolumeDown -> -VOLUME_KEY_STEP
                else -> return@handler false
            }
            scope.launch {
                val current = cache.value ?: runCatching { rc.getVolume() }.getOrNull() ?: 0
                val next = (current + delta).coerceIn(VOLUME_MIN_PERCENT, VOLUME_MAX_PERCENT)
                cache.value = next
                runCatching { rc.setVolume(next) }
            }
            true
        }
    }
}

private class VolumeCache(var value: Int? = null)

private const val VOLUME_KEY_STEP = 5
private const val VOLUME_MIN_PERCENT = 0
private const val VOLUME_MAX_PERCENT = 100
