mirror of
https://github.com/FluxaMedia/fluxa.git
synced 2026-08-09 00:17:35 +00:00
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:
parent
b9c2063bd4
commit
e8dced3b20
20 changed files with 969 additions and 985 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -65,6 +65,7 @@ kotlin {
|
|||
commonTest.dependencies {
|
||||
implementation(kotlin("test"))
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
androidMain {
|
||||
dependencies {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue