v0.1.0-AA (vc=15): inline player on VideoDetail + fullscreen pill
Tap the 16:9 thumbnail box on VideoDetail and the video plays right there in the card — like YouTube. Uses its own ExoPlayer (released on nav-back via DisposableEffect) with PlayerView's built-in controls (play/pause/seek/duration bar). Top-right ⛶ pill on the inline player jumps to the existing fullscreen PlayerScreen which still has the full toolset (speed picker, audio-only, share, PiP, background, SponsorBlock chip). Restarts from 0 on entry — seek-position handoff between inline + fullscreen is a future refinement. Inline player state (playing/not-playing) is keyed on streamUrl so navigating to a different video resets it back to the thumbnail-with- play-overlay default.
This commit is contained in:
parent
75329867e9
commit
5b36de8888
2 changed files with 165 additions and 25 deletions
|
|
@ -15,6 +15,6 @@ const val NEWPIPE_APPLICATION_ID_OLD = "org.schabi.newpipe"
|
||||||
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
|
||||||
|
|
||||||
// Sulkta fork — Straw
|
// Sulkta fork — Straw
|
||||||
const val STRAW_VERSION_CODE = 14
|
const val STRAW_VERSION_CODE = 15
|
||||||
const val STRAW_VERSION_NAME = "0.1.0-Z"
|
const val STRAW_VERSION_NAME = "0.1.0-AA"
|
||||||
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
const val STRAW_APPLICATION_ID = "com.sulkta.straw"
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,17 @@ import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
|
import androidx.annotation.OptIn
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.platform.LocalContext
|
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.DownloadKind
|
||||||
import com.sulkta.straw.feature.download.Downloader
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.PlayArrow
|
import androidx.compose.material.icons.filled.PlayArrow
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
|
@ -53,7 +57,19 @@ import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
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 coil3.compose.AsyncImage
|
||||||
|
import com.sulkta.straw.extractor.NewPipeDownloader
|
||||||
import com.sulkta.straw.util.formatCount
|
import com.sulkta.straw.util.formatCount
|
||||||
import com.sulkta.straw.util.formatViews
|
import com.sulkta.straw.util.formatViews
|
||||||
import com.sulkta.straw.util.stripHtml
|
import com.sulkta.straw.util.stripHtml
|
||||||
|
|
@ -70,6 +86,9 @@ fun VideoDetailScreen(
|
||||||
val state by vm.ui.collectAsStateWithLifecycle()
|
val state by vm.ui.collectAsStateWithLifecycle()
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
var showDownloadDialog by remember { mutableStateOf(false) }
|
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) }
|
LaunchedEffect(streamUrl) { vm.load(streamUrl) }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -92,35 +111,47 @@ fun VideoDetailScreen(
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
val d = state.detail ?: return@Column
|
val d = state.detail ?: return@Column
|
||||||
// AUD-feedback: tap the thumbnail to play. Was hidden under
|
// Tap the thumbnail to play inline. Fullscreen button (top-right
|
||||||
// a "Play" button below. Now the thumbnail is the obvious
|
// overlay on the inline player) jumps to the fullscreen Player
|
||||||
// affordance with a play-icon overlay.
|
// screen which has the full toolset.
|
||||||
Box(
|
if (inlinePlaying) {
|
||||||
modifier = Modifier
|
InlinePlayer(
|
||||||
.fillMaxWidth()
|
streamUrl = streamUrl,
|
||||||
.aspectRatio(16f / 9f)
|
onFullscreen = onPlay,
|
||||||
.clip(RoundedCornerShape(8.dp))
|
modifier = Modifier
|
||||||
.clickable(onClick = onPlay),
|
.fillMaxWidth()
|
||||||
contentAlignment = Alignment.Center,
|
.aspectRatio(16f / 9f)
|
||||||
) {
|
.clip(RoundedCornerShape(8.dp))
|
||||||
AsyncImage(
|
.background(Color.Black),
|
||||||
model = d.thumbnail,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
)
|
||||||
|
} else {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(64.dp)
|
.fillMaxWidth()
|
||||||
.clip(androidx.compose.foundation.shape.CircleShape)
|
.aspectRatio(16f / 9f)
|
||||||
.background(Color(0xCC000000)),
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.clickable { inlinePlaying = true },
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
Icon(
|
AsyncImage(
|
||||||
Icons.Filled.PlayArrow,
|
model = d.thumbnail,
|
||||||
contentDescription = "Play",
|
contentDescription = null,
|
||||||
tint = Color.White,
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.size(40.dp),
|
|
||||||
)
|
)
|
||||||
|
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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue