Created
November 25, 2025 13:17
-
-
Save notsatria/85f675892da4545b5e00fd39a9186eba to your computer and use it in GitHub Desktop.
Tiktok Clone + PiP mode
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <?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> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @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 | |
| ) | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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") | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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