Skip to content

Instantly share code, notes, and snippets.

@notsatria
Created November 25, 2025 13:17
Show Gist options
  • Select an option

  • Save notsatria/85f675892da4545b5e00fd39a9186eba to your computer and use it in GitHub Desktop.

Select an option

Save notsatria/85f675892da4545b5e00fd39a9186eba to your computer and use it in GitHub Desktop.
Tiktok Clone + PiP mode
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Research">
<activity
android:name=".MainActivity"
android:configChanges="orientation|screenLayout|screenSize|smallestScreenSize"
android:exported="true"
android:label="@string/app_name"
android:supportsPictureInPicture="true"
android:theme="@style/Theme.Research">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@RequiresApi(Build.VERSION_CODES.O)
@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)
}
@RequiresApi(Build.VERSION_CODES.O)
@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
)
}
}
@RequiresApi(Build.VERSION_CODES.O)
@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
)
}
}
}
internal fun Context.findActivity(): ComponentActivity {
var context = this
while (context is ContextWrapper) {
if (context is ComponentActivity) return context
context = context.baseContext
}
throw IllegalStateException("Picture in picture should be called in the context of an Activity")
}
enum class VideoState {
LOADING,
READY,
BUFFERING,
ERROR
}
@RequiresApi(Build.VERSION_CODES.O)
@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) }
var wasLongPress by remember { mutableStateOf(false) }
var isFastForwarding by remember { mutableStateOf(false) }
val context = LocalContext.current
val isInPiP = rememberIsInPipMode()
val pipModifier = modifier.onGloballyPositioned { layoutCoordinates ->
val builder = PictureInPictureParams.Builder()
if (player.videoSize != VideoSize.UNKNOWN) {
val sourceRect = layoutCoordinates.boundsInWindow().toAndroidRectF().toRect()
builder.setSourceRectHint(sourceRect)
builder.setAspectRatio(
Rational(player.videoSize.width, player.videoSize.height)
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
builder.setAutoEnterEnabled(true)
}
context.findActivity().setPictureInPictureParams(builder.build())
}
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, isInPiP) {
if (isVisible) {
player.playWhenReady = true
player.play()
isPaused = false
} else if (!isInPiP) {
player.pause()
player.playWhenReady = false
}
}
DisposableEffect(player) {
onDispose {
player.pause()
}
}
BoxWithConstraints(pipModifier.fillMaxSize()) {
val screenWidth = maxWidth
// 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()
.background(Color.Black)
)
// Full-screen clickable overlay
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(player, screenWidth) {
detectTapGestures(
onTap = {
if (wasLongPress) {
wasLongPress = false
} else {
if (videoState == VideoState.READY) {
if (player.isPlaying) {
player.pause()
lastAction = PlayPauseAction.PAUSE
} else {
player.play()
lastAction = PlayPauseAction.PLAY
}
showPlayPauseAnimation = true
}
}
},
onPress = { offset ->
if (offset.x > screenWidth.value / 2f) {
try {
// Tunggu long press
withTimeout(viewConfiguration.longPressTimeoutMillis) {
awaitRelease() // Tunggu jari diangkat
}
} catch (e: TimeoutCancellationException) {
isFastForwarding = true
wasLongPress = true
player.setPlaybackSpeed(2.0f)
awaitRelease()
player.setPlaybackSpeed(1.0f)
isFastForwarding = false
}
} else {
try {
awaitRelease()
} catch (e: TimeoutCancellationException) {
awaitRelease()
}
}
},
)
}
)
// Fast forward text
AnimatedVisibility(
visible = isFastForwarding,
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 90.dp),
enter = fadeIn(tween(100)),
exit = fadeOut(tween(100))
) {
Text(
text = "Speed: 2x",
color = Color.White,
fontSize = 14.sp,
modifier = Modifier
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
.padding(horizontal = 12.dp, vertical = 6.dp)
)
}
// 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
)
}
}
// PiP indicator
AnimatedVisibility(
visible = videoState == VideoState.READY && !isInPiP,
enter = fadeIn(tween(300)),
exit = fadeOut(tween(200)),
modifier = Modifier
.align(Alignment.TopEnd)
.padding(16.dp)
) {
IconButton(onClick = {
context.findActivity().enterPictureInPictureMode(
PictureInPictureParams.Builder().build()
)
}) {
Icon(
painter = painterResource(id = R.drawable.ic_picture_in_picture),
contentDescription = "PiP",
tint = Color.White.copy(alpha = 0.6f),
modifier = Modifier.size(48.dp)
)
}
}
}
}
@Composable
fun rememberIsInPipMode(): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val activity = LocalContext.current.findActivity()
var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) }
DisposableEffect(activity) {
val observer = Consumer<PictureInPictureModeChangedInfo> { info ->
pipMode = info.isInPictureInPictureMode
}
activity.addOnPictureInPictureModeChangedListener(
observer
)
onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) }
}
return pipMode
} else {
return false
}
}
private enum class PlayPauseAction {
PLAY,
PAUSE
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment