diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerOverlays.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerOverlays.kt index dde324a..5151e39 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerOverlays.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerOverlays.kt @@ -1,82 +1,14 @@ -@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class) package com.fluxa.app.ui.catalog -import com.fluxa.app.common.AppStrings -import com.fluxa.app.data.local.* -import com.fluxa.app.data.remote.* -import com.fluxa.app.data.repository.* +import com.fluxa.app.data.remote.Meta import com.fluxa.app.core.rust.FluxaCoreNative -import com.fluxa.app.domain.discovery.* import com.fluxa.app.shared.feature.player.MobilePlayerUIContent import com.fluxa.app.shared.feature.player.PlayerContentUiModel import com.fluxa.app.shared.feature.player.TvPlayerUIContent -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.runtime.Composable import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.* -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import com.fluxa.app.player.MediaTrack -import java.util.Locale - -private fun String?.isTorrentPlaybackUrlForOverlay(): Boolean { - return FluxaCoreNative.isTorrentPlaybackUrl(this) -} - -private fun isEnglishUi(lang: String?): Boolean { - return lang?.substringBefore('-')?.substringBefore('_')?.equals("en", ignoreCase = true) == true -} - -internal fun playerText(lang: String?, key: String): String { - return AppStrings.t(lang, "player.$key") -} - -internal fun playerStatusText(lang: String?, value: String): String { - return if (value.startsWith("player.")) AppStrings.t(lang, value) else value -} +import androidx.compose.ui.graphics.Color internal suspend fun resolveIntroImdbId( viewModel: HomeViewModel, @@ -91,330 +23,6 @@ internal fun extractSeasonEpisode(videoId: String?): Pair? { return FluxaCoreNative.parseEpisodeLocator(videoId)?.let { it.season to it.episode } } -@Composable -internal fun SkipSegmentCard( - deviceType: DeviceType, - type: String, - nextEpisode: Video? = null, - lang: String? = "en", - autoAdvanceSeconds: Int? = null, - onSkip: () -> Unit, - onDismiss: () -> Unit -) { - if (type == "outro" && nextEpisode != null) { - NextEpisodeSkipCard( - deviceType = deviceType, - episode = nextEpisode, - lang = lang, - autoAdvanceSeconds = autoAdvanceSeconds, - onSkip = onSkip - ) - return - } - val label = when (type) { - "intro" -> playerText(lang, "skip_intro") - "outro" -> playerText(lang, "finish_episode") - "recap" -> playerText(lang, "skip_recap") - else -> playerText(lang, "skip") - } - val focusRequester = remember { FocusRequester() } - var isFocused by remember { mutableStateOf(false) } - LaunchedEffect(deviceType) { - if (deviceType == DeviceType.TV) focusRequester.requestFocus() - } - Box( - modifier = Modifier - .widthIn(min = if (deviceType == DeviceType.Mobile) 108.dp else 160.dp) - .clip(RoundedCornerShape(10.dp)) - .background(Color.White) - .then( - if (deviceType == DeviceType.TV) { - Modifier.border(2.dp, if (isFocused) FluxaColors.accent else Color.Transparent, RoundedCornerShape(10.dp)) - } else { - Modifier - } - ) - .focusRequester(focusRequester) - .onFocusChanged { isFocused = it.isFocused } - .focusable() - .clickable { onSkip() } - .padding(horizontal = if (deviceType == DeviceType.Mobile) 16.dp else 28.dp, vertical = if (deviceType == DeviceType.Mobile) 8.dp else 13.dp), - contentAlignment = Alignment.Center - ) { - Text( - text = label, - color = Color.Black, - fontWeight = FontWeight.SemiBold, - fontSize = if (deviceType == DeviceType.Mobile) 13.sp else 16.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } -} - -@Composable -private fun NextEpisodeSkipCard( - deviceType: DeviceType, - episode: Video, - lang: String?, - autoAdvanceSeconds: Int? = null, - onSkip: () -> Unit -) { - var remainingSeconds by remember(episode.id, autoAdvanceSeconds) { mutableStateOf(autoAdvanceSeconds) } - LaunchedEffect(episode.id, autoAdvanceSeconds) { - var remaining = autoAdvanceSeconds ?: return@LaunchedEffect - while (remaining > 0) { - kotlinx.coroutines.delay(1000) - remaining -= 1 - remainingSeconds = remaining - } - onSkip() - } - val thumbnailSize = if (deviceType == DeviceType.Mobile) 46.dp else 74.dp - val cardWidth = if (deviceType == DeviceType.Mobile) 240.dp else 364.dp - val focusRequester = remember { FocusRequester() } - var isFocused by remember { mutableStateOf(false) } - LaunchedEffect(deviceType) { - if (deviceType == DeviceType.TV) focusRequester.requestFocus() - } - Row( - modifier = Modifier - .width(cardWidth) - .clip(RoundedCornerShape(if (deviceType == DeviceType.Mobile) 12.dp else 14.dp)) - .background(Color.Black.copy(alpha = 0.82f)) - .border( - 1.dp, - if (deviceType == DeviceType.TV && isFocused) FluxaColors.accent else Color.White.copy(alpha = 0.16f), - RoundedCornerShape(if (deviceType == DeviceType.Mobile) 12.dp else 14.dp) - ) - .focusRequester(focusRequester) - .onFocusChanged { isFocused = it.isFocused } - .focusable() - .clickable { onSkip() } - .padding(if (deviceType == DeviceType.Mobile) 7.dp else 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(if (deviceType == DeviceType.Mobile) 10.dp else 12.dp) - ) { - AsyncImage( - model = episode.thumbnail, - contentDescription = null, - modifier = Modifier - .size(thumbnailSize) - .clip(RoundedCornerShape(if (deviceType == DeviceType.Mobile) 8.dp else 10.dp)) - .background(Color.White.copy(alpha = 0.08f)), - contentScale = ContentScale.Crop - ) - Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = remainingSeconds?.let { "${AppStrings.t(lang, "auto.next_episode").uppercase(Locale.ROOT)} · ${it}s" } - ?: AppStrings.t(lang, "auto.next_episode").uppercase(Locale.ROOT), - color = Color.White, - fontWeight = FontWeight.Black, - fontSize = if (deviceType == DeviceType.Mobile) 10.sp else 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = nextEpisodeSubtitle(lang, episode), - color = Color.White.copy(alpha = 0.68f), - fontWeight = FontWeight.Bold, - fontSize = if (deviceType == DeviceType.Mobile) 10.sp else 13.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - Icon( - FluxaIcons.KeyboardArrowRight, - null, - tint = Color.White.copy(alpha = 0.92f), - modifier = Modifier.size(if (deviceType == DeviceType.Mobile) 20.dp else 28.dp) - ) - } -} - -private fun nextEpisodeSubtitle(lang: String?, episode: Video): String { - val season = episode.season ?: 1 - val number = episode.number ?: 0 - val name = episode.name.orEmpty().trim() - return if (name.isBlank()) { - AppStrings.format(lang, "player.next_episode_number_format", season, number) - } else { - AppStrings.format(lang, "player.next_episode_detail_format", season, number, name) - } -} - -@Composable -internal fun SegmentSkipChevronFeedback() { - val transition = rememberInfiniteTransition(label = "segmentSkip") - val phase by transition.animateFloat( - initialValue = 0f, - targetValue = 1f, - animationSpec = infiniteRepeatable( - animation = tween(FluxaDimensions.AnimDuration.nextEpisode, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Restart - ), - label = "segmentSkipPhase" - ) - Canvas(modifier = Modifier.size(150.dp, 96.dp)) { - val stroke = size.minDimension * 0.09f - val centerY = size.height / 2f - val startX = size.width * 0.30f - repeat(3) { index -> - val local = ((phase + index * 0.22f) % 1f) - val alpha = 0.18f + local * 0.48f - val x = startX + index * size.width * 0.18f + local * size.width * 0.05f - drawLine( - color = Color.White.copy(alpha = alpha), - start = Offset(x - size.width * 0.055f, centerY - size.height * 0.14f), - end = Offset(x + size.width * 0.055f, centerY), - strokeWidth = stroke, - cap = StrokeCap.Round - ) - drawLine( - color = Color.White.copy(alpha = alpha), - start = Offset(x + size.width * 0.055f, centerY), - end = Offset(x - size.width * 0.055f, centerY + size.height * 0.14f), - strokeWidth = stroke, - cap = StrokeCap.Round - ) - } - } -} - -@Composable -internal fun ArtisticLoadingOverlay(bg: String, logo: String, title: String, status: com.fluxa.app.player.TorrentStreamStatus, deviceType: DeviceType, buffer: BufferSnapshot = BufferSnapshot(), error: String? = null, currentUrl: String?, isSwitchingAudioSource: Boolean = false, currentSourceIdx: Int = 0, totalSources: Int = 0, playback: PlaybackSnapshot = PlaybackSnapshot(), hasRenderedFirstFrame: Boolean = false, lang: String? = "en") { - val startupLoading = !hasRenderedFirstFrame - Box( - modifier = Modifier - .fillMaxSize() - .background(if (startupLoading) Color.Black else Color.Transparent) - ) { - if (startupLoading && bg.isNotEmpty()) { - AsyncImage( - bg, - null, - modifier = Modifier - .fillMaxSize() - .alpha(if (deviceType == DeviceType.TV) 0.35f else 0.30f), - contentScale = ContentScale.Crop - ) - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.34f)) - ) - } - val currentUrlStr = currentUrl ?: "" - val isTorrent = currentUrlStr.isTorrentPlaybackUrlForOverlay() || currentUrlStr.contains(".torrent") - val byteProgress = buffer.loadProgress > 0.015f || - buffer.bufferPercent > 0 || - status.bufferProgress > 0 || - status.downloadSpeed > 0.0 - - val rebufferProgress = when { - status.bufferProgress > 0 -> (status.bufferProgress / 100f).coerceIn(0f, 1f) - buffer.bufferPercent > 0 -> (buffer.bufferPercent.toFloat() / 100f).coerceIn(0f, 1f) - buffer.seekbarBufferedProgress > 0f -> buffer.seekbarBufferedProgress.coerceIn(0f, 1f) - else -> 0f - } - val activeRebuffer = hasRenderedFirstFrame && playback.isBuffering - val rawTargetProgress = when { - activeRebuffer -> rebufferProgress - hasRenderedFirstFrame && playback.hasStartedPlaying && !playback.isBuffering -> 1.0f - buffer.loadProgress > 0f -> buffer.loadProgress.coerceIn(0f, 1f) - status.bufferProgress > 0 -> (status.bufferProgress / 100f).coerceIn(0f, 1f) - !isTorrent && buffer.bufferPercent > 0 -> (buffer.bufferPercent.toFloat() / 100f).coerceIn(0f, 1f) - else -> 0f - } - val targetProgress = when { - startupLoading && rawTargetProgress > 0f -> rawTargetProgress.coerceAtMost(0.92f) - activeRebuffer && rawTargetProgress > 0f -> rawTargetProgress.coerceAtMost(0.96f) - else -> rawTargetProgress - } - val loadProgress by animateFloatAsState( - targetValue = targetProgress, - animationSpec = tween(FluxaDimensions.AnimDuration.progressRing, easing = FastOutSlowInEasing), - label = "logoLoadProgress" - ) - val useBreathe = startupLoading && !byteProgress && loadProgress <= 0.015f && targetProgress <= 0.015f - val hasProgress = !useBreathe && (activeRebuffer || byteProgress || loadProgress > 0.015f || targetProgress > 0.015f) - val visibleLoadProgress = if (hasProgress) maxOf(loadProgress, if (activeRebuffer) 0.08f else 0.045f) else 0f - val breatheTransition = rememberInfiniteTransition(label = "loadingLogoBreathe") - val breatheAlpha by breatheTransition.animateFloat( - initialValue = 0.42f, - targetValue = 0.66f, - animationSpec = infiniteRepeatable( - animation = tween(FluxaDimensions.AnimDuration.marquee, easing = FastOutSlowInEasing), - repeatMode = RepeatMode.Reverse - ), - label = "loadingLogoAlpha" - ) - val containerWidth = if (deviceType == DeviceType.TV) 500.dp else 280.dp - Column(modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { - Box( - modifier = Modifier - .width(containerWidth) - .height(200.dp), - contentAlignment = Alignment.Center - ) { - var logoFailed by remember { mutableStateOf(false) } - - when { - logo.isEmpty() || logoFailed -> { - val spinnerAlpha = if (hasProgress) 0.18f + 0.72f * loadProgress.coerceIn(0f, 1f) else breatheAlpha - CircularProgressIndicator( - color = Color.White.copy(alpha = spinnerAlpha), - strokeWidth = 3.dp, - modifier = Modifier.size(52.dp) - ) - } - !hasProgress -> { - AsyncImage( - model = logo, - contentDescription = null, - modifier = Modifier.fillMaxSize().alpha(breatheAlpha), - contentScale = ContentScale.Fit, - onError = { logoFailed = true } - ) - } - else -> { - AsyncImage( - model = logo, - contentDescription = null, - modifier = Modifier.fillMaxSize().alpha(0.18f), - contentScale = ContentScale.Fit, - onError = { logoFailed = true } - ) - val revealProgress = visibleLoadProgress.coerceIn(0f, 1f) - AsyncImage( - model = logo, - contentDescription = null, - modifier = Modifier - .fillMaxSize() - .drawWithContent { - clipRect(right = size.width * revealProgress) { - this@drawWithContent.drawContent() - } - }, - contentScale = ContentScale.Fit, - onError = { logoFailed = true } - ) - } - } - } - } - - if (error != null) { - Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.8f)), contentAlignment = Alignment.Center) { - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Icon(FluxaIcons.ErrorOutline, null, tint = Color.White, modifier = Modifier.size(64.dp).padding(bottom = 16.dp)) - Text(text = error, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 64.dp)) - } - } - } - } -} - @Composable internal fun PlayerUIContent( content: PlayerContentUiModel, lang: String, duration: Long, position: Long, bufferedFraction: Float, chapters: List = emptyList(), isPlaying: Boolean, isBuffering: Boolean, hasStartedPlaying: Boolean, deviceType: DeviceType, @@ -532,13 +140,3 @@ internal fun PlayerUIContent( onClose = onClose ) } - -@Composable -fun VolumeBar(current: Int, max: Int) { - val progress = current.toFloat() / max.toFloat() - Row(modifier = Modifier.background(Color(0xB010141A), RoundedCornerShape(18.dp)).padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Icon(imageVector = when { progress == 0f -> FluxaIcons.VolumeMute; progress < 0.5f -> FluxaIcons.VolumeDown; else -> FluxaIcons.VolumeUp }, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) - Box(modifier = Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.2f), CircleShape)) { Box(modifier = Modifier.fillMaxWidth(progress).fillMaxHeight().background(Color.White, CircleShape)) } - Text(text = "${(progress * 100).toInt()}%", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) - } -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSideEffects.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSideEffects.kt index e6bdef3..02f62ca 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSideEffects.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSideEffects.kt @@ -17,6 +17,7 @@ import com.fluxa.app.data.remote.Meta import com.fluxa.app.data.remote.Stream import com.fluxa.app.data.remote.Video import com.fluxa.app.data.repository.TraktIntegration +import com.fluxa.app.shared.feature.player.withCurrentEpisodeArtwork import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt index 269ebc7..268312b 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerPlaybackSurface.kt @@ -47,8 +47,16 @@ import com.fluxa.app.player.MpvAndroidSurfaceView import com.fluxa.app.player.MpvEmbeddedPlayer import com.fluxa.app.player.PlayerEngine import com.fluxa.app.player.TorrentStreamStatus +import com.fluxa.app.core.rust.FluxaCoreNative +import com.fluxa.app.shared.feature.player.ArtisticLoadingOverlay +import com.fluxa.app.shared.feature.player.MarkSegmentSidebar import com.fluxa.app.shared.feature.player.PlayerContentUiModel +import com.fluxa.app.shared.feature.player.PlayerSkipSegmentOverlay import com.fluxa.app.shared.feature.player.PlayerTopIconButton +import com.fluxa.app.shared.feature.player.PlayerTransientOverlays +import com.fluxa.app.shared.feature.player.SourceSidebar +import com.fluxa.app.shared.feature.player.UniversalSettingsSidebar +import com.fluxa.app.shared.feature.player.ZoomOverlayMode private data class ExoSurfaceConfig( val resizeMode: Int, @@ -250,7 +258,7 @@ internal fun BoxScope.PlayerPlaybackSurface( (!render.isVideoRendered && !(useMpvBackend && playback.hasStartedPlaying)) || (playback.hasStartedPlaying && playback.isBuffering) if (showLoadingOverlay) { - ArtisticLoadingOverlay(content.background, content.logo, content.title, torrentStatus, deviceType, buffer, playerError, currentUrl, isSwitchingAudioSource, currentSourceIdx = currentStreamIndex + 1, totalSources = currentStreamsSize, playback, hasRenderedFirstFrame = render.isVideoRendered, lang = lang) + ArtisticLoadingOverlay(content.background, content.logo, content.title, torrentStatus, deviceType, buffer, playerError, currentUrl, isSwitchingAudioSource, currentSourceIdx = currentStreamIndex + 1, totalSources = currentStreamsSize, playback, hasRenderedFirstFrame = render.isVideoRendered, lang = lang, isTorrentUrl = FluxaCoreNative.isTorrentPlaybackUrl(currentUrl)) Box( modifier = Modifier .align(Alignment.TopStart) @@ -368,7 +376,7 @@ internal fun BoxScope.PlayerPlaybackSurface( PlayerTransientOverlays( showSegmentSkipFeedback = showSegmentSkipFeedback, holdSpeedVisible = holdSpeedVisible, - activeProfile = activeProfile, + holdSpeed = activeProfile?.safeHoldSpeed ?: 2f, deviceType = deviceType, showVolumeBar = showVolumeBar, currentVolume = currentVolume, @@ -537,6 +545,7 @@ internal fun PlayerSettingsPanel( onSubtitleOutlineOpacityChange = onSubtitleOutlineOpacityChange, deviceType = deviceType, lang = lang, + languageDisplayName = ::nativeLanguageName, onClose = onCloseSettings ) } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt index e134f2e..b3aef8d 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreen.kt @@ -26,6 +26,7 @@ import com.fluxa.app.data.remote.Stream import com.fluxa.app.player.* import com.fluxa.app.player.MediaPlayerController import com.fluxa.app.player.MediaTrack +import com.fluxa.app.shared.feature.player.dismissKey import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt index 89a0a44..d77c25a 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerScreenContent.kt @@ -34,6 +34,9 @@ import com.fluxa.app.player.MediaTrack import com.fluxa.app.player.MpvEmbeddedPlayer import com.fluxa.app.player.PlayerEngine import com.fluxa.app.player.TorrentStreamStatus +import com.fluxa.app.shared.feature.player.dismissKey +import com.fluxa.app.shared.feature.player.playerInputControls +import com.fluxa.app.shared.feature.player.playerText private fun markSegmentCooldownRemainingSec(state: PlayerScreenState, meta: Meta): Long? { val type = state.markSegmentType ?: return null @@ -135,9 +138,10 @@ internal fun PlayerScreenContent( deviceType = deviceType, hasStartedPlaying = state.engine.playback.hasStartedPlaying, showControls = state.showControls, - activeProfile = activeProfile, - activeEngine = activeEngine, + holdToSpeedEnabled = activeProfile?.safeHoldToSpeedEnabled != false, + holdSpeed = activeProfile?.safeHoldSpeed ?: 2f, playbackSpeed = state.playbackSpeed, + onSetSpeed = { activeEngine?.setSpeed(it) }, onRaiseVolume = { audioManager.adjustStreamVolume(android.media.AudioManager.STREAM_MUSIC, android.media.AudioManager.ADJUST_RAISE, 0) state.currentVolume = audioManager.getStreamVolume(android.media.AudioManager.STREAM_MUSIC) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt index a8a3f73..71a9fae 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSidebarUtils.kt @@ -1,6 +1,5 @@ package com.fluxa.app.ui.catalog -import com.fluxa.app.data.remote.Meta import java.util.Locale internal fun nativeLanguageName(code: String): String { @@ -9,9 +8,3 @@ internal fun nativeLanguageName(code: String): String { val native = locale.getDisplayLanguage(locale).trim() return native.takeIf { it.isNotBlank() }?.replaceFirstChar { it.titlecase(locale) } ?: code } - -internal fun Meta.withCurrentEpisodeArtwork(artwork: String?): Meta { - val episodeArtwork = artwork?.takeIf { it.isNotBlank() } ?: return this - if (type != "series") return this - return copy(continueWatchingPoster = episodeArtwork, continueWatchingBackground = episodeArtwork) -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSourceSidebars.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSourceSidebars.kt index c58c7c1..b20b71f 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSourceSidebars.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSourceSidebars.kt @@ -2,90 +2,40 @@ package com.fluxa.app.ui.catalog import com.fluxa.app.common.AppStrings -import com.fluxa.app.data.local.* -import com.fluxa.app.data.remote.* -import com.fluxa.app.data.repository.* -import com.fluxa.app.domain.discovery.* +import com.fluxa.app.data.local.UserProfile +import com.fluxa.app.data.local.safeAccentColorArgb +import com.fluxa.app.data.local.safeLanguage +import com.fluxa.app.data.remote.Meta +import com.fluxa.app.data.remote.Video +import com.fluxa.app.shared.feature.player.PlayerSidebarShell +import com.fluxa.app.shared.feature.player.TrackItem -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.* -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import com.fluxa.app.player.MediaTrack -import java.util.Locale - -@Composable -fun SourceSidebar(streams: List, currentUrl: String, deviceType: DeviceType, lang: String = "en", onSelect: (String) -> Unit, onClose: (() -> Unit)? = null) { - PlayerSidebarShell( - title = AppStrings.t(lang, "player.source_selection_title"), - subtitle = AppStrings.t(lang, "player.source_selection_subtitle"), - deviceType = deviceType, - onClose = onClose, - sideSheetOnMobile = false - ) { - LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { - items(streams, key = { it.playableUrl ?: (it.title.orEmpty() + it.name.orEmpty()) }) { stream -> - val playableUrl = stream.playableUrl - TrackItem( - modifier = Modifier.animateItem(), - title = stream.streamSourceHeader(), - isSelected = stream.playableUrl == currentUrl, - onClick = { playableUrl?.let(onSelect) }, - subtitle = stream.streamRawBody(), - badge = null, - deviceType = deviceType, - leadingIcon = FluxaIcons.PlayArrow - ) - } - } - } -} @Composable fun EpisodeSidebar( @@ -242,293 +192,3 @@ fun EpisodeSidebar( } } } - -@Composable -fun PlayerSidebarShell( - title: String, - subtitle: String, - deviceType: DeviceType, - onClose: (() -> Unit)? = null, - sideSheetOnMobile: Boolean = false, - compactCenterOnMobile: Boolean = false, - content: @Composable ColumnScope.() -> Unit -) { - var shown by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { shown = true } - val panelAlpha by animateFloatAsState(if (shown) 1f else 0f, animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha), label = "sidebarAlpha") - val panelOffset by animateFloatAsState(if (shown) 0f else 44f, animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing), label = "sidebarOffset") - val isMobile = deviceType == DeviceType.Mobile - val panelShape = if (isMobile) { - if (compactCenterOnMobile) { - RoundedCornerShape(24.dp) - } else if (sideSheetOnMobile) { - RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp, topEnd = 0.dp, bottomEnd = 0.dp) - } else { - RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 0.dp, bottomEnd = 0.dp) - } - } else { - RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp, topEnd = 0.dp, bottomEnd = 0.dp) - } - val panelSizeModifier = when { - isMobile && compactCenterOnMobile -> Modifier - .fillMaxWidth(0.74f) - .widthIn(max = 340.dp) - .wrapContentHeight() - .heightIn(min = 160.dp, max = 390.dp) - isMobile && sideSheetOnMobile -> Modifier - .fillMaxHeight() - .fillMaxWidth(0.46f) - .widthIn(min = 300.dp, max = 520.dp) - isMobile -> Modifier - .fillMaxWidth(0.92f) - .widthIn(max = 430.dp) - .wrapContentHeight() - .heightIn(min = 180.dp, max = 520.dp) - else -> Modifier - .widthIn(min = 300.dp, max = 420.dp) - .wrapContentHeight() - .heightIn(max = 620.dp) - } - - Box(modifier = Modifier.fillMaxSize().zIndex(100f)) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.48f)) - .clickable( - interactionSource = remember { androidx.compose.foundation.interaction.MutableInteractionSource() }, - indication = null - ) { onClose?.invoke() } - ) - - Column( - modifier = Modifier - .align( - if (isMobile) { - if (compactCenterOnMobile) Alignment.Center else if (sideSheetOnMobile) Alignment.CenterEnd else Alignment.BottomCenter - } else { - Alignment.CenterEnd - } - ) - .then(panelSizeModifier) - .graphicsLayer { - alpha = panelAlpha - translationY = if (isMobile && !sideSheetOnMobile) panelOffset else 0f - translationX = if (!isMobile || sideSheetOnMobile) panelOffset else 0f - } - .background( - brush = if (isMobile && !sideSheetOnMobile) { - Brush.verticalGradient( - listOf(Color(0xFF151A22).copy(alpha = 0.99f), Color(0xFF0D1218).copy(alpha = 0.99f)) - ) - } else { - Brush.verticalGradient( - listOf(Color(0xFF121922).copy(alpha = 0.98f), Color(0xFF0A0F15).copy(alpha = 0.98f)) - ) - }, - shape = panelShape - ) - .border( - BorderStroke(1.dp, Brush.horizontalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.Transparent))), - shape = panelShape - ) - .clip(panelShape) - .then(if (isMobile && !sideSheetOnMobile) Modifier.navigationBarsPadding() else Modifier.navigationBarsPadding()) - .padding(if (deviceType == DeviceType.TV) 16.dp else if (isMobile && !sideSheetOnMobile) 18.dp else 16.dp) - ) { - if (isMobile && sideSheetOnMobile) { - Box( - modifier = Modifier - .align(Alignment.CenterHorizontally) - .padding(bottom = 14.dp) - .width(52.dp) - .height(5.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.16f)) - ) - } - Row( - modifier = Modifier.fillMaxWidth().padding(bottom = 14.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - color = Color.White, - fontSize = if (deviceType == DeviceType.TV) 18.sp else 17.sp, - fontWeight = FontWeight.Black, - letterSpacing = 0.8.sp - ) - if (subtitle.isNotBlank()) { - Spacer(Modifier.height(8.dp)) - Text( - text = subtitle, - color = Color.White.copy(alpha = 0.58f), - fontSize = if (deviceType == DeviceType.TV) 11.sp else 10.sp, - lineHeight = 14.sp - ) - } - } - - if (onClose != null) { - Box( - modifier = Modifier - .size(if (deviceType == DeviceType.TV) 38.dp else 34.dp) - .clip(CircleShape) - .background(Color.White.copy(alpha = 0.08f)) - .clickable { onClose() }, - contentAlignment = Alignment.Center - ) { - Icon(FluxaIcons.Close, null, tint = Color.White, modifier = Modifier.size(if (deviceType == DeviceType.TV) 20.dp else 16.dp)) - } - } - } - content() - } - } -} - -@Composable -fun TrackItem( - modifier: Modifier = Modifier, - title: String, - isSelected: Boolean, - onClick: () -> Unit, - subtitle: String? = null, - badge: String? = null, - formatBadge: (@Composable () -> Unit)? = null, - deviceType: DeviceType? = null, - leadingIcon: ImageVector? = null -) { - val resolvedDeviceType = deviceType ?: LocalDeviceType.current - var isFocused by remember { mutableStateOf(false) } - val bgColor by animateColorAsState( - targetValue = when { - isSelected -> Color.White - isFocused -> Color.White.copy(alpha = 0.18f) - else -> Color.White.copy(alpha = 0.04f) - }, - animationSpec = tween(FluxaDimensions.AnimDuration.heroSnap), - label = "bg" - ) - val textColor = if (isSelected) Color.Black else Color.White - val secondaryTextColor = if (isSelected) Color.Black.copy(alpha = 0.58f) else Color.White.copy(alpha = 0.56f) - val iconColor = if (isSelected) Color.Black else Color.White - val iconBackgroundColor = if (isSelected) Color.Black.copy(alpha = 0.08f) else Color.White.copy(alpha = 0.07f) - val badgeBackgroundColor = if (isSelected) Color.Black.copy(alpha = 0.08f) else Color.White.copy(alpha = 0.1f) - val scale by animateFloatAsState( - targetValue = when { - isSelected -> 1.015f - isFocused -> 1.01f - else -> 1f - }, - animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing), - label = "trackScale" - ) - val minHeight = if (subtitle.isNullOrBlank()) { - if (resolvedDeviceType == DeviceType.TV) 70.dp else 68.dp - } else { - if (resolvedDeviceType == DeviceType.TV) 88.dp else 84.dp - } - - Box( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = minHeight) - .graphicsLayer { - scaleX = scale - scaleY = scale - } - .animateContentSize(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing)) - .clip(RoundedCornerShape(if (resolvedDeviceType == DeviceType.TV) 18.dp else 16.dp)) - .background(bgColor) - .clickable { onClick() } - .onFocusChanged { isFocused = it.isFocused } - .focusable(), - contentAlignment = Alignment.CenterStart - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 18.dp, vertical = 14.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - if (leadingIcon != null) { - Box( - modifier = Modifier - .size(if (resolvedDeviceType == DeviceType.TV) 36.dp else 34.dp) - .clip(RoundedCornerShape(12.dp)) - .background(iconBackgroundColor), - contentAlignment = Alignment.Center - ) { - Icon(leadingIcon, null, tint = iconColor, modifier = Modifier.size(20.dp)) - } - Spacer(modifier = Modifier.width(14.dp)) - } - - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = title, - modifier = Modifier.weight(1f, fill = false), - color = textColor, - fontWeight = if (isSelected) FontWeight.ExtraBold else FontWeight.Bold, - fontSize = if (resolvedDeviceType == DeviceType.TV) 15.sp else 14.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - if (!badge.isNullOrBlank()) { - Box( - modifier = Modifier - .clip(CircleShape) - .background(badgeBackgroundColor) - .padding(horizontal = 10.dp, vertical = 4.dp) - ) { - Text( - text = badge, - color = textColor, - fontSize = 10.sp, - fontWeight = FontWeight.Black, - maxLines = 1 - ) - } - } - } - if (!subtitle.isNullOrBlank() || formatBadge != null) { - Spacer(modifier = Modifier.height(5.dp)) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) - ) { - if (!subtitle.isNullOrBlank()) { - Text( - text = subtitle, - color = secondaryTextColor, - fontWeight = FontWeight.Medium, - fontSize = 11.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - lineHeight = 15.sp - ) - } - if (formatBadge != null) { - formatBadge() - } - } - } - } - - if (isSelected) { - Spacer(modifier = Modifier.width(12.dp)) - Icon( - imageVector = FluxaIcons.CheckCircle, - contentDescription = null, - tint = iconColor, - modifier = Modifier.size(20.dp) - ) - } - } - } -} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackMemoryEffects.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackMemoryEffects.kt index 8ec8350..0f4d75d 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackMemoryEffects.kt +++ b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackMemoryEffects.kt @@ -12,6 +12,7 @@ import com.fluxa.app.data.remote.Meta import com.fluxa.app.data.remote.Stream import com.fluxa.app.player.MediaTrack import com.fluxa.app.player.PlayerEngine +import com.fluxa.app.shared.feature.player.withCurrentEpisodeArtwork import java.util.Locale @Composable diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackSidebars.kt b/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackSidebars.kt deleted file mode 100644 index d746d93..0000000 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerTrackSidebars.kt +++ /dev/null @@ -1,121 +0,0 @@ -@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class) -package com.fluxa.app.ui.catalog - -import com.fluxa.app.common.AppStrings -import com.fluxa.app.data.local.* -import com.fluxa.app.data.remote.* -import com.fluxa.app.data.repository.* -import com.fluxa.app.domain.discovery.* -import com.fluxa.app.shared.feature.player.SeekIconButton - -import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.* -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import com.fluxa.app.player.MediaTrack -import java.util.Locale - -@Composable -fun TrackSidebar(title: String, tracks: List, selected: MediaTrack?, deviceType: DeviceType, lang: String = "en", onSelect: (MediaTrack) -> Unit) { - PlayerSidebarShell( - title = title, - subtitle = AppStrings.t(lang, "player.choose_preferred_source"), - deviceType = deviceType - ) { - LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { - items(tracks, key = { it.id }) { track -> - TrackItem( - title = track.label, - isSelected = track == selected, - onClick = { onSelect(track) }, - subtitle = track.language?.let { nativeLanguageName(it) }, - deviceType = deviceType - ) - } - } - } -} - -@Composable -fun QuickSettingsSidebar(profile: UserProfile?, onUpdateProfile: (UserProfile) -> Unit, currentOffset: Long, onOffsetChange: (Long) -> Unit, deviceType: DeviceType, lang: String = profile?.safeLanguage ?: "en", onClose: () -> Unit) { - PlayerSidebarShell( - title = AppStrings.t(lang, "player.quick_settings_title"), - subtitle = AppStrings.t(lang, "player.quick_settings_subtitle"), - deviceType = deviceType, - onClose = onClose - ) { - Text(AppStrings.t(lang, "player.subtitle_sync_title"), color = Color.White.copy(alpha = 0.62f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 12.dp)) - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(22.dp)) - .background(Color.White.copy(alpha = 0.04f)) - .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(22.dp)) - .padding(18.dp) - ) { - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { - SeekIconButton(FluxaIcons.Remove, deviceType) { onOffsetChange(currentOffset - 500) } - Text(text = "${if(currentOffset >= 0) "+" else ""}${currentOffset/1000.0}s", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Black, modifier = Modifier.weight(1f), textAlign = TextAlign.Center) - SeekIconButton(FluxaIcons.Add, deviceType) { onOffsetChange(currentOffset + 500) } - } - } - - Spacer(Modifier.height(8.dp)) - } -} - -@Composable -fun PlayerPremiumToggle(title: String, desc: String, isEnabled: Boolean, onToggle: () -> Unit) { - Box(modifier = Modifier.fillMaxWidth().height(82.dp).clip(RoundedCornerShape(22.dp)).background(Color.White.copy(alpha = 0.05f)).border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(22.dp)).clickable { onToggle() }.padding(horizontal = 20.dp), contentAlignment = Alignment.CenterStart) { - Row(verticalAlignment = Alignment.CenterVertically) { - Column(modifier = Modifier.weight(1f)) { - Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold) - Text(desc, color = Color.White.copy(alpha = 0.56f), fontSize = 12.sp) - } - Switch(checked = isEnabled, onCheckedChange = { onToggle() }, colors = SwitchDefaults.colors(checkedThumbColor = FluxaColors.accent, checkedTrackColor = FluxaColors.accent)) - } - } -} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 9d44b24..3db7af8 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -65,6 +65,7 @@ kotlin { commonTest.dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.coroutines.test) + implementation(libs.kotlinx.serialization.json) } androidMain { dependencies { diff --git a/player/src/androidMain/kotlin/com/fluxa/app/player/TorrentStreamManager.kt b/player/src/androidMain/kotlin/com/fluxa/app/player/TorrentStreamManager.kt index 0c9b7e4..ec4cafb 100644 --- a/player/src/androidMain/kotlin/com/fluxa/app/player/TorrentStreamManager.kt +++ b/player/src/androidMain/kotlin/com/fluxa/app/player/TorrentStreamManager.kt @@ -23,14 +23,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import java.util.concurrent.TimeUnit -data class TorrentStreamStatus( - val bufferProgress: Int = 0, - val detailedStatus: String = "", - val downloadSpeed: Double = 0.0, - val activePeers: Int = 0, - val totalPeers: Int = 0 -) - sealed class TorrentStreamResult { data class Success(val url: String) : TorrentStreamResult() data class Error(val message: String) : TorrentStreamResult() diff --git a/player/src/commonMain/kotlin/com/fluxa/app/player/TorrentStreamStatus.kt b/player/src/commonMain/kotlin/com/fluxa/app/player/TorrentStreamStatus.kt new file mode 100644 index 0000000..b578b47 --- /dev/null +++ b/player/src/commonMain/kotlin/com/fluxa/app/player/TorrentStreamStatus.kt @@ -0,0 +1,9 @@ +package com.fluxa.app.player + +data class TorrentStreamStatus( + val bufferProgress: Int = 0, + val detailedStatus: String = "", + val downloadSpeed: Double = 0.0, + val activePeers: Int = 0, + val totalPeers: Int = 0 +) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 8b18e42..913de7a 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -30,10 +30,10 @@ kotlin { implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.serialization.json) - } - androidMain.dependencies { implementation(libs.coil3) implementation(libs.coil3.compose) + } + androidMain.dependencies { implementation(libs.coil3.network.okhttp) } commonTest.dependencies { diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerInputControls.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerInputControls.kt similarity index 84% rename from app/src/main/java/com/fluxa/app/ui/catalog/PlayerInputControls.kt rename to shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerInputControls.kt index 662125d..4b74e05 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerInputControls.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerInputControls.kt @@ -1,4 +1,6 @@ -package com.fluxa.app.ui.catalog +package com.fluxa.app.shared.feature.player + +import com.fluxa.app.ui.catalog.DeviceType import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectTapGestures @@ -10,20 +12,21 @@ 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.input.pointer.pointerInput -import com.fluxa.app.data.local.* -import com.fluxa.app.data.local.UserProfile -import com.fluxa.app.player.PlayerEngine import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlin.time.Clock +import kotlin.time.ExperimentalTime -internal fun Modifier.playerInputControls( +@OptIn(ExperimentalTime::class) +fun Modifier.playerInputControls( deviceType: DeviceType, hasStartedPlaying: Boolean, showControls: Boolean, - activeProfile: UserProfile?, - activeEngine: PlayerEngine?, + holdToSpeedEnabled: Boolean, + holdSpeed: Float, playbackSpeed: Float, + onSetSpeed: (Float) -> Unit, onRaiseVolume: () -> Unit, onLowerVolume: () -> Unit, onShowControlsTemp: () -> Unit, @@ -81,10 +84,9 @@ internal fun Modifier.playerInputControls( Modifier.pointerInput( hasStartedPlaying, showControls, - activeProfile?.safeHoldToSpeedEnabled, - activeProfile?.safeHoldSpeed, - playbackSpeed, - activeEngine + holdToSpeedEnabled, + holdSpeed, + playbackSpeed ) { detectTapGestures( onPress = { @@ -93,16 +95,16 @@ internal fun Modifier.playerInputControls( var appliedHoldSpeed = false val holdJob = launch { delay(260) - if (hasStartedPlaying && activeProfile?.safeHoldToSpeedEnabled != false) { + if (hasStartedPlaying && holdToSpeedEnabled) { appliedHoldSpeed = true onHoldSpeedVisibleChanged(true) - activeEngine?.setSpeed(activeProfile?.safeHoldSpeed ?: 2f) + onSetSpeed(holdSpeed) } } tryAwaitRelease() holdJob.cancel() if (appliedHoldSpeed) { - activeEngine?.setSpeed(originalSpeed) + onSetSpeed(originalSpeed) onHoldSpeedVisibleChanged(false) } } @@ -121,7 +123,7 @@ internal fun Modifier.playerInputControls( var lastZoomMs = 0L detectTransformGestures { _, _, zoom, _ -> if (zoom != 1.0f) { - val now = System.currentTimeMillis() + val now = Clock.System.now().toEpochMilliseconds() if (now - lastZoomMs > 350L) onPinchZoomGestureStart() lastZoomMs = now onPinchZoom(zoom) diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerMarkSegmentSidebar.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerMarkSegmentSidebar.kt similarity index 96% rename from app/src/main/java/com/fluxa/app/ui/catalog/PlayerMarkSegmentSidebar.kt rename to shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerMarkSegmentSidebar.kt index f69603f..6aa1a9c 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerMarkSegmentSidebar.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerMarkSegmentSidebar.kt @@ -1,4 +1,6 @@ -package com.fluxa.app.ui.catalog +package com.fluxa.app.shared.feature.player + +import com.fluxa.app.ui.catalog.DeviceType import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,10 +23,9 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import java.util.concurrent.TimeUnit @Composable -internal fun MarkSegmentSidebar( +fun MarkSegmentSidebar( deviceType: DeviceType, lang: String, selectedType: String?, @@ -198,9 +199,9 @@ private fun MarkSegmentSubmitButton(enabled: Boolean, submitting: Boolean, label private fun formatSegmentTime(ms: Long): String { val totalSeconds = ms / 1000 - val minutes = TimeUnit.SECONDS.toMinutes(totalSeconds) - val seconds = totalSeconds - TimeUnit.MINUTES.toSeconds(minutes) - return "%02d:%02d".format(minutes, seconds) + val minutes = totalSeconds / 60 + val seconds = totalSeconds - minutes * 60 + return "${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}" } private fun formatCooldown(remainingSec: Long): String { diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerOverlayCards.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerOverlayCards.kt new file mode 100644 index 0000000..c3c8ed1 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerOverlayCards.kt @@ -0,0 +1,397 @@ +package com.fluxa.app.shared.feature.player + +import com.fluxa.app.common.AppStrings +import com.fluxa.app.data.remote.Video +import com.fluxa.app.player.TorrentStreamStatus +import com.fluxa.app.ui.catalog.BufferSnapshot +import com.fluxa.app.ui.catalog.DeviceType +import com.fluxa.app.ui.catalog.FluxaColors +import com.fluxa.app.ui.catalog.FluxaDimensions +import com.fluxa.app.ui.catalog.FluxaIcons +import com.fluxa.app.ui.catalog.PlaybackSnapshot + +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.layout.* +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.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil3.compose.AsyncImage +import java.util.Locale + +fun playerText(lang: String?, key: String): String { + return AppStrings.t(lang, "player.$key") +} + +fun playerStatusText(lang: String?, value: String): String { + return if (value.startsWith("player.")) AppStrings.t(lang, value) else value +} + +@Composable +fun SkipSegmentCard( + deviceType: DeviceType, + type: String, + nextEpisode: Video? = null, + lang: String? = "en", + autoAdvanceSeconds: Int? = null, + onSkip: () -> Unit, + onDismiss: () -> Unit +) { + if (type == "outro" && nextEpisode != null) { + NextEpisodeSkipCard( + deviceType = deviceType, + episode = nextEpisode, + lang = lang, + autoAdvanceSeconds = autoAdvanceSeconds, + onSkip = onSkip + ) + return + } + val label = when (type) { + "intro" -> playerText(lang, "skip_intro") + "outro" -> playerText(lang, "finish_episode") + "recap" -> playerText(lang, "skip_recap") + else -> playerText(lang, "skip") + } + val focusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } + LaunchedEffect(deviceType) { + if (deviceType == DeviceType.TV) focusRequester.requestFocus() + } + Box( + modifier = Modifier + .widthIn(min = if (deviceType == DeviceType.Mobile) 108.dp else 160.dp) + .clip(RoundedCornerShape(10.dp)) + .background(Color.White) + .then( + if (deviceType == DeviceType.TV) { + Modifier.border(2.dp, if (isFocused) FluxaColors.accent else Color.Transparent, RoundedCornerShape(10.dp)) + } else { + Modifier + } + ) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable { onSkip() } + .padding(horizontal = if (deviceType == DeviceType.Mobile) 16.dp else 28.dp, vertical = if (deviceType == DeviceType.Mobile) 8.dp else 13.dp), + contentAlignment = Alignment.Center + ) { + Text( + text = label, + color = Color.Black, + fontWeight = FontWeight.SemiBold, + fontSize = if (deviceType == DeviceType.Mobile) 13.sp else 16.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +private fun NextEpisodeSkipCard( + deviceType: DeviceType, + episode: Video, + lang: String?, + autoAdvanceSeconds: Int? = null, + onSkip: () -> Unit +) { + var remainingSeconds by remember(episode.id, autoAdvanceSeconds) { mutableStateOf(autoAdvanceSeconds) } + LaunchedEffect(episode.id, autoAdvanceSeconds) { + var remaining = autoAdvanceSeconds ?: return@LaunchedEffect + while (remaining > 0) { + kotlinx.coroutines.delay(1000) + remaining -= 1 + remainingSeconds = remaining + } + onSkip() + } + val thumbnailSize = if (deviceType == DeviceType.Mobile) 46.dp else 74.dp + val cardWidth = if (deviceType == DeviceType.Mobile) 240.dp else 364.dp + val focusRequester = remember { FocusRequester() } + var isFocused by remember { mutableStateOf(false) } + LaunchedEffect(deviceType) { + if (deviceType == DeviceType.TV) focusRequester.requestFocus() + } + Row( + modifier = Modifier + .width(cardWidth) + .clip(RoundedCornerShape(if (deviceType == DeviceType.Mobile) 12.dp else 14.dp)) + .background(Color.Black.copy(alpha = 0.82f)) + .border( + 1.dp, + if (deviceType == DeviceType.TV && isFocused) FluxaColors.accent else Color.White.copy(alpha = 0.16f), + RoundedCornerShape(if (deviceType == DeviceType.Mobile) 12.dp else 14.dp) + ) + .focusRequester(focusRequester) + .onFocusChanged { isFocused = it.isFocused } + .focusable() + .clickable { onSkip() } + .padding(if (deviceType == DeviceType.Mobile) 7.dp else 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(if (deviceType == DeviceType.Mobile) 10.dp else 12.dp) + ) { + AsyncImage( + model = episode.thumbnail, + contentDescription = null, + modifier = Modifier + .size(thumbnailSize) + .clip(RoundedCornerShape(if (deviceType == DeviceType.Mobile) 8.dp else 10.dp)) + .background(Color.White.copy(alpha = 0.08f)), + contentScale = ContentScale.Crop + ) + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = remainingSeconds?.let { "${AppStrings.t(lang, "auto.next_episode").uppercase(Locale.ROOT)} · ${it}s" } + ?: AppStrings.t(lang, "auto.next_episode").uppercase(Locale.ROOT), + color = Color.White, + fontWeight = FontWeight.Black, + fontSize = if (deviceType == DeviceType.Mobile) 10.sp else 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = nextEpisodeSubtitle(lang, episode), + color = Color.White.copy(alpha = 0.68f), + fontWeight = FontWeight.Bold, + fontSize = if (deviceType == DeviceType.Mobile) 10.sp else 13.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + Icon( + FluxaIcons.KeyboardArrowRight, + null, + tint = Color.White.copy(alpha = 0.92f), + modifier = Modifier.size(if (deviceType == DeviceType.Mobile) 20.dp else 28.dp) + ) + } +} + +private fun nextEpisodeSubtitle(lang: String?, episode: Video): String { + val season = episode.season ?: 1 + val number = episode.number ?: 0 + val name = episode.name.orEmpty().trim() + return if (name.isBlank()) { + AppStrings.format(lang, "player.next_episode_number_format", season, number) + } else { + AppStrings.format(lang, "player.next_episode_detail_format", season, number, name) + } +} + +@Composable +internal fun SegmentSkipChevronFeedback() { + val transition = rememberInfiniteTransition(label = "segmentSkip") + val phase by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(FluxaDimensions.AnimDuration.nextEpisode, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Restart + ), + label = "segmentSkipPhase" + ) + Canvas(modifier = Modifier.size(150.dp, 96.dp)) { + val stroke = size.minDimension * 0.09f + val centerY = size.height / 2f + val startX = size.width * 0.30f + repeat(3) { index -> + val local = ((phase + index * 0.22f) % 1f) + val alpha = 0.18f + local * 0.48f + val x = startX + index * size.width * 0.18f + local * size.width * 0.05f + drawLine( + color = Color.White.copy(alpha = alpha), + start = Offset(x - size.width * 0.055f, centerY - size.height * 0.14f), + end = Offset(x + size.width * 0.055f, centerY), + strokeWidth = stroke, + cap = StrokeCap.Round + ) + drawLine( + color = Color.White.copy(alpha = alpha), + start = Offset(x + size.width * 0.055f, centerY), + end = Offset(x - size.width * 0.055f, centerY + size.height * 0.14f), + strokeWidth = stroke, + cap = StrokeCap.Round + ) + } + } +} + +@Composable +fun ArtisticLoadingOverlay(bg: String, logo: String, title: String, status: TorrentStreamStatus, deviceType: DeviceType, buffer: BufferSnapshot = BufferSnapshot(), error: String? = null, currentUrl: String?, isSwitchingAudioSource: Boolean = false, currentSourceIdx: Int = 0, totalSources: Int = 0, playback: PlaybackSnapshot = PlaybackSnapshot(), hasRenderedFirstFrame: Boolean = false, lang: String? = "en", isTorrentUrl: Boolean = false) { + val startupLoading = !hasRenderedFirstFrame + Box( + modifier = Modifier + .fillMaxSize() + .background(if (startupLoading) Color.Black else Color.Transparent) + ) { + if (startupLoading && bg.isNotEmpty()) { + AsyncImage( + bg, + null, + modifier = Modifier + .fillMaxSize() + .alpha(if (deviceType == DeviceType.TV) 0.35f else 0.30f), + contentScale = ContentScale.Crop + ) + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.34f)) + ) + } + val isTorrent = isTorrentUrl || (currentUrl ?: "").contains(".torrent") + val byteProgress = buffer.loadProgress > 0.015f || + buffer.bufferPercent > 0 || + status.bufferProgress > 0 || + status.downloadSpeed > 0.0 + + val rebufferProgress = when { + status.bufferProgress > 0 -> (status.bufferProgress / 100f).coerceIn(0f, 1f) + buffer.bufferPercent > 0 -> (buffer.bufferPercent.toFloat() / 100f).coerceIn(0f, 1f) + buffer.seekbarBufferedProgress > 0f -> buffer.seekbarBufferedProgress.coerceIn(0f, 1f) + else -> 0f + } + val activeRebuffer = hasRenderedFirstFrame && playback.isBuffering + val rawTargetProgress = when { + activeRebuffer -> rebufferProgress + hasRenderedFirstFrame && playback.hasStartedPlaying && !playback.isBuffering -> 1.0f + buffer.loadProgress > 0f -> buffer.loadProgress.coerceIn(0f, 1f) + status.bufferProgress > 0 -> (status.bufferProgress / 100f).coerceIn(0f, 1f) + !isTorrent && buffer.bufferPercent > 0 -> (buffer.bufferPercent.toFloat() / 100f).coerceIn(0f, 1f) + else -> 0f + } + val targetProgress = when { + startupLoading && rawTargetProgress > 0f -> rawTargetProgress.coerceAtMost(0.92f) + activeRebuffer && rawTargetProgress > 0f -> rawTargetProgress.coerceAtMost(0.96f) + else -> rawTargetProgress + } + val loadProgress by animateFloatAsState( + targetValue = targetProgress, + animationSpec = tween(FluxaDimensions.AnimDuration.progressRing, easing = FastOutSlowInEasing), + label = "logoLoadProgress" + ) + val useBreathe = startupLoading && !byteProgress && loadProgress <= 0.015f && targetProgress <= 0.015f + val hasProgress = !useBreathe && (activeRebuffer || byteProgress || loadProgress > 0.015f || targetProgress > 0.015f) + val visibleLoadProgress = if (hasProgress) maxOf(loadProgress, if (activeRebuffer) 0.08f else 0.045f) else 0f + val breatheTransition = rememberInfiniteTransition(label = "loadingLogoBreathe") + val breatheAlpha by breatheTransition.animateFloat( + initialValue = 0.42f, + targetValue = 0.66f, + animationSpec = infiniteRepeatable( + animation = tween(FluxaDimensions.AnimDuration.marquee, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse + ), + label = "loadingLogoAlpha" + ) + val containerWidth = if (deviceType == DeviceType.TV) 500.dp else 280.dp + Column(modifier = Modifier.align(Alignment.Center), horizontalAlignment = Alignment.CenterHorizontally) { + Box( + modifier = Modifier + .width(containerWidth) + .height(200.dp), + contentAlignment = Alignment.Center + ) { + var logoFailed by remember { mutableStateOf(false) } + + when { + logo.isEmpty() || logoFailed -> { + val spinnerAlpha = if (hasProgress) 0.18f + 0.72f * loadProgress.coerceIn(0f, 1f) else breatheAlpha + CircularProgressIndicator( + color = Color.White.copy(alpha = spinnerAlpha), + strokeWidth = 3.dp, + modifier = Modifier.size(52.dp) + ) + } + !hasProgress -> { + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier.fillMaxSize().alpha(breatheAlpha), + contentScale = ContentScale.Fit, + onError = { logoFailed = true } + ) + } + else -> { + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier.fillMaxSize().alpha(0.18f), + contentScale = ContentScale.Fit, + onError = { logoFailed = true } + ) + val revealProgress = visibleLoadProgress.coerceIn(0f, 1f) + AsyncImage( + model = logo, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .drawWithContent { + clipRect(right = size.width * revealProgress) { + this@drawWithContent.drawContent() + } + }, + contentScale = ContentScale.Fit, + onError = { logoFailed = true } + ) + } + } + } + } + + if (error != null) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.8f)), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Icon(FluxaIcons.ErrorOutline, null, tint = Color.White, modifier = Modifier.size(64.dp).padding(bottom = 16.dp)) + Text(text = error, color = Color.White, fontSize = 20.sp, fontWeight = FontWeight.Bold, textAlign = TextAlign.Center, modifier = Modifier.padding(horizontal = 64.dp)) + } + } + } + } +} + +@Composable +fun VolumeBar(current: Int, max: Int) { + val progress = current.toFloat() / max.toFloat() + Row(modifier = Modifier.background(Color(0xB010141A), RoundedCornerShape(18.dp)).padding(horizontal = 16.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Icon(imageVector = when { progress == 0f -> FluxaIcons.VolumeMute; progress < 0.5f -> FluxaIcons.VolumeDown; else -> FluxaIcons.VolumeUp }, contentDescription = null, tint = Color.White, modifier = Modifier.size(20.dp)) + Box(modifier = Modifier.width(150.dp).height(4.dp).background(Color.White.copy(alpha = 0.2f), CircleShape)) { Box(modifier = Modifier.fillMaxWidth(progress).fillMaxHeight().background(Color.White, CircleShape)) } + Text(text = "${(progress * 100).toInt()}%", color = Color.White, fontSize = 12.sp, fontWeight = FontWeight.Bold) + } +} diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerRuntimeOverlays.kt similarity index 95% rename from app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt rename to shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerRuntimeOverlays.kt index 363cd27..30eda47 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerRuntimeOverlays.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerRuntimeOverlays.kt @@ -1,8 +1,11 @@ -@file:OptIn(androidx.compose.animation.ExperimentalAnimationApi::class) +package com.fluxa.app.shared.feature.player -package com.fluxa.app.ui.catalog +import com.fluxa.app.data.remote.IntroTimestamps +import com.fluxa.app.data.remote.Video +import com.fluxa.app.ui.catalog.DeviceType +import com.fluxa.app.ui.catalog.FluxaDimensions +import com.fluxa.app.ui.catalog.FluxaIcons -import com.fluxa.app.shared.feature.player.PlayerSeekFeedback import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.core.FastOutSlowInEasing import androidx.compose.animation.core.tween @@ -40,13 +43,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import com.fluxa.app.data.local.* -import com.fluxa.app.data.local.UserProfile -import com.fluxa.app.data.remote.IntroTimestamps -import com.fluxa.app.data.remote.Video @Composable -internal fun PlayerSkipSegmentOverlay( +fun PlayerSkipSegmentOverlay( currentPosition: Long, skipSegments: List, dismissedSkipSegments: Set, @@ -117,13 +116,13 @@ internal fun PlayerSkipSegmentOverlay( } } -internal enum class ZoomOverlayMode { Original, Fit, Zoom } +enum class ZoomOverlayMode { Original, Fit, Zoom } @Composable -internal fun BoxScope.PlayerTransientOverlays( +fun BoxScope.PlayerTransientOverlays( showSegmentSkipFeedback: Boolean, holdSpeedVisible: Boolean, - activeProfile: UserProfile?, + holdSpeed: Float, deviceType: DeviceType, showVolumeBar: Boolean, currentVolume: Int, @@ -209,7 +208,7 @@ internal fun BoxScope.PlayerTransientOverlays( contentAlignment = Alignment.Center ) { Text( - text = "${activeProfile?.safeHoldSpeed ?: 2f}x", + text = "${holdSpeed}x", color = Color.White, fontSize = if (deviceType == DeviceType.Mobile) 18.sp else 22.sp, fontWeight = FontWeight.Black @@ -256,6 +255,6 @@ internal fun BoxScope.PlayerTransientOverlays( } } -internal fun IntroTimestamps.dismissKey(): String { +fun IntroTimestamps.dismissKey(): String { return "$type:$startTime:$endTime" } diff --git a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSettingsSidebars.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerSettingsSidebars.kt similarity index 86% rename from app/src/main/java/com/fluxa/app/ui/catalog/PlayerSettingsSidebars.kt rename to shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerSettingsSidebars.kt index a05f5ca..89a9468 100644 --- a/app/src/main/java/com/fluxa/app/ui/catalog/PlayerSettingsSidebars.kt +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerSettingsSidebars.kt @@ -1,64 +1,31 @@ -@file:OptIn(androidx.tv.material3.ExperimentalTvMaterial3Api::class, androidx.compose.animation.ExperimentalAnimationApi::class, androidx.compose.material3.ExperimentalMaterial3Api::class) -package com.fluxa.app.ui.catalog +package com.fluxa.app.shared.feature.player import com.fluxa.app.common.AppStrings -import com.fluxa.app.data.local.* -import com.fluxa.app.data.remote.* -import com.fluxa.app.data.repository.* -import com.fluxa.app.domain.discovery.* -import com.fluxa.app.shared.feature.player.SeekIconButton +import com.fluxa.app.player.MediaTrack +import com.fluxa.app.ui.catalog.DeviceType +import com.fluxa.app.ui.catalog.FluxaIcons import androidx.compose.animation.Crossfade -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.FastOutSlowInEasing -import androidx.compose.animation.core.RepeatMode -import androidx.compose.animation.core.animateFloat -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.infiniteRepeatable -import androidx.compose.animation.core.rememberInfiniteTransition -import androidx.compose.animation.core.tween -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Canvas import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Icon import androidx.compose.material3.Slider import androidx.compose.material3.SliderDefaults -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.* -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.graphics.drawscope.clipRect -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.compose.ui.zIndex -import coil3.compose.AsyncImage -import com.fluxa.app.player.MediaTrack -import java.util.Locale +import kotlin.math.roundToInt @Composable fun UniversalSettingsSidebar( @@ -84,6 +51,7 @@ fun UniversalSettingsSidebar( onSubtitleOutlineOpacityChange: (Float) -> Unit, deviceType: DeviceType, lang: String = "en", + languageDisplayName: (String) -> String = { it }, onClose: () -> Unit ) { val title = when (activeTab) { @@ -106,10 +74,7 @@ fun UniversalSettingsSidebar( ) { val listMaxHeight = if (activeTab == 0 || activeTab == 1) 720.dp else if (deviceType == DeviceType.TV) 420.dp else 300.dp Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.TopCenter) { - Crossfade( - targetState = activeTab, - - ) { tab -> + Crossfade(targetState = activeTab) { tab -> when (tab) { 0 -> { LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier.fillMaxWidth().heightIn(max = listMaxHeight)) { @@ -124,7 +89,7 @@ fun UniversalSettingsSidebar( } items(audioTracks, key = { it.id }) { track -> val title = if (track.language != null) { - nativeLanguageName(track.language!!) + languageDisplayName(track.language!!) } else { track.label } @@ -195,7 +160,7 @@ fun UniversalSettingsSidebar( title = track.label, isSelected = track == currentSubtitle, onClick = { onSelectSubtitle(track) }, - subtitle = track.language?.let { nativeLanguageName(it) } ?: AppStrings.t(lang, "player.embedded_subtitle"), + subtitle = track.language?.let { languageDisplayName(it) } ?: AppStrings.t(lang, "player.embedded_subtitle"), deviceType = deviceType, leadingIcon = FluxaIcons.Subtitles ) @@ -251,7 +216,7 @@ fun UniversalSettingsSidebar( } private fun formatSpeedLabel(speed: Float): String { - val rounded = Math.round(speed * 100) / 100f + val rounded = (speed * 100).roundToInt() / 100f val text = if (rounded == rounded.toInt().toFloat()) rounded.toInt().toString() else rounded.toString() return "${text}x" } @@ -323,8 +288,10 @@ private fun DelayAdjustmentItem( } private fun formatDelayMs(valueMs: Long): String { - val seconds = valueMs / 1000.0 - return "${if (valueMs >= 0) "+" else ""}${String.format(Locale.US, "%.2f", seconds)}s" + val hundredths = (kotlin.math.abs(valueMs) / 10) % 100 + val seconds = kotlin.math.abs(valueMs) / 1000 + val sign = if (valueMs >= 0) "+" else "-" + return "$sign$seconds.${hundredths.toString().padStart(2, '0')}s" } @Composable diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerSidebarShell.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerSidebarShell.kt new file mode 100644 index 0000000..b9b471f --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerSidebarShell.kt @@ -0,0 +1,372 @@ +package com.fluxa.app.shared.feature.player + +import com.fluxa.app.common.AppStrings +import com.fluxa.app.data.remote.Meta +import com.fluxa.app.data.remote.Stream +import com.fluxa.app.ui.catalog.DeviceType +import com.fluxa.app.ui.catalog.FluxaDimensions +import com.fluxa.app.ui.catalog.FluxaIcons +import com.fluxa.app.ui.catalog.LocalDeviceType +import com.fluxa.app.ui.catalog.streamRawBody +import com.fluxa.app.ui.catalog.streamSourceHeader + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +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.sp +import androidx.compose.ui.zIndex + +@Composable +fun SourceSidebar(streams: List, currentUrl: String, deviceType: DeviceType, lang: String = "en", onSelect: (String) -> Unit, onClose: (() -> Unit)? = null) { + PlayerSidebarShell( + title = AppStrings.t(lang, "player.source_selection_title"), + subtitle = AppStrings.t(lang, "player.source_selection_subtitle"), + deviceType = deviceType, + onClose = onClose, + sideSheetOnMobile = false + ) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(streams, key = { it.playableUrl ?: (it.title.orEmpty() + it.name.orEmpty()) }) { stream -> + val playableUrl = stream.playableUrl + TrackItem( + modifier = Modifier.animateItem(), + title = stream.streamSourceHeader(), + isSelected = stream.playableUrl == currentUrl, + onClick = { playableUrl?.let(onSelect) }, + subtitle = stream.streamRawBody(), + badge = null, + deviceType = deviceType, + leadingIcon = FluxaIcons.PlayArrow + ) + } + } + } +} + +@Composable +fun PlayerSidebarShell( + title: String, + subtitle: String, + deviceType: DeviceType, + onClose: (() -> Unit)? = null, + sideSheetOnMobile: Boolean = false, + compactCenterOnMobile: Boolean = false, + content: @Composable ColumnScope.() -> Unit +) { + var shown by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { shown = true } + val panelAlpha by animateFloatAsState(if (shown) 1f else 0f, animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha), label = "sidebarAlpha") + val panelOffset by animateFloatAsState(if (shown) 0f else 44f, animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing), label = "sidebarOffset") + val isMobile = deviceType == DeviceType.Mobile + val panelShape = if (isMobile) { + if (compactCenterOnMobile) { + RoundedCornerShape(24.dp) + } else if (sideSheetOnMobile) { + RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp, topEnd = 0.dp, bottomEnd = 0.dp) + } else { + RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp, bottomStart = 0.dp, bottomEnd = 0.dp) + } + } else { + RoundedCornerShape(topStart = 24.dp, bottomStart = 24.dp, topEnd = 0.dp, bottomEnd = 0.dp) + } + val panelSizeModifier = when { + isMobile && compactCenterOnMobile -> Modifier + .fillMaxWidth(0.74f) + .widthIn(max = 340.dp) + .wrapContentHeight() + .heightIn(min = 160.dp, max = 390.dp) + isMobile && sideSheetOnMobile -> Modifier + .fillMaxHeight() + .fillMaxWidth(0.46f) + .widthIn(min = 300.dp, max = 520.dp) + isMobile -> Modifier + .fillMaxWidth(0.92f) + .widthIn(max = 430.dp) + .wrapContentHeight() + .heightIn(min = 180.dp, max = 520.dp) + else -> Modifier + .widthIn(min = 300.dp, max = 420.dp) + .wrapContentHeight() + .heightIn(max = 620.dp) + } + + Box(modifier = Modifier.fillMaxSize().zIndex(100f)) { + Box( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.48f)) + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null + ) { onClose?.invoke() } + ) + + Column( + modifier = Modifier + .align( + if (isMobile) { + if (compactCenterOnMobile) Alignment.Center else if (sideSheetOnMobile) Alignment.CenterEnd else Alignment.BottomCenter + } else { + Alignment.CenterEnd + } + ) + .then(panelSizeModifier) + .graphicsLayer { + alpha = panelAlpha + translationY = if (isMobile && !sideSheetOnMobile) panelOffset else 0f + translationX = if (!isMobile || sideSheetOnMobile) panelOffset else 0f + } + .background( + brush = if (isMobile && !sideSheetOnMobile) { + Brush.verticalGradient( + listOf(Color(0xFF151A22).copy(alpha = 0.99f), Color(0xFF0D1218).copy(alpha = 0.99f)) + ) + } else { + Brush.verticalGradient( + listOf(Color(0xFF121922).copy(alpha = 0.98f), Color(0xFF0A0F15).copy(alpha = 0.98f)) + ) + }, + shape = panelShape + ) + .border( + BorderStroke(1.dp, Brush.horizontalGradient(listOf(Color.White.copy(alpha = 0.14f), Color.Transparent))), + shape = panelShape + ) + .clip(panelShape) + .windowInsetsPadding(WindowInsets.navigationBars) + .padding(if (deviceType == DeviceType.TV) 16.dp else if (isMobile && !sideSheetOnMobile) 18.dp else 16.dp) + ) { + if (isMobile && sideSheetOnMobile) { + Box( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 14.dp) + .width(52.dp) + .height(5.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.16f)) + ) + } + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 14.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Top + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + color = Color.White, + fontSize = if (deviceType == DeviceType.TV) 18.sp else 17.sp, + fontWeight = FontWeight.Black, + letterSpacing = 0.8.sp + ) + if (subtitle.isNotBlank()) { + Spacer(Modifier.height(8.dp)) + Text( + text = subtitle, + color = Color.White.copy(alpha = 0.58f), + fontSize = if (deviceType == DeviceType.TV) 11.sp else 10.sp, + lineHeight = 14.sp + ) + } + } + + if (onClose != null) { + Box( + modifier = Modifier + .size(if (deviceType == DeviceType.TV) 38.dp else 34.dp) + .clip(CircleShape) + .background(Color.White.copy(alpha = 0.08f)) + .clickable { onClose() }, + contentAlignment = Alignment.Center + ) { + Icon(FluxaIcons.Close, null, tint = Color.White, modifier = Modifier.size(if (deviceType == DeviceType.TV) 20.dp else 16.dp)) + } + } + } + content() + } + } +} + +@Composable +fun TrackItem( + modifier: Modifier = Modifier, + title: String, + isSelected: Boolean, + onClick: () -> Unit, + subtitle: String? = null, + badge: String? = null, + formatBadge: (@Composable () -> Unit)? = null, + deviceType: DeviceType? = null, + leadingIcon: ImageVector? = null +) { + val resolvedDeviceType = deviceType ?: LocalDeviceType.current + var isFocused by remember { mutableStateOf(false) } + val bgColor by animateColorAsState( + targetValue = when { + isSelected -> Color.White + isFocused -> Color.White.copy(alpha = 0.18f) + else -> Color.White.copy(alpha = 0.04f) + }, + animationSpec = tween(FluxaDimensions.AnimDuration.heroSnap), + label = "bg" + ) + val textColor = if (isSelected) Color.Black else Color.White + val secondaryTextColor = if (isSelected) Color.Black.copy(alpha = 0.58f) else Color.White.copy(alpha = 0.56f) + val iconColor = if (isSelected) Color.Black else Color.White + val iconBackgroundColor = if (isSelected) Color.Black.copy(alpha = 0.08f) else Color.White.copy(alpha = 0.07f) + val badgeBackgroundColor = if (isSelected) Color.Black.copy(alpha = 0.08f) else Color.White.copy(alpha = 0.1f) + val scale by animateFloatAsState( + targetValue = when { + isSelected -> 1.015f + isFocused -> 1.01f + else -> 1f + }, + animationSpec = tween(FluxaDimensions.AnimDuration.scaleAlpha, easing = FastOutSlowInEasing), + label = "trackScale" + ) + val minHeight = if (subtitle.isNullOrBlank()) { + if (resolvedDeviceType == DeviceType.TV) 70.dp else 68.dp + } else { + if (resolvedDeviceType == DeviceType.TV) 88.dp else 84.dp + } + + Box( + modifier = modifier + .fillMaxWidth() + .defaultMinSize(minHeight = minHeight) + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .animateContentSize(animationSpec = tween(FluxaDimensions.AnimDuration.contentExpand, easing = FastOutSlowInEasing)) + .clip(RoundedCornerShape(if (resolvedDeviceType == DeviceType.TV) 18.dp else 16.dp)) + .background(bgColor) + .clickable { onClick() } + .onFocusChanged { isFocused = it.isFocused } + .focusable(), + contentAlignment = Alignment.CenterStart + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + if (leadingIcon != null) { + Box( + modifier = Modifier + .size(if (resolvedDeviceType == DeviceType.TV) 36.dp else 34.dp) + .clip(RoundedCornerShape(12.dp)) + .background(iconBackgroundColor), + contentAlignment = Alignment.Center + ) { + Icon(leadingIcon, null, tint = iconColor, modifier = Modifier.size(20.dp)) + } + Spacer(modifier = Modifier.width(14.dp)) + } + + Column(modifier = Modifier.weight(1f)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = title, + modifier = Modifier.weight(1f, fill = false), + color = textColor, + fontWeight = if (isSelected) FontWeight.ExtraBold else FontWeight.Bold, + fontSize = if (resolvedDeviceType == DeviceType.TV) 15.sp else 14.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + if (!badge.isNullOrBlank()) { + Box( + modifier = Modifier + .clip(CircleShape) + .background(badgeBackgroundColor) + .padding(horizontal = 10.dp, vertical = 4.dp) + ) { + Text( + text = badge, + color = textColor, + fontSize = 10.sp, + fontWeight = FontWeight.Black, + maxLines = 1 + ) + } + } + } + if (!subtitle.isNullOrBlank() || formatBadge != null) { + Spacer(modifier = Modifier.height(5.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + color = secondaryTextColor, + fontWeight = FontWeight.Medium, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + lineHeight = 15.sp + ) + } + if (formatBadge != null) { + formatBadge() + } + } + } + } + + if (isSelected) { + Spacer(modifier = Modifier.width(12.dp)) + Icon( + imageVector = FluxaIcons.CheckCircle, + contentDescription = null, + tint = iconColor, + modifier = Modifier.size(20.dp) + ) + } + } + } +} + +fun Meta.withCurrentEpisodeArtwork(artwork: String?): Meta { + val episodeArtwork = artwork?.takeIf { it.isNotBlank() } ?: return this + if (type != "series") return this + return copy(continueWatchingPoster = episodeArtwork, continueWatchingBackground = episodeArtwork) +} diff --git a/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerTrackSidebars.kt b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerTrackSidebars.kt new file mode 100644 index 0000000..3f51678 --- /dev/null +++ b/shared/src/commonMain/kotlin/com/fluxa/app/shared/feature/player/PlayerTrackSidebars.kt @@ -0,0 +1,98 @@ +package com.fluxa.app.shared.feature.player + +import com.fluxa.app.common.AppStrings +import com.fluxa.app.data.local.UserProfile +import com.fluxa.app.player.MediaTrack +import com.fluxa.app.ui.catalog.DeviceType +import com.fluxa.app.ui.catalog.FluxaColors +import com.fluxa.app.ui.catalog.FluxaIcons + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun TrackSidebar( + title: String, + tracks: List, + selected: MediaTrack?, + deviceType: DeviceType, + lang: String = "en", + languageDisplayName: (String) -> String = { it }, + onSelect: (MediaTrack) -> Unit +) { + PlayerSidebarShell( + title = title, + subtitle = AppStrings.t(lang, "player.choose_preferred_source"), + deviceType = deviceType + ) { + LazyColumn(verticalArrangement = Arrangement.spacedBy(12.dp)) { + items(tracks, key = { it.id }) { track -> + TrackItem( + title = track.label, + isSelected = track == selected, + onClick = { onSelect(track) }, + subtitle = track.language?.let { languageDisplayName(it) }, + deviceType = deviceType + ) + } + } + } +} + +@Composable +fun QuickSettingsSidebar(profile: UserProfile?, onUpdateProfile: (UserProfile) -> Unit, currentOffset: Long, onOffsetChange: (Long) -> Unit, deviceType: DeviceType, lang: String = "en", onClose: () -> Unit) { + PlayerSidebarShell( + title = AppStrings.t(lang, "player.quick_settings_title"), + subtitle = AppStrings.t(lang, "player.quick_settings_subtitle"), + deviceType = deviceType, + onClose = onClose + ) { + Text(AppStrings.t(lang, "player.subtitle_sync_title"), color = Color.White.copy(alpha = 0.62f), fontSize = 12.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 12.dp)) + Box( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(22.dp)) + .background(Color.White.copy(alpha = 0.04f)) + .border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(22.dp)) + .padding(18.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) { + SeekIconButton(FluxaIcons.Remove, deviceType) { onOffsetChange(currentOffset - 500) } + Text(text = "${if (currentOffset >= 0) "+" else ""}${currentOffset / 1000.0}s", color = Color.White, fontSize = 18.sp, fontWeight = FontWeight.Black, modifier = Modifier.weight(1f), textAlign = TextAlign.Center) + SeekIconButton(FluxaIcons.Add, deviceType) { onOffsetChange(currentOffset + 500) } + } + } + + Spacer(Modifier.height(8.dp)) + } +} + +@Composable +fun PlayerPremiumToggle(title: String, desc: String, isEnabled: Boolean, onToggle: () -> Unit) { + Box(modifier = Modifier.fillMaxWidth().height(82.dp).clip(RoundedCornerShape(22.dp)).background(Color.White.copy(alpha = 0.05f)).border(1.dp, Color.White.copy(alpha = 0.08f), RoundedCornerShape(22.dp)).clickable { onToggle() }.padding(horizontal = 20.dp), contentAlignment = Alignment.CenterStart) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f)) { + Text(title, color = Color.White, fontSize = 16.sp, fontWeight = FontWeight.Bold) + Text(desc, color = Color.White.copy(alpha = 0.56f), fontSize = 12.sp) + } + Switch(checked = isEnabled, onCheckedChange = { onToggle() }, colors = SwitchDefaults.colors(checkedThumbColor = FluxaColors.accent, checkedTrackColor = FluxaColors.accent)) + } + } +}