diff --git a/buildSrc/src/main/kotlin/ProjectConfig.kt b/buildSrc/src/main/kotlin/ProjectConfig.kt index e227098d7..0a9e73122 100644 --- a/buildSrc/src/main/kotlin/ProjectConfig.kt +++ b/buildSrc/src/main/kotlin/ProjectConfig.kt @@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe" const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app" // Sulkta fork — Straw -const val STRAW_VERSION_CODE = 14 -const val STRAW_VERSION_NAME = "0.1.0-Z" +const val STRAW_VERSION_CODE = 15 +const val STRAW_VERSION_NAME = "0.1.0-AA" const val STRAW_APPLICATION_ID = "com.sulkta.straw" diff --git a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt index 74a91cd06..1720dd4da 100644 --- a/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt +++ b/strawApp/src/main/kotlin/com/sulkta/straw/feature/detail/VideoDetailScreen.kt @@ -33,13 +33,17 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import android.content.Intent import android.widget.Toast +import androidx.annotation.OptIn import androidx.compose.material3.AlertDialog +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView import com.sulkta.straw.feature.download.DownloadKind import com.sulkta.straw.feature.download.Downloader +import com.sulkta.straw.feature.player.PlayerViewModel import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.runtime.Composable @@ -53,7 +57,19 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.dash.DashMediaSource +import androidx.media3.exoplayer.hls.HlsMediaSource +import androidx.media3.exoplayer.source.MergingMediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import androidx.media3.ui.PlayerView import coil3.compose.AsyncImage +import com.sulkta.straw.extractor.NewPipeDownloader import com.sulkta.straw.util.formatCount import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.stripHtml @@ -70,6 +86,9 @@ fun VideoDetailScreen( val state by vm.ui.collectAsStateWithLifecycle() val context = LocalContext.current var showDownloadDialog by remember { mutableStateOf(false) } + // Inline-play state. Resets when the user navigates to a different + // video (keyed on streamUrl). + var inlinePlaying by remember(streamUrl) { mutableStateOf(false) } LaunchedEffect(streamUrl) { vm.load(streamUrl) } Column( @@ -92,35 +111,47 @@ fun VideoDetailScreen( else -> { val d = state.detail ?: return@Column - // AUD-feedback: tap the thumbnail to play. Was hidden under - // a "Play" button below. Now the thumbnail is the obvious - // affordance with a play-icon overlay. - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(16f / 9f) - .clip(RoundedCornerShape(8.dp)) - .clickable(onClick = onPlay), - contentAlignment = Alignment.Center, - ) { - AsyncImage( - model = d.thumbnail, - contentDescription = null, - modifier = Modifier.fillMaxSize(), + // Tap the thumbnail to play inline. Fullscreen button (top-right + // overlay on the inline player) jumps to the fullscreen Player + // screen which has the full toolset. + if (inlinePlaying) { + InlinePlayer( + streamUrl = streamUrl, + onFullscreen = onPlay, + modifier = Modifier + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)) + .background(Color.Black), ) + } else { Box( modifier = Modifier - .size(64.dp) - .clip(androidx.compose.foundation.shape.CircleShape) - .background(Color(0xCC000000)), + .fillMaxWidth() + .aspectRatio(16f / 9f) + .clip(RoundedCornerShape(8.dp)) + .clickable { inlinePlaying = true }, contentAlignment = Alignment.Center, ) { - Icon( - Icons.Filled.PlayArrow, - contentDescription = "Play", - tint = Color.White, - modifier = Modifier.size(40.dp), + AsyncImage( + model = d.thumbnail, + contentDescription = null, + modifier = Modifier.fillMaxSize(), ) + Box( + modifier = Modifier + .size(64.dp) + .clip(androidx.compose.foundation.shape.CircleShape) + .background(Color(0xCC000000)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Filled.PlayArrow, + contentDescription = "Play", + tint = Color.White, + modifier = Modifier.size(40.dp), + ) + } } } Spacer(modifier = Modifier.height(12.dp)) @@ -346,3 +377,112 @@ private fun RelatedRow( } } +/** + * Inline player embedded in the 16:9 thumbnail box on VideoDetailScreen. + * Uses its own ExoPlayer + PlayerView (with the built-in controller for + * play/pause/seek). A small fullscreen pill in the top-right hops the user + * to the fullscreen PlayerScreen for the full toolset (speed picker, audio- + * only, share, PiP, background). Player is released when the composable + * leaves composition (navigate back or away from VideoDetail). + */ +@OptIn(UnstableApi::class) +@Composable +private fun InlinePlayer( + streamUrl: String, + onFullscreen: () -> Unit, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val playerVm: PlayerViewModel = viewModel() + val state by playerVm.ui.collectAsStateWithLifecycle() + LaunchedEffect(streamUrl) { playerVm.resolve(streamUrl) } + + val exoPlayer = remember { + ExoPlayer.Builder(context) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MOVIE) + .build(), + /* handleAudioFocus = */ true, + ) + .build() + } + + DisposableEffect(Unit) { + onDispose { exoPlayer.release() } + } + + val resolved = state.resolved + LaunchedEffect(resolved) { + val r = resolved ?: return@LaunchedEffect + val dataSourceFactory = DefaultHttpDataSource.Factory() + .setUserAgent(NewPipeDownloader.USER_AGENT) + .setAllowCrossProtocolRedirects(true) + val source = when { + r.dashMpdUrl != null -> DashMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(r.dashMpdUrl)) + r.hlsUrl != null -> HlsMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(r.hlsUrl)) + r.combinedUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(r.combinedUrl)) + r.videoUrl != null && r.audioUrl != null -> { + val v = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(r.videoUrl)) + val a = ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(r.audioUrl)) + MergingMediaSource(v, a) + } + r.videoUrl != null -> ProgressiveMediaSource.Factory(dataSourceFactory) + .createMediaSource(MediaItem.fromUri(r.videoUrl)) + else -> null + } + if (source != null) { + exoPlayer.setMediaSource(source) + exoPlayer.prepare() + exoPlayer.playWhenReady = true + } + } + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + when { + state.loading -> CircularProgressIndicator(color = Color.White) + state.error != null -> Text( + "playback error: ${state.error}", + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(16.dp), + ) + resolved?.isPlayable != true -> Text( + "no playable stream", + color = Color.White, + modifier = Modifier.padding(16.dp), + ) + else -> { + AndroidView( + factory = { ctx -> + PlayerView(ctx).apply { + player = exoPlayer + useController = true + } + }, + modifier = Modifier.fillMaxSize(), + ) + // Top-right fullscreen pill — hops to the fullscreen + // PlayerScreen which has speed/audio-only/share/PiP/background. + Box( + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp) + .size(36.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color(0xCC222222)) + .clickable(onClick = onFullscreen), + contentAlignment = Alignment.Center, + ) { + Text("⛶", color = Color.White) + } + } + } + } +} +