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:
parent
ae8bef117f
commit
d0a5122e4f
7 changed files with 108 additions and 13 deletions
|
|
@ -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" />
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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 ?: ""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
24
strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt
Normal file
24
strawApp/src/main/kotlin/com/sulkta/straw/util/Thumbnails.kt
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue