Skip to content

Instantly share code, notes, and snippets.

@notsatria
Created November 11, 2025 13:26
Show Gist options
  • Select an option

  • Save notsatria/804973fa7d6d442cc8d17358943f990a to your computer and use it in GitHub Desktop.

Select an option

Save notsatria/804973fa7d6d442cc8d17358943f990a to your computer and use it in GitHub Desktop.
Tiktok Clone Jetpack Compose
package dev.notsatria.animation.screen.tiktokClone
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.pager.PagerState
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlin.math.abs
@Composable
fun FeedRoute(modifier: Modifier = Modifier) {
val sampleVideos = listOf(
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2FTikTok%20Video%20from%20jenisikucing.mp4?alt=media&token=75109fab-76ad-4a5e-b15f-760fb0488a2e",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2FTikTok%20Video%20from%20hewanpunyadunia.mp4?alt=media&token=5b0cd706-941f-4f2d-b101-4bfcc2dc06a8",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2FTikTok%20Video%20from%20Goofy%20Gibber.mp4?alt=media&token=ea31cdf6-b6e4-4a4f-9e86-b733a7107837",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2F2025-10-11%2023-28-57.mp4?alt=media&token=20a04e9b-3d20-4e8b-89de-ea589f591b03",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2F2025-10-13%2021-44-49.mp4?alt=media&token=f9998936-6d57-4d5b-ab4d-71f98335f6c8",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2F2025-10-02%2021-50-43.mp4?alt=media&token=a8191a39-4a8f-4894-9538-7b7584f14536",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2F2025-07-27%2021-10-39.mp4?alt=media&token=d991dea3-8444-40da-9992-cc9d0a18d461",
"https://firebasestorage.googleapis.com/v0/b/notsatria-research.firebasestorage.app/o/uploads%2F2025-07-24%2020-06-37.mp4?alt=media&token=6e698fae-561b-40c4-9099-64a7f529ce02"
)
val playerManager = VideoPlayerManager(LocalContext.current)
DisposableEffect(Unit) {
onDispose {
playerManager.release()
}
}
FeedScreen(modifier, videoUrls = sampleVideos, playerManager = playerManager)
}
@Composable
fun FeedScreen(
modifier: Modifier = Modifier,
videoUrls: List<String> = emptyList(),
playerManager: VideoPlayerManager
) {
val pagerState = rememberPagerState {
videoUrls.size
}
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }
.distinctUntilChanged()
.collect { page ->
// Preload next video
val nextIndex = page + 1
if (nextIndex < videoUrls.size) {
playerManager.preloadVideo(videoUrls[nextIndex])
}
// Cleanup cache
val nextUrl = if (nextIndex < videoUrls.size) videoUrls[nextIndex] else null
playerManager.trimCache(videoUrls[page], nextUrl)
}
}
Box(modifier.fillMaxSize()) {
VideoVerticalPager(
modifier = Modifier.fillMaxSize(),
state = pagerState,
videoUrls = videoUrls,
playerManager = playerManager
)
}
}
@Composable
fun VideoVerticalPager(
modifier: Modifier = Modifier,
state: PagerState,
videoUrls: List<String>,
playerManager: VideoPlayerManager
) {
VerticalPager(state, modifier = modifier) { page ->
val url = videoUrls[page]
val isVisible = state.currentPage == page
val player = playerManager.getOrCreatePlayer(url)
DisposableEffect(page) {
onDispose {
// Release players that are far from current position
val distance = abs(state.currentPage - page)
if (distance > 2) {
playerManager.releasePlayer(url)
}
}
}
Box(Modifier.fillMaxSize()) {
VideoItem(
modifier = Modifier.fillMaxSize(),
player = player,
isVisible = isVisible
)
}
}
}
package dev.notsatria.animation.screen.tiktokClone
import android.view.ViewGroup
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
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.graphics.Color
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import dev.notsatria.animation.R
import kotlinx.coroutines.delay
enum class VideoState {
LOADING,
READY,
BUFFERING,
ERROR
}
@Composable
fun VideoItem(modifier: Modifier = Modifier, player: ExoPlayer, isVisible: Boolean) {
var videoState by remember { mutableStateOf(VideoState.LOADING) }
var isPaused by remember { mutableStateOf(false) }
var showPlayPauseAnimation by remember { mutableStateOf(false) }
var lastAction by remember { mutableStateOf<PlayPauseAction?>(null) }
LaunchedEffect(player) {
videoState = when (player.playbackState) {
Player.STATE_BUFFERING -> VideoState.BUFFERING
Player.STATE_READY -> VideoState.READY
Player.STATE_ENDED -> VideoState.READY
Player.STATE_IDLE -> VideoState.LOADING
else -> VideoState.LOADING
}
val listener = object : Player.Listener {
override fun onPlaybackStateChanged(playbackState: Int) {
videoState = when (playbackState) {
Player.STATE_BUFFERING -> VideoState.BUFFERING
Player.STATE_READY -> VideoState.READY
Player.STATE_ENDED -> VideoState.READY
Player.STATE_IDLE -> VideoState.LOADING
else -> VideoState.LOADING
}
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
// Only update isPaused when video is ready
if (videoState == VideoState.READY) {
isPaused = !isPlaying
}
}
override fun onPlayerError(error: androidx.media3.common.PlaybackException) {
videoState = VideoState.ERROR
}
}
player.addListener(listener)
}
LaunchedEffect(isVisible) {
if (isVisible) {
player.playWhenReady = true
player.play()
isPaused = false
} else {
player.pause()
player.playWhenReady = false
}
}
DisposableEffect(player) {
onDispose {
player.pause()
}
}
Box(modifier.fillMaxSize()) {
// Video player
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
useController = false
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
this.player = player
}
},
update = { view ->
if (view.player != player) {
view.player = player
}
},
modifier = Modifier.fillMaxSize()
)
// Full-screen clickable overlay
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null // Remove ripple effect
) {
if (videoState == VideoState.READY) {
if (player.isPlaying) {
player.pause()
lastAction = PlayPauseAction.PAUSE
} else {
player.play()
lastAction = PlayPauseAction.PLAY
}
showPlayPauseAnimation = true
}
}
)
// Persistent pause indicator (small, subtle)
AnimatedVisibility(
visible = isPaused && videoState == VideoState.READY && !showPlayPauseAnimation,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200)),
modifier = Modifier.align(Alignment.Center)
) {
Icon(
painter = painterResource(id = R.drawable.ic_pause),
contentDescription = "Paused",
tint = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp)
)
}
// Animated play/pause feedback (shows on tap)
AnimatedVisibility(
visible = showPlayPauseAnimation,
enter = fadeIn(tween(150)) + scaleIn(
tween(150),
initialScale = 0.7f
),
exit = fadeOut(tween(150)) + scaleOut(
tween(150),
targetScale = 1.3f
),
modifier = Modifier.align(Alignment.Center)
) {
LaunchedEffect(Unit) {
delay(400) // Show for 400ms
showPlayPauseAnimation = false
}
Box(
modifier = Modifier
.size(100.dp)
.background(
Color.Black.copy(alpha = 0.6f),
shape = CircleShape
),
contentAlignment = Alignment.Center
) {
Icon(
painter = painterResource(
id = when (lastAction) {
PlayPauseAction.PLAY -> R.drawable.ic_play
PlayPauseAction.PAUSE -> R.drawable.ic_pause
null -> R.drawable.ic_pause
}
),
contentDescription = when (lastAction) {
PlayPauseAction.PLAY -> "Playing"
PlayPauseAction.PAUSE -> "Paused"
null -> null
},
tint = Color.White,
modifier = Modifier.size(48.dp)
)
}
}
// Loading overlay
AnimatedVisibility(
visible = videoState == VideoState.LOADING || videoState == VideoState.BUFFERING,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(
color = Color.White
)
}
}
// Error state
AnimatedVisibility(
visible = videoState == VideoState.ERROR,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.fillMaxSize()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
contentAlignment = Alignment.Center
) {
androidx.compose.material3.Text(
text = "Failed to load video",
color = Color.White
)
}
}
}
}
private enum class PlayPauseAction {
PLAY,
PAUSE
}
package dev.notsatria.animation.screen.tiktokClone
import android.content.Context
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
class VideoPlayerManager(private val context: Context) {
private val playerPool = mutableMapOf<String, ExoPlayer>()
private val maxCachedPlayers = 3
fun getOrCreatePlayer(url: String): ExoPlayer {
return playerPool.getOrPut(url) {
ExoPlayer.Builder(context).build().apply {
setMediaItem(MediaItem.fromUri(url))
prepare()
repeatMode = Player.REPEAT_MODE_ONE
// Pre-buffer video but don't play yet
playWhenReady = false
}
}
}
fun preloadVideo(url: String) {
if (!playerPool.containsKey(url) && playerPool.size < maxCachedPlayers) {
getOrCreatePlayer(url)
}
}
fun releasePlayer(url: String) {
playerPool[url]?.release()
playerPool.remove(url)
}
fun trimCache(currentUrl: String, nextUrl: String?) {
if (playerPool.size > maxCachedPlayers) {
playerPool.keys.toList().forEach { url ->
if (url != currentUrl && url != nextUrl) {
releasePlayer(url)
}
}
}
}
fun release() {
playerPool.values.forEach { it.release() }
playerPool.clear()
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment