Move player overlays, sidebars, and gesture input into shared CMP

Relocates skip-segment/transient overlays, the sidebar shell + track
list primitives, mark-segment/settings/track sidebars, and the
playerInputControls gesture modifier from app-module Android files
into shared/commonMain. Promotes coil3/coil3-compose to commonMain
(genuinely multiplatform; only coil3-network-okhttp stays
Android-only) so the overlay cards using AsyncImage can move too, and
promotes TorrentStreamStatus to player/commonMain as a plain data
holder.

Where a composable used to reach into UserProfile's JNI-backed
`safeXxx` properties (holdSpeed, holdToSpeedEnabled, language) or an
Android-only Locale API, it now takes the resolved primitive as a
parameter instead, with the app-side caller supplying it - keeping
the commonMain/androidMain boundary honest rather than papering over
it.

EpisodeSidebar (Hilt ViewModel-bound) and TrackSelectionState (direct
FluxaCoreNative JNI calls) stay Android-only, along with the actual
video surface, playback effects, and setup effects - those are the
next sub-steps in the player-extraction migration.
This commit is contained in:
KhooLy 2026-07-17 00:06:49 +03:00
parent b9c2063bd4
commit e8dced3b20
20 changed files with 969 additions and 985 deletions

View file

@ -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<Int, Int>? {
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<com.fluxa.app.player.Chapter> = 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)
}
}

View file

@ -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

View file

@ -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
)
}

View file

@ -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

View file

@ -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)

View file

@ -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)
}

View file

@ -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<Stream>, 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)
)
}
}
}
}

View file

@ -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

View file

@ -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<MediaTrack>, 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))
}
}
}

View file

@ -65,6 +65,7 @@ kotlin {
commonTest.dependencies {
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
implementation(libs.kotlinx.serialization.json)
}
androidMain {
dependencies {

View file

@ -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()

View file

@ -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
)

View file

@ -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 {

View file

@ -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)

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -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<IntroTimestamps>,
dismissedSkipSegments: Set<String>,
@ -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"
}

View file

@ -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

View file

@ -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<Stream>, 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)
}

View file

@ -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<MediaTrack>,
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))
}
}
}