vc=26: look + feel pass — sulkta.com palette + Material Icons

Color palette pulled directly from sulkta.com's stylesheet — the same
greens used on the website now drive the app theme:
  #166534  deep green   (light-theme primary, top app bar background)
  #4ade80  bright lime  (dark-theme primary, accents in dark mode)
  #86efac  light green  (primaryContainer in light theme)
  #e8f5e8  pale green   (secondary container tint)
  #d97706  amber accent (tertiary)
  #374137  olive gray   (secondary on light, container on dark)

Replaces the made-up forest palette from vc=23 with the real Sulkta
brand. Same M3 tonal-role mapping so derived surfaces stay consistent.

TopAppBar redone NewPipe-style: solid deep-green bar with white
"straw" title, white hamburger + search icons. Clear bold header
instead of the previous white-with-a-pill-underneath layout.

Material Icons swapped in everywhere we had emoji:
  drawer        Person / History / PlaylistPlay / Download / Settings
  minibar       PlayArrow / Pause / Close
  fullscreen    Speed / Headphones / Videocam / Share /
                PictureInPictureAlt / KeyboardArrowDown
Pulled in material-icons-extended (4 MB APK growth, all icons).
Consistent renders across vendors; no more emoji font fallback drift.

FeedRow gets a NewPipe-style duration pill burned into the bottom-right
of every thumbnail (mm:ss / h:mm:ss). Live streams / mixes with no
duration leave it off.

Audit deferred-MED items addressed:
  MED-6: dropped the PlayerService STATE_ENDED auto-stop. Service
  shutdown is now driven only by onTaskRemoved + the minibar's ×.
  Removes the implicit "we'll never queue" assumption and is correct
  for a future autoplay/queue feature.
  LOW-7: DownloadsScreen adaptive poll — 1s while a download is
  active, 5s when idle. No more wasted DB queries when nothing
  is running.
This commit is contained in:
Sulkta 2026-05-25 17:46:23 +00:00
parent 38bc663777
commit 5ced0f00c4
8 changed files with 207 additions and 124 deletions

View file

@ -55,6 +55,6 @@ const val NEWPIPE_APPLICATION_ID_NEW = "net.newpipe.app"
// vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via // vc=19 / 0.1.0-AE — rust pipeline cutover. Extraction via
// strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no // strawcore-core (Sulkta-OSS/strawcore) via the UniFFI wrapper; no
// NewPipeExtractor in the runtime path. // NewPipeExtractor in the runtime path.
const val STRAW_VERSION_CODE = 25 const val STRAW_VERSION_CODE = 26
const val STRAW_VERSION_NAME = "0.1.0-AK" const val STRAW_VERSION_NAME = "0.1.0-AL"
const val STRAW_APPLICATION_ID = "com.sulkta.straw" const val STRAW_APPLICATION_ID = "com.sulkta.straw"

View file

@ -81,7 +81,7 @@ dependencies {
implementation(libs.jetbrains.compose.foundation) implementation(libs.jetbrains.compose.foundation)
implementation(libs.jetbrains.compose.material3) implementation(libs.jetbrains.compose.material3)
implementation(libs.jetbrains.compose.ui) implementation(libs.jetbrains.compose.ui)
implementation("androidx.compose.material:material-icons-core:1.7.5") implementation("androidx.compose.material:material-icons-extended:1.7.5")
// Lifecycle + ViewModel for Compose // Lifecycle + ViewModel for Compose
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")

View file

@ -8,8 +8,10 @@
package com.sulkta.straw package com.sulkta.straw
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.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
@ -25,7 +27,13 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.History
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.PlaylistPlay
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue import androidx.compose.material3.DrawerValue
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -36,14 +44,12 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.NavigationDrawerItem import androidx.compose.material3.NavigationDrawerItem
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberDrawerState import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -68,6 +74,7 @@ import com.sulkta.straw.data.Subscriptions
import com.sulkta.straw.data.WatchHistoryItem import com.sulkta.straw.data.WatchHistoryItem
import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel import com.sulkta.straw.feature.feed.SubscriptionFeedViewModel
import com.sulkta.straw.feature.search.StreamItem import com.sulkta.straw.feature.search.StreamItem
import com.sulkta.straw.OverlayDimColor
import com.sulkta.straw.util.formatViews import com.sulkta.straw.util.formatViews
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -109,7 +116,7 @@ fun StrawHome(
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Subscriptions") }, label = { Text("Subscriptions") },
icon = { Text("👤") }, icon = { Icon(Icons.Filled.Person, contentDescription = null) },
selected = view == HomeView.Subs, selected = view == HomeView.Subs,
onClick = { onClick = {
view = HomeView.Subs view = HomeView.Subs
@ -119,7 +126,7 @@ fun StrawHome(
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("History") }, label = { Text("History") },
icon = { Text("📺") }, icon = { Icon(Icons.Filled.History, contentDescription = null) },
selected = view == HomeView.History, selected = view == HomeView.History,
onClick = { onClick = {
view = HomeView.History view = HomeView.History
@ -129,7 +136,7 @@ fun StrawHome(
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Playlists") }, label = { Text("Playlists") },
icon = { Text("📃") }, icon = { Icon(Icons.Filled.PlaylistPlay, contentDescription = null) },
selected = false, selected = false,
onClick = { onClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@ -139,7 +146,7 @@ fun StrawHome(
) )
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Downloads") }, label = { Text("Downloads") },
icon = { Text("") }, icon = { Icon(Icons.Filled.Download, contentDescription = null) },
selected = false, selected = false,
onClick = { onClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@ -150,7 +157,7 @@ fun StrawHome(
HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp)) HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
NavigationDrawerItem( NavigationDrawerItem(
label = { Text("Settings") }, label = { Text("Settings") },
icon = { Text("") }, icon = { Icon(Icons.Filled.Settings, contentDescription = null) },
selected = false, selected = false,
onClick = { onClick = {
scope.launch { drawerState.close() } scope.launch { drawerState.close() }
@ -163,42 +170,33 @@ fun StrawHome(
) { ) {
Scaffold( Scaffold(
topBar = { topBar = {
// Green-tinted bar inspired by NewPipe/Tubular's colored
// header, but using our forest-green primary container so
// it sits cleanly with the rest of the Material 3 surfaces.
TopAppBar( TopAppBar(
title = { title = {
// Search-pill in the title slot — tap takes you to the Text(
// full search screen with the field auto-focused. Same "straw",
// idea as YT's mobile top bar. style = MaterialTheme.typography.titleLarge,
Surface( fontWeight = FontWeight.SemiBold,
modifier = Modifier )
.fillMaxWidth()
.padding(end = 8.dp)
.height(40.dp)
.clip(RoundedCornerShape(20.dp))
.clickable(onClick = onOpenSearch),
color = MaterialTheme.colorScheme.surfaceVariant,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.padding(horizontal = 14.dp),
) {
Text(
"🔍",
style = MaterialTheme.typography.bodyMedium,
)
Spacer(modifier = Modifier.width(10.dp))
Text(
"Search YouTube",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}, },
navigationIcon = { navigationIcon = {
IconButton(onClick = { scope.launch { drawerState.open() } }) { IconButton(onClick = { scope.launch { drawerState.open() } }) {
Icon(Icons.Filled.Menu, contentDescription = "Menu") Icon(Icons.Filled.Menu, contentDescription = "Menu")
} }
}, },
actions = {
IconButton(onClick = onOpenSearch) {
Icon(Icons.Filled.Search, contentDescription = "Search")
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primary,
titleContentColor = MaterialTheme.colorScheme.onPrimary,
navigationIconContentColor = MaterialTheme.colorScheme.onPrimary,
actionIconContentColor = MaterialTheme.colorScheme.onPrimary,
),
) )
}, },
) { padding -> ) { padding ->
@ -352,13 +350,12 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top,
) { ) {
AsyncImage( ThumbnailWithDuration(
model = item.thumbnail, thumbnail = item.thumbnail,
contentDescription = null, durationSeconds = item.durationSeconds,
modifier = Modifier modifier = Modifier
.width(140.dp) .width(140.dp)
.height(80.dp) .height(80.dp),
.clip(RoundedCornerShape(6.dp)),
) )
Spacer(modifier = Modifier.width(10.dp)) Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
@ -387,6 +384,48 @@ private fun FeedRow(item: StreamItem, onClick: () -> Unit) {
} }
} }
/**
* 16:9 thumbnail with a NewPipe-style duration pill burned into the
* bottom-right corner. `durationSeconds == 0` skips the badge (live
* streams, mixes that come back without a duration, etc.).
*/
@Composable
private fun ThumbnailWithDuration(
thumbnail: String?,
durationSeconds: Long,
modifier: Modifier = Modifier,
) {
Box(modifier = modifier) {
AsyncImage(
model = thumbnail,
contentDescription = null,
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(6.dp)),
)
if (durationSeconds > 0) {
Text(
text = formatDurationShort(durationSeconds),
style = MaterialTheme.typography.labelSmall,
color = androidx.compose.ui.graphics.Color.White,
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(4.dp)
.clip(RoundedCornerShape(3.dp))
.background(OverlayDimColor)
.padding(horizontal = 4.dp, vertical = 1.dp),
)
}
}
}
private fun formatDurationShort(totalSec: Long): String {
val h = totalSec / 3600
val m = (totalSec % 3600) / 60
val s = totalSec % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, s) else "%d:%02d".format(m, s)
}
@Composable @Composable
private fun SubChip( private fun SubChip(
ch: ChannelRef, ch: ChannelRef,

View file

@ -2,10 +2,19 @@
* SPDX-FileCopyrightText: 2026 Sulkta * SPDX-FileCopyrightText: 2026 Sulkta
* SPDX-License-Identifier: GPL-3.0-or-later * SPDX-License-Identifier: GPL-3.0-or-later
* *
* Sulkta green palette for Straw. Replaces the M3 default lavender/red * Straw palette pulled directly from sulkta.com's stylesheet:
* tints with a clean forest green primary modern, clean, distinct from * #4ade80 primary green (Tailwind green-400, most-used on the site)
* NewPipe / Tubular's red. Same Tonal Palette structure Material 3 uses * #166534 deep green (green-800, headings + emphasis)
* internally so all the derived surfaces stay in harmony. * #22c55e mid green (green-500, links + buttons)
* #86efac light green container (green-300)
* #e8f5e8 pale green tint
* #d97706 amber accent (sulkta.com calls this out for chips)
* #374137 olive-gray secondary
* #0a0a0a near-black text on light
* #111411 near-black with green tint for dark surface
*
* Mapped into Material 3's primary / secondary / tertiary tonal roles
* so all the derived M3 surfaces (containers, outlines, etc.) follow.
*/ */
package com.sulkta.straw package com.sulkta.straw
@ -15,56 +24,61 @@ import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme import androidx.compose.material3.lightColorScheme
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
private val GreenPrimary = Color(0xFF386A1F) // Light theme — primary is sulkta.com's deep green (#166534), strong
private val GreenOnPrimary = Color(0xFFFFFFFF) // enough for white text and matches the site's heading emphasis.
private val GreenPrimaryContainer = Color(0xFFB6F397) private val LPrimary = Color(0xFF166534)
private val GreenOnPrimaryContainer = Color(0xFF0B2000) private val LOnPrimary = Color(0xFFFFFFFF)
private val GreenSecondary = Color(0xFF55624C) private val LPrimaryContainer = Color(0xFF86EFAC)
private val GreenOnSecondary = Color(0xFFFFFFFF) private val LOnPrimaryContainer = Color(0xFF0A0A0A)
private val GreenSecondaryContainer = Color(0xFFD8E7CB) private val LSecondary = Color(0xFF374137)
private val GreenOnSecondaryContainer = Color(0xFF131F0D) private val LOnSecondary = Color(0xFFFFFFFF)
private val GreenTertiary = Color(0xFF386666) private val LSecondaryContainer = Color(0xFFE8F5E8)
private val GreenOnTertiary = Color(0xFFFFFFFF) private val LOnSecondaryContainer = Color(0xFF0A0A0A)
private val LTertiary = Color(0xFFD97706)
private val LOnTertiary = Color(0xFFFFFFFF)
private val DarkGreenPrimary = Color(0xFF9BD67D) // Dark theme — primary is sulkta.com's bright lime (#4ade80) since dark
private val DarkGreenOnPrimary = Color(0xFF153800) // backgrounds need a brighter accent for readability. PrimaryContainer
private val DarkGreenPrimaryContainer = Color(0xFF205107) // is the deep green so emphasis stays consistent across themes.
private val DarkGreenOnPrimaryContainer = Color(0xFFB6F397) private val DPrimary = Color(0xFF4ADE80)
private val DarkGreenSecondary = Color(0xFFBDCBB0) private val DOnPrimary = Color(0xFF0A0A0A)
private val DarkGreenOnSecondary = Color(0xFF283420) private val DPrimaryContainer = Color(0xFF166534)
private val DarkGreenSecondaryContainer = Color(0xFF3E4A35) private val DOnPrimaryContainer = Color(0xFF86EFAC)
private val DarkGreenOnSecondaryContainer = Color(0xFFD8E7CB) private val DSecondary = Color(0xFF9AB89A)
private val DarkGreenTertiary = Color(0xFFA0CFD0) private val DOnSecondary = Color(0xFF111411)
private val DarkGreenOnTertiary = Color(0xFF003738) private val DSecondaryContainer = Color(0xFF374137)
private val DOnSecondaryContainer = Color(0xFFE8F5E8)
private val DTertiary = Color(0xFFD97706)
private val DOnTertiary = Color(0xFF0A0A0A)
fun strawLightColors(): ColorScheme = lightColorScheme( fun strawLightColors(): ColorScheme = lightColorScheme(
primary = GreenPrimary, primary = LPrimary,
onPrimary = GreenOnPrimary, onPrimary = LOnPrimary,
primaryContainer = GreenPrimaryContainer, primaryContainer = LPrimaryContainer,
onPrimaryContainer = GreenOnPrimaryContainer, onPrimaryContainer = LOnPrimaryContainer,
secondary = GreenSecondary, secondary = LSecondary,
onSecondary = GreenOnSecondary, onSecondary = LOnSecondary,
secondaryContainer = GreenSecondaryContainer, secondaryContainer = LSecondaryContainer,
onSecondaryContainer = GreenOnSecondaryContainer, onSecondaryContainer = LOnSecondaryContainer,
tertiary = GreenTertiary, tertiary = LTertiary,
onTertiary = GreenOnTertiary, onTertiary = LOnTertiary,
) )
fun strawDarkColors(): ColorScheme = darkColorScheme( fun strawDarkColors(): ColorScheme = darkColorScheme(
primary = DarkGreenPrimary, primary = DPrimary,
onPrimary = DarkGreenOnPrimary, onPrimary = DOnPrimary,
primaryContainer = DarkGreenPrimaryContainer, primaryContainer = DPrimaryContainer,
onPrimaryContainer = DarkGreenOnPrimaryContainer, onPrimaryContainer = DOnPrimaryContainer,
secondary = DarkGreenSecondary, secondary = DSecondary,
onSecondary = DarkGreenOnSecondary, onSecondary = DOnSecondary,
secondaryContainer = DarkGreenSecondaryContainer, secondaryContainer = DSecondaryContainer,
onSecondaryContainer = DarkGreenOnSecondaryContainer, onSecondaryContainer = DOnSecondaryContainer,
tertiary = DarkGreenTertiary, tertiary = DTertiary,
onTertiary = DarkGreenOnTertiary, onTertiary = DOnTertiary,
) )
// Semi-transparent overlays for chrome (overlay buttons, the SB badge, // Semi-transparent overlays for chrome (overlay buttons, the SB badge,
// the inline-player fullscreen pill) and for the dimmed area behind the // the inline-player fullscreen pill) and for the dimmed area behind the
// minibar thumbnail. Kept here so a theme tweak touches one place. // minibar thumbnail. Kept here so a theme tweak touches one place.
val OverlayChromeColor = androidx.compose.ui.graphics.Color(0xCC222222) val OverlayChromeColor = Color(0xCC222222)
val OverlayDimColor = androidx.compose.ui.graphics.Color(0xCC000000) val OverlayDimColor = Color(0xCC000000)

View file

@ -86,14 +86,19 @@ fun DownloadsScreen() {
val context = LocalContext.current val context = LocalContext.current
var rows by remember { mutableStateOf<List<DownloadRow>>(emptyList()) } var rows by remember { mutableStateOf<List<DownloadRow>>(emptyList()) }
// Poll DownloadManager every second while the screen is visible. // DownloadManager doesn't broadcast progress, so we poll while the
// DownloadManager doesn't broadcast progress, so polling is the // screen is visible. Fast cadence (1s) when something is actively
// standard pattern. Cheap query — single cursor across the app's own // running, slow cadence (5s) when everything is settled — no
// download queue. // animations to update.
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
while (true) { while (true) {
rows = queryDownloads(context) val fresh = queryDownloads(context)
delay(1000) rows = fresh
val active = fresh.any {
it.status == DownloadManager.STATUS_RUNNING ||
it.status == DownloadManager.STATUS_PENDING
}
delay(if (active) 1000 else 5000)
} }
} }

View file

@ -27,7 +27,12 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Pause
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
@ -113,10 +118,13 @@ fun MinibarOverlay(
) )
} }
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
MinibarIconButton(label = if (isPlaying) "" else "") { MinibarIconButton(
icon = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
desc = if (isPlaying) "Pause" else "Play",
) {
if (controller.isPlaying) controller.pause() else controller.play() if (controller.isPlaying) controller.pause() else controller.play()
} }
MinibarIconButton(label = "×") { MinibarIconButton(icon = Icons.Filled.Close, desc = "Stop") {
controller.stop() controller.stop()
controller.clearMediaItems() controller.clearMediaItems()
NowPlaying.clear() NowPlaying.clear()
@ -128,7 +136,11 @@ fun MinibarOverlay(
} }
@Composable @Composable
private fun MinibarIconButton(label: String, onClick: () -> Unit) { private fun MinibarIconButton(
icon: androidx.compose.ui.graphics.vector.ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(44.dp)
@ -136,6 +148,6 @@ private fun MinibarIconButton(label: String, onClick: () -> Unit) {
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text(label, style = MaterialTheme.typography.titleMedium) Icon(imageVector = icon, contentDescription = desc, modifier = Modifier.size(22.dp))
} }
} }

View file

@ -79,14 +79,11 @@ class PlaybackService : MediaSessionService() {
) )
.build() .build()
// Stop ourselves once playback genuinely ends so the foreground slot // Service shutdown is driven by onTaskRemoved (user swiped app away)
// is released. STATE_IDLE + STATE_ENDED both qualify; STATE_BUFFERING // + the user pressing × on the minibar (which clears the queue).
// / STATE_READY mean we're still doing work even if paused. // Don't auto-stop on STATE_ENDED — a future autoplay/queue feature
player.addListener(object : Player.Listener { // expects the service to stay alive between items in the queue.
override fun onPlaybackStateChanged(state: Int) { // Foreground notification fades on its own when nothing is playing.
if (state == Player.STATE_ENDED) stopSelfWhenIdle()
}
})
val sessionActivityIntent = PendingIntent.getActivity( val sessionActivityIntent = PendingIntent.getActivity(
this, this,
@ -132,13 +129,6 @@ class PlaybackService : MediaSessionService() {
super.onDestroy() super.onDestroy()
} }
private fun stopSelfWhenIdle() {
val p = mediaSession?.player ?: return
if (p.mediaItemCount == 0 || p.playbackState == Player.STATE_IDLE) {
stopSelf()
}
}
companion object { companion object {
const val MEDIA_SESSION_ID = "straw" const val MEDIA_SESSION_ID = "straw"

View file

@ -33,11 +33,20 @@ import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Headphones
import androidx.compose.material.icons.filled.PictureInPictureAlt
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.Speed
import androidx.compose.material.icons.filled.Videocam
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
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.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -209,10 +218,13 @@ fun PlayerScreen(
modifier = Modifier.align(Alignment.TopEnd).padding(12.dp), modifier = Modifier.align(Alignment.TopEnd).padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
OverlayButton(label = if (playbackSpeed == 1f) "1×" else "${playbackSpeed}×") { OverlayIconButton(icon = Icons.Filled.Speed, desc = "Playback speed") {
showSpeedDialog = true showSpeedDialog = true
} }
OverlayButton(label = if (audioOnly) "📻" else "📺") { OverlayIconButton(
icon = if (audioOnly) Icons.Filled.Headphones else Icons.Filled.Videocam,
desc = if (audioOnly) "Audio-only on" else "Video on",
) {
audioOnly = !audioOnly audioOnly = !audioOnly
controller.trackSelectionParameters = TrackSelectionParameters.Builder(context) controller.trackSelectionParameters = TrackSelectionParameters.Builder(context)
.setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly) .setTrackTypeDisabled(C.TRACK_TYPE_VIDEO, audioOnly)
@ -223,7 +235,7 @@ fun PlayerScreen(
Toast.LENGTH_SHORT, Toast.LENGTH_SHORT,
).show() ).show()
} }
OverlayButton(label = "") { OverlayIconButton(icon = Icons.Filled.Share, desc = "Share") {
val send = Intent(Intent.ACTION_SEND).apply { val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain" type = "text/plain"
putExtra(Intent.EXTRA_TEXT, streamUrl) putExtra(Intent.EXTRA_TEXT, streamUrl)
@ -231,14 +243,14 @@ fun PlayerScreen(
} }
context.startActivity(Intent.createChooser(send, "Share video")) context.startActivity(Intent.createChooser(send, "Share video"))
} }
OverlayButton(label = "") { OverlayIconButton(icon = Icons.Filled.PictureInPictureAlt, desc = "Picture in picture") {
if (activity == null) { if (activity == null) {
Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show() Toast.makeText(context, "PiP: no activity", Toast.LENGTH_SHORT).show()
return@OverlayButton return@OverlayIconButton
} }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show() Toast.makeText(context, "PiP needs Android 8+", Toast.LENGTH_SHORT).show()
return@OverlayButton return@OverlayIconButton
} }
val params = PictureInPictureParams.Builder() val params = PictureInPictureParams.Builder()
.setAspectRatio(Rational(16, 9)) .setAspectRatio(Rational(16, 9))
@ -251,7 +263,9 @@ fun PlayerScreen(
Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show() Toast.makeText(context, "PiP failed: ${t.message}", Toast.LENGTH_LONG).show()
} }
} }
OverlayButton(label = "") { onMinimize() } OverlayIconButton(icon = Icons.Filled.KeyboardArrowDown, desc = "Minimize") {
onMinimize()
}
} }
if (showSpeedDialog) { if (showSpeedDialog) {
@ -271,7 +285,11 @@ fun PlayerScreen(
} }
@Composable @Composable
private fun OverlayButton(label: String, onClick: () -> Unit) { private fun OverlayIconButton(
icon: ImageVector,
desc: String,
onClick: () -> Unit,
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
@ -280,7 +298,12 @@ private fun OverlayButton(label: String, onClick: () -> Unit) {
.clickable(onClick = onClick), .clickable(onClick = onClick),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
Text(label, color = Color.White, style = MaterialTheme.typography.titleSmall) Icon(
imageVector = icon,
contentDescription = desc,
tint = Color.White,
modifier = Modifier.size(20.dp),
)
} }
} }