Straw phase M-1: tap-thumbnail-to-play + hi-res thumbs + Picture-in-Picture

a tester's real-use feedback: "playing a video is a button under a very
pixelated thumbnail, no playing in window mode, ... where are all the
features." This is the visible-UX pass.

Thumbnails:
- New util/Thumbnails.kt#bestThumbnail picks the highest-res image from
  NewPipeExtractor's List<Image> by w*h pixel area. Was firstOrNull()
  which is the smallest in the sorted list. Applied across Search,
  Channel video rows, Channel avatar + banner, VideoDetail.

Tap-to-play:
- VideoDetail thumbnail is now wrapped in a clickable Box that fires
  onPlay(). Centered semi-transparent black circle with a white play
  triangle overlay so the affordance is obvious. The standalone "Play"
  button below stays for accessibility (tap target consistency).

Picture-in-Picture:
- Manifest: android:supportsPictureInPicture="true" on StrawActivity +
  added screenLayout|smallestScreenSize|screenSize to configChanges so
  rotation into PiP doesn't recreate.
- PlayerScreen: top-right floating button enters PiP with 16:9 aspect.
- Lifecycle ON_STOP observer now checks isInPictureInPictureMode and
  skips pausing when we're in PiP (was killing playback on PiP entry).

Phase M-2 next: MediaSession + background audio + foreground service
(the "play with screen off / lock screen controls" feature).
This commit is contained in:
Sulkta 2026-05-23 20:40:53 -07:00
parent ae8bef117f
commit d0a5122e4f
7 changed files with 108 additions and 13 deletions

View file

@ -16,7 +16,8 @@
android:name=".StrawActivity" android:name=".StrawActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTask" android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden"> android:supportsPictureInPicture="true"
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|keyboardHidden">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />

View file

@ -8,6 +8,7 @@ package com.sulkta.straw.feature.channel
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import org.schabi.newpipe.extractor.NewPipe import org.schabi.newpipe.extractor.NewPipe
import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.ServiceList
@ -61,7 +62,7 @@ class ChannelViewModel : ViewModel() {
title = it.name ?: "(no title)", title = it.name ?: "(no title)",
uploader = it.uploaderName ?: info.name ?: "", uploader = it.uploaderName ?: info.name ?: "",
uploaderUrl = it.uploaderUrl ?: channelUrl, uploaderUrl = it.uploaderUrl ?: channelUrl,
thumbnail = it.thumbnails?.firstOrNull()?.url, thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration, durationSeconds = it.duration,
viewCount = it.viewCount, viewCount = it.viewCount,
) )
@ -74,8 +75,8 @@ class ChannelViewModel : ViewModel() {
loading = false, loading = false,
name = info.name ?: "", name = info.name ?: "",
subscriberCount = info.subscriberCount, subscriberCount = info.subscriberCount,
banner = info.banners?.firstOrNull()?.url, banner = bestThumbnail(info.banners),
avatar = info.avatars?.firstOrNull()?.url, avatar = bestThumbnail(info.avatars),
videos = videos, videos = videos,
) )
} catch (t: Throwable) { } catch (t: Throwable) {

View file

@ -5,6 +5,7 @@
package com.sulkta.straw.feature.detail package com.sulkta.straw.feature.detail
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -16,6 +17,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@ -24,8 +26,11 @@ import androidx.compose.material3.AssistChip
import androidx.compose.material3.AssistChipDefaults import androidx.compose.material3.AssistChipDefaults
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -72,14 +77,37 @@ fun VideoDetailScreen(
else -> { else -> {
val d = state.detail ?: return@Column val d = state.detail ?: return@Column
AsyncImage( // AUD-feedback: tap the thumbnail to play. Was hidden under
model = d.thumbnail, // a "Play" button below. Now the thumbnail is the obvious
contentDescription = null, // affordance with a play-icon overlay.
Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.aspectRatio(16f / 9f) .aspectRatio(16f / 9f)
.clip(RoundedCornerShape(8.dp)), .clip(RoundedCornerShape(8.dp))
) .clickable(onClick = onPlay),
contentAlignment = Alignment.Center,
) {
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)) Spacer(modifier = Modifier.height(12.dp))
Text( Text(
text = d.title, text = d.title,

View file

@ -13,6 +13,7 @@ import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.net.RydClient import com.sulkta.straw.net.RydClient
import com.sulkta.straw.net.RydVotes import com.sulkta.straw.net.RydVotes
import com.sulkta.straw.net.SponsorBlockClient import com.sulkta.straw.net.SponsorBlockClient
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -57,7 +58,7 @@ class VideoDetailViewModel : ViewModel() {
try { try {
val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) } val info = withContext(Dispatchers.IO) { StreamInfo.getInfo(streamUrl) }
val videoId = info.id val videoId = info.id
val thumb = info.thumbnails?.firstOrNull()?.url val thumb = bestThumbnail(info.thumbnails)
val title = info.name ?: "(no title)" val title = info.name ?: "(no title)"
val uploader = info.uploaderName ?: "" val uploader = info.uploaderName ?: ""

View file

@ -8,12 +8,18 @@
package com.sulkta.straw.feature.player package com.sulkta.straw.feature.player
import android.app.Activity
import android.app.PictureInPictureParams
import android.os.Build
import android.util.Rational
import android.widget.Toast import android.widget.Toast
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@ -71,11 +77,17 @@ fun PlayerScreen(
// AUD-MED: pause playback when app goes to background. Without this, // AUD-MED: pause playback when app goes to background. Without this,
// ExoPlayer keeps playing audio with no MediaSession — user can't pause // ExoPlayer keeps playing audio with no MediaSession — user can't pause
// from the notification shade. // from the notification shade. EXCEPTION: don't pause when entering
// Picture-in-Picture mode (that's the whole point of PiP).
val lifecycleOwner = LocalLifecycleOwner.current val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner) { DisposableEffect(lifecycleOwner) {
val observer = LifecycleEventObserver { _, event -> val observer = LifecycleEventObserver { _, event ->
if (event == Lifecycle.Event.ON_STOP) exoPlayer.pause() if (event == Lifecycle.Event.ON_STOP) {
val activity = context as? Activity
if (activity?.isInPictureInPictureMode != true) {
exoPlayer.pause()
}
}
} }
lifecycleOwner.lifecycle.addObserver(observer) lifecycleOwner.lifecycle.addObserver(observer)
onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
@ -200,6 +212,33 @@ fun PlayerScreen(
) )
} }
} }
// PiP button — top-right. Tapping it puts the player into
// floating-window mode so the user can use other apps while
// the video keeps playing.
Box(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(12.dp)
.size(36.dp)
.clip(RoundedCornerShape(6.dp))
.background(Color(0xCC222222))
.clickable {
val activity = (context as? Activity) ?: return@clickable
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9))
.build()
runCatching { activity.enterPictureInPictureMode(params) }
}
},
contentAlignment = Alignment.Center,
) {
Text(
text = "",
color = Color.White,
style = MaterialTheme.typography.titleMedium,
)
}
} }
} }
} }

View file

@ -8,6 +8,7 @@ package com.sulkta.straw.feature.search
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.sulkta.straw.data.History import com.sulkta.straw.data.History
import com.sulkta.straw.util.bestThumbnail
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -74,7 +75,7 @@ class SearchViewModel : ViewModel() {
title = it.name ?: "(no title)", title = it.name ?: "(no title)",
uploader = it.uploaderName ?: "", uploader = it.uploaderName ?: "",
uploaderUrl = it.uploaderUrl, uploaderUrl = it.uploaderUrl,
thumbnail = it.thumbnails?.firstOrNull()?.url, thumbnail = bestThumbnail(it.thumbnails),
durationSeconds = it.duration, durationSeconds = it.duration,
viewCount = it.viewCount, viewCount = it.viewCount,
) )

View file

@ -0,0 +1,24 @@
/*
* SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later
*
* NewPipeExtractor returns thumbnails as a List<Image> with width/height
* fields. Calling .firstOrNull() picks the smallest (the list is sorted
* ascending) which gave us pixelated thumbnails. This helper picks the
* largest by pixel area instead.
*/
package com.sulkta.straw.util
import org.schabi.newpipe.extractor.Image
fun bestThumbnail(images: List<Image>?): String? {
if (images.isNullOrEmpty()) return null
return images
.maxByOrNull {
val w = it.width.takeIf { v -> v > 0 } ?: 0
val h = it.height.takeIf { v -> v > 0 } ?: 0
w.toLong() * h.toLong()
}
?.url
}