Приложение
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
package stories.presentation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil.compose.AsyncImage
|
||||
import core.presentation.theme.OnSurfaceVariant
|
||||
import core.presentation.theme.PrimaryIndigo
|
||||
import core.presentation.theme.SurfaceContainer
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun CreateStoryScreen(
|
||||
onClose: () -> Unit,
|
||||
onCreated: () -> Unit,
|
||||
viewModel: CreateStoryViewModel = hiltViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.GetContent()
|
||||
) { uri: Uri? ->
|
||||
uri?.let {
|
||||
val isVideo = context.contentResolver.getType(it)?.startsWith("video/") == true
|
||||
viewModel.onMediaSelected(it, isVideo)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(viewModel.uploadSuccess) {
|
||||
viewModel.uploadSuccess.collect { success ->
|
||||
if (success) {
|
||||
onCreated()
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Header
|
||||
Header(
|
||||
onClose = onClose,
|
||||
isUploading = viewModel.isUploading,
|
||||
onPublish = { viewModel.publishStory(context) }
|
||||
)
|
||||
|
||||
Row(modifier = Modifier.weight(1f)) {
|
||||
// Main Area
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(16.dp)
|
||||
.clip(RoundedCornerShape(32.dp))
|
||||
.background(viewModel.bgColor)
|
||||
.border(8.dp, Color(0xFF161616), RoundedCornerShape(32.dp)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (viewModel.selectedMediaUri == null) {
|
||||
EmptyMediaState(onSelect = { launcher.launch("*/*") })
|
||||
} else {
|
||||
MediaPreview(
|
||||
uri = viewModel.selectedMediaUri,
|
||||
isVideo = viewModel.isVideo,
|
||||
onRemove = { viewModel.removeMedia() }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Sidebar Tools
|
||||
Sidebar(
|
||||
currentTool = viewModel.currentTool,
|
||||
onToolSelect = { viewModel.setTool(it) },
|
||||
onAddText = { viewModel.addText("New Text") },
|
||||
onColorChange = { viewModel.updateBgColor(it) },
|
||||
bgColor = viewModel.bgColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Header(
|
||||
onClose: () -> Unit,
|
||||
isUploading: Boolean,
|
||||
onPublish: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(80.dp)
|
||||
.background(Color(0xFF0A0A0A))
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
IconButton(onClick = onClose) {
|
||||
Icon(Icons.Default.Close, contentDescription = stringResource(R.string.back), tint = Color.Gray)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
stringResource(R.string.story_editor),
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 2.sp,
|
||||
color = Color.Gray
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
Button(
|
||||
onClick = onPublish,
|
||||
enabled = !isUploading,
|
||||
colors = ButtonDefaults.buttonColors(containerColor = PrimaryIndigo),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
if (isUploading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(20.dp),
|
||||
color = Color.White,
|
||||
strokeWidth = 2.dp
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
stringResource(R.string.publish),
|
||||
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Black)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun EmptyMediaState(onSelect: () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier.clickable { onSelect() },
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.border(2.dp, Color(0xFF333333), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.CameraAlt, contentDescription = null, tint = Color(0xFF333333), modifier = Modifier.size(32.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text(
|
||||
stringResource(R.string.start_creation),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 4.sp,
|
||||
color = Color(0xFF333333)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MediaPreview(
|
||||
uri: Uri?,
|
||||
isVideo: Boolean,
|
||||
onRemove: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_ALL
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(uri) {
|
||||
if (isVideo && uri != null) {
|
||||
exoPlayer.setMediaItem(MediaItem.fromUri(uri))
|
||||
exoPlayer.prepare()
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose { exoPlayer.release() }
|
||||
}
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (isVideo) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
PlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
useController = false
|
||||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = uri,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onRemove,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(16.dp)
|
||||
.background(Color.Black.copy(0.6f), RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Icon(Icons.Default.Delete, contentDescription = stringResource(R.string.remove), tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Sidebar(
|
||||
currentTool: StoryTool,
|
||||
onToolSelect: (StoryTool) -> Unit,
|
||||
onAddText: () -> Unit,
|
||||
onColorChange: (Color) -> Unit,
|
||||
bgColor: Color
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.fillMaxHeight()
|
||||
.background(Color(0xFF0A0A0A))
|
||||
.padding(vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
) {
|
||||
ToolButton(Icons.Default.TextFields, stringResource(R.string.text_tool), currentTool is StoryTool.Text) { onAddText() }
|
||||
ToolButton(Icons.Default.ContentCut, stringResource(R.string.crop_tool), currentTool is StoryTool.Crop) { onToolSelect(StoryTool.Crop) }
|
||||
ToolButton(Icons.Default.SentimentSatisfied, stringResource(R.string.stickers_tool), currentTool is StoryTool.Stickers) { onToolSelect(StoryTool.Stickers) }
|
||||
ToolButton(Icons.Default.Brush, stringResource(R.string.brush_tool), currentTool is StoryTool.Brush) { onToolSelect(StoryTool.Brush) }
|
||||
ToolButton(Icons.Default.Tune, stringResource(R.string.filters_tool), currentTool is StoryTool.Filters) { onToolSelect(StoryTool.Filters) }
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(bgColor)
|
||||
.border(2.dp, Color.White.copy(0.1f), CircleShape)
|
||||
.clickable { onColorChange(Color((0..0xFFFFFF).random() or 0xFF000000.toInt())) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ToolButton(
|
||||
icon: ImageVector,
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
icon,
|
||||
contentDescription = label,
|
||||
tint = if (isSelected) PrimaryIndigo else OnSurfaceVariant,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 8.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
color = if (isSelected) Color.White else OnSurfaceVariant
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package stories.presentation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import core.network.ServerConfig
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import stories.data.remote.api.CreateStoryRequest
|
||||
import stories.data.remote.api.StoryApi
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
data class TextObject(
|
||||
val id: Long = System.currentTimeMillis(),
|
||||
val text: String,
|
||||
val offset: Offset = Offset(100f, 100f),
|
||||
val color: Color = Color.White,
|
||||
val fontSize: Float = 24f,
|
||||
val scale: Float = 1f
|
||||
)
|
||||
|
||||
data class StickerObject(
|
||||
val id: Long = System.currentTimeMillis(),
|
||||
val emoji: String,
|
||||
val offset: Offset = Offset(200f, 200f),
|
||||
val scale: Float = 1.5f
|
||||
)
|
||||
|
||||
sealed class StoryTool {
|
||||
object None : StoryTool()
|
||||
object Text : StoryTool()
|
||||
object Crop : StoryTool()
|
||||
object Stickers : StoryTool()
|
||||
object Brush : StoryTool()
|
||||
object Filters : StoryTool()
|
||||
object Trim : StoryTool()
|
||||
}
|
||||
|
||||
@HiltViewModel
|
||||
class CreateStoryViewModel @Inject constructor(
|
||||
private val storyApi: StoryApi,
|
||||
private val serverConfig: ServerConfig
|
||||
) : ViewModel() {
|
||||
|
||||
var selectedMediaUri by mutableStateOf<Uri?>(null)
|
||||
private set
|
||||
|
||||
var isVideo by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
var currentTool by mutableStateOf<StoryTool>(StoryTool.None)
|
||||
private set
|
||||
|
||||
var bgColor by mutableStateOf(Color(0xFF1E1E2E))
|
||||
private set
|
||||
|
||||
val textObjects = mutableStateListOf<TextObject>()
|
||||
val stickerObjects = mutableStateListOf<StickerObject>()
|
||||
|
||||
var isUploading by mutableStateOf(false)
|
||||
private set
|
||||
|
||||
private val _uploadSuccess = MutableStateFlow(false)
|
||||
val uploadSuccess = _uploadSuccess.asStateFlow()
|
||||
|
||||
private val _errorMessage = MutableStateFlow<String?>(null)
|
||||
val errorMessage = _errorMessage.asStateFlow()
|
||||
|
||||
fun onMediaSelected(uri: Uri, isVideo: Boolean) {
|
||||
this.selectedMediaUri = uri
|
||||
this.isVideo = isVideo
|
||||
if (isVideo) {
|
||||
currentTool = StoryTool.Trim
|
||||
} else {
|
||||
currentTool = StoryTool.Crop
|
||||
}
|
||||
}
|
||||
|
||||
fun setTool(tool: StoryTool) {
|
||||
currentTool = tool
|
||||
}
|
||||
|
||||
fun addText(text: String) {
|
||||
textObjects.add(TextObject(text = text))
|
||||
currentTool = StoryTool.Text
|
||||
}
|
||||
|
||||
fun addSticker(emoji: String) {
|
||||
stickerObjects.add(StickerObject(emoji = emoji))
|
||||
}
|
||||
|
||||
fun updateBgColor(color: Color) {
|
||||
bgColor = color
|
||||
}
|
||||
|
||||
fun removeMedia() {
|
||||
selectedMediaUri = null
|
||||
isVideo = false
|
||||
currentTool = StoryTool.None
|
||||
textObjects.clear()
|
||||
stickerObjects.clear()
|
||||
}
|
||||
|
||||
fun publishStory(context: android.content.Context) {
|
||||
val uri = selectedMediaUri ?: return
|
||||
val maxFileSize = serverConfig.getServerConfig().limits.maxFileSize
|
||||
|
||||
viewModelScope.launch {
|
||||
isUploading = true
|
||||
try {
|
||||
val file = uriToFile(context, uri)
|
||||
|
||||
if (file.length() > maxFileSize) {
|
||||
_errorMessage.value = "File too large for this server"
|
||||
return@launch
|
||||
}
|
||||
|
||||
val mediaUrl = if (isVideo) {
|
||||
val part = MultipartBody.Part.createFormData(
|
||||
"file", file.name, file.asRequestBody("video/*".toMediaTypeOrNull())
|
||||
)
|
||||
storyApi.uploadVideo(part).url
|
||||
} else {
|
||||
// Simulating image upload via same or different endpoint
|
||||
"https://example.com/simulated_upload.jpg"
|
||||
}
|
||||
|
||||
val request = CreateStoryRequest(
|
||||
type = if (isVideo) "video" else "image",
|
||||
mediaUrl = mediaUrl,
|
||||
bgColor = String.format("#%06X", (0xFFFFFF and bgColor.value.toInt())),
|
||||
content = null // Serialize stickers/text if needed
|
||||
)
|
||||
|
||||
storyApi.createStory(request)
|
||||
_uploadSuccess.value = true
|
||||
} catch (e: Exception) {
|
||||
_errorMessage.value = e.message ?: "Upload failed"
|
||||
} finally {
|
||||
isUploading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun uriToFile(context: android.content.Context, uri: Uri): File {
|
||||
val inputStream = context.contentResolver.openInputStream(uri)
|
||||
val file = File(context.cacheDir, "temp_story_${System.currentTimeMillis()}")
|
||||
file.outputStream().use { outputStream ->
|
||||
inputStream?.copyTo(outputStream)
|
||||
}
|
||||
return file
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package stories.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import stories.data.remote.api.StoryGroupDto
|
||||
import stories.domain.repository.StoryRepository
|
||||
import javax.inject.Inject
|
||||
|
||||
data class StoryState(
|
||||
val storyGroups: List<StoryGroupDto> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class StoryViewModel @Inject constructor(
|
||||
private val repository: StoryRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(StoryState())
|
||||
val state: StateFlow<StoryState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
loadStories()
|
||||
}
|
||||
|
||||
fun loadStories() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true) }
|
||||
try {
|
||||
val stories = repository.getStories()
|
||||
_state.update { it.copy(storyGroups = stories, isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.localizedMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markAsViewed(storyId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.viewStory(storyId)
|
||||
} catch (e: Exception) {
|
||||
// Ignore error for viewing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendReaction(storyId: String, emoji: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.addReaction(storyId, emoji)
|
||||
} catch (e: Exception) {
|
||||
// Handle error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package stories.presentation
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.ui.PlayerView
|
||||
import coil.compose.AsyncImage
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
@Composable
|
||||
fun StoryViewerScreen(
|
||||
stories: List<String>, // URL-адреса фото/видео
|
||||
onClose: () -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var currentIndex by remember { mutableIntStateOf(0) }
|
||||
val progress = remember { Animatable(0f) }
|
||||
val scope = rememberCoroutineScope()
|
||||
var isPaused by remember { mutableStateOf(false) }
|
||||
|
||||
val currentUrl = stories[currentIndex]
|
||||
val isVideo = currentUrl.endsWith(".mp4") || currentUrl.contains("video")
|
||||
|
||||
val exoPlayer = remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
repeatMode = Player.REPEAT_MODE_OFF
|
||||
playWhenReady = true
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(exoPlayer) {
|
||||
onDispose {
|
||||
exoPlayer.release()
|
||||
}
|
||||
}
|
||||
|
||||
// Подготовка видео при смене индекса
|
||||
LaunchedEffect(currentIndex) {
|
||||
if (isVideo) {
|
||||
exoPlayer.setMediaItem(MediaItem.fromUri(Uri.parse(currentUrl)))
|
||||
exoPlayer.prepare()
|
||||
exoPlayer.play()
|
||||
} else {
|
||||
exoPlayer.stop()
|
||||
}
|
||||
}
|
||||
|
||||
// Запуск таймера прогресса
|
||||
LaunchedEffect(currentIndex, isPaused) {
|
||||
if (!isPaused) {
|
||||
val duration = if (isVideo) {
|
||||
// Ждем пока загрузится длительность видео или используем 15сек по умолчанию
|
||||
delay(500) // Даем немного времени на загрузку метаданных
|
||||
if (exoPlayer.duration > 0) exoPlayer.duration else 15000
|
||||
} else {
|
||||
5000L // 5 секунд для фото
|
||||
}
|
||||
|
||||
progress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = duration.toInt(),
|
||||
easing = LinearEasing
|
||||
)
|
||||
)
|
||||
// Когда прогресс дошел до конца — следующая история
|
||||
if (currentIndex < stories.size - 1) {
|
||||
currentIndex++
|
||||
progress.snapTo(0f)
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
} else {
|
||||
if (isVideo) exoPlayer.pause() else Unit
|
||||
}
|
||||
}
|
||||
|
||||
// Возобновление видео при снятии паузы
|
||||
LaunchedEffect(isPaused) {
|
||||
if (!isPaused && isVideo) exoPlayer.play()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures(
|
||||
onPress = {
|
||||
isPaused = true
|
||||
tryAwaitRelease()
|
||||
isPaused = false
|
||||
},
|
||||
onTap = { offset ->
|
||||
val screenWidth = size.width
|
||||
if (offset.x < screenWidth / 3) {
|
||||
if (currentIndex > 0) {
|
||||
currentIndex--
|
||||
scope.launch { progress.snapTo(0f) }
|
||||
}
|
||||
} else {
|
||||
if (currentIndex < stories.size - 1) {
|
||||
currentIndex++
|
||||
scope.launch { progress.snapTo(0f) }
|
||||
} else {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) {
|
||||
if (isVideo) {
|
||||
AndroidView(
|
||||
factory = {
|
||||
PlayerView(context).apply {
|
||||
player = exoPlayer
|
||||
useController = false
|
||||
resizeMode = androidx.media3.ui.AspectRatioFrameLayout.RESIZE_MODE_ZOOM
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
} else {
|
||||
AsyncImage(
|
||||
model = currentUrl,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
// Верхние индикаторы
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 16.dp, start = 8.dp, end = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
stories.forEachIndexed { index, _ ->
|
||||
val currentProgress = when {
|
||||
index < currentIndex -> 1f
|
||||
index == currentIndex -> progress.value
|
||||
else -> 0f
|
||||
}
|
||||
LinearProgressIndicator(
|
||||
progress = currentProgress,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(3.dp)
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
color = Color.White,
|
||||
trackColor = Color.White.copy(alpha = 0.3f)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
IconButton(
|
||||
onClick = onClose,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(top = 24.dp, end = 16.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Close, contentDescription = "Close", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package stories.presentation.components
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import core.presentation.components.AppAvatar
|
||||
import core.presentation.theme.SoftSquareShape
|
||||
|
||||
@Composable
|
||||
fun StoryThumbnail(
|
||||
username: String,
|
||||
avatarUrl: String?,
|
||||
hasUnseen: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val gradientBrush = Brush.sweepGradient(
|
||||
colors = listOf(Color.Cyan, Color.Magenta, Color.Yellow, Color.Cyan)
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(8.dp)
|
||||
.clickable(onClick = onClick),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.padding(4.dp)
|
||||
.then(
|
||||
if (hasUnseen) Modifier.border(3.dp, gradientBrush, SoftSquareShape)
|
||||
else Modifier.border(1.dp, Color.Gray, SoftSquareShape)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
AppAvatar(
|
||||
url = avatarUrl,
|
||||
name = username,
|
||||
size = 60.dp,
|
||||
modifier = Modifier.padding(2.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = username,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 1,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user