10 Commits
Author SHA1 Message Date
austinried 7bbc29a1d6 feat: show the build's commit hash under About
A short, faded commit hash next to the version in Settings > About, so a bug
report can name the exact build. Baked into BuildConfig at build time from
`git rev-parse HEAD` (fixed 7 chars, so the same commit always yields the
same value and the APK stays reproducible); empty, and hidden, on a build
with no git metadata such as a source tarball.
2026-10-09 18:06:38 +00:00
austinried 0a3e839da0 feat: clean up the 1.x app's leftover data on upgrade
The React Native 1.x app shipped as com.subtracks and left an AsyncStorage
database (RKStorage), an older `servers` dump and a downloaded/cached
artwork+audio tree in app-specific storage. v3 reads none of those paths, so
an update would otherwise leave the data behind, reachable only by wiping the
whole app.

Remove them once at startup, gated on one of those 1.x paths existing, so a
fresh install does nothing and an upgrade is cleaned exactly once. The cached
trees can be large, so the delete runs off the main thread. Kept in a single
file so it can be deleted once no installs can come from 1.x.
2026-10-09 17:05:19 +00:00
austinried e0a09b724b chore: drop the F-Droid sync and submit workflows
The recipe is maintained by hand now, so the two Forgejo workflows that
pulled fdroiddata into tools/fdroid, tracked a working-copy hash and
opened the GitLab merge request are more machinery than the channel
needs. Their support files and the GL_TOKEN note go with them; the
F-Droid references in the README, signing docs and architecture notes
describe the channel itself and stay.
2026-10-09 16:47:34 +00:00
austinried e806eaef89 playback: cache streamed audio to stop redundant refetches
A streamed MP3 with no Xing/Info/VBRI header gets an estimated duration
from Mp3Extractor. At end of stream the extractor rewrites it, which
refreshes the timeline; MediaPeriodQueue.updateQueuedPeriods then sees
the playing period's duration change, drops every period after it and
re-enqueues the next one, so the next track is opened from byte 0 again.
After a seek near the end that repeats a few times.

Route streamed bytes through a bounded SimpleCache (DefaultDataSource ->
CacheDataSource -> KnownLengthDataSource -> OkHttp). A re-opened next
period is served from the bytes the prefetch already committed, so the
redundant network fetches stop without changing playback length or
gapless padding. DefaultDataSource still handles file:/content:, so
downloads bypass the cache.

The cache is a process-scoped 64 MiB LRU in cacheDir/stream-cache; a
construction failure falls back to no cache so playback still starts.
2026-10-09 13:36:55 +00:00
austinried 0cef3657bc build: make the release APK reproducible
AGP's dependency-metadata signing block (0x504b4453) is encrypted per
build, so two otherwise byte-identical release APKs differed in that
block. Disable it in the APK with dependenciesInfo.includeInApk = false;
the bundle keeps its copy for Play.
2026-10-09 13:34:38 +00:00
austinried 20c6ba526f scrobble: only dedupe when the track was already announced
The now-playing suppression keyed off the item id alone, so a track loaded while paused (which sets the tracked id without announcing) never sent its first now-playing when playback then started from the beginning. Gate the suppression on nowPlayingSent as well, and cover that case plus the transition at the combine boundary.
2026-10-09 12:31:48 +00:00
austinried cfb663c250 scrobble: send now playing once per track change
A track transition resets the player position, and the scrobble policy's restart heuristic (same song, position drops to ~0) then re-announced the track we just left and, when the new track's position jittered back to zero, announced it twice. Suppress the now-playing when the restart is the same song; the submission logic still resets so a genuine replay is scrobbled again.
2026-10-09 12:31:48 +00:00
austinried ac1e62fc02 library: drop shuffle fab from album and playlist detail 2026-10-09 09:15:36 +00:00
austinried adabe21d63 library: cover starred/downloaded shuffle in tests, dedupe list predicate 2026-10-09 09:15:36 +00:00
austinried 0bdac8fa22 library: tap-to-top and shuffle fab for long song lists 2026-10-09 09:15:36 +00:00
27 changed files with 582 additions and 385 deletions
+22
View File
@@ -14,6 +14,19 @@ ktlint {
version.set(libs.versions.ktlint.get()) version.set(libs.versions.ktlint.get())
} }
val commitHash: String =
runCatching {
providers
.exec {
commandLine("git", "rev-parse", "HEAD")
workingDir = rootProject.layout.projectDirectory.asFile
isIgnoreExitValue = true
}.standardOutput.asText
.get()
.trim()
.take(7)
}.getOrDefault("")
android { android {
namespace = "com.subtracks" namespace = "com.subtracks"
compileSdk = 37 compileSdk = 37
@@ -25,6 +38,7 @@ android {
targetSdk = 37 targetSdk = 37
versionCode = 13 versionCode = 13
versionName = "3.0.0" versionName = "3.0.0"
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
} }
buildFeatures { buildFeatures {
@@ -36,6 +50,12 @@ android {
generateLocaleConfig = true generateLocaleConfig = true
} }
dependenciesInfo {
// The APK signing-block copy is encrypted per build, breaking APK reproducibility, and
// F-Droid rejects it; the bundle keeps its copy for Play.
includeInApk = false
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
@@ -238,7 +258,9 @@ dependencies {
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.exoplayer)
implementation(libs.androidx.media3.session) implementation(libs.androidx.media3.session)
implementation(libs.androidx.media3.datasource)
implementation(libs.androidx.media3.datasource.okhttp) implementation(libs.androidx.media3.datasource.okhttp)
implementation(libs.androidx.media3.database)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
@@ -5,6 +5,7 @@ import coil3.ImageLoader
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import coil3.annotation.DelicateCoilApi import coil3.annotation.DelicateCoilApi
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.LegacyDataCleanup
import com.subtracks.data.prefs.UserPreferences import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.ArtworkSeedStore import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule import com.subtracks.di.appModule
@@ -25,6 +26,7 @@ class SubtracksApp : Application() {
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Log.init(this) Log.init(this)
LegacyDataCleanup.run(this)
stopKoin() stopKoin()
SingletonImageLoader.reset() SingletonImageLoader.reset()
val koin = val koin =
@@ -0,0 +1,41 @@
package com.subtracks.data
import android.content.Context
import com.subtracks.log.Log
import java.io.File
// One-shot cleanup of the 1.x React Native app, which shipped as com.subtracks before this rewrite.
// It left an AsyncStorage database (RKStorage), an older `servers` dump and a downloaded/cached
// artwork+audio tree (under the app's external files dir) that v3 never reads. Only a real 1.x upgrade
// has those paths, so a fresh install does nothing and an upgrade is cleaned exactly once.
//
// Delete this file and its call in SubtracksApp once installs can no longer be coming from 1.x.
internal object LegacyDataCleanup {
fun run(context: Context) {
// The cached trees can be large, so keep the walk off the main thread.
Thread { run(context.dataDir, context.getExternalFilesDir(null)) }.start()
}
internal fun run(
dataDir: File,
externalFilesDir: File?,
) {
val database = File(dataDir, "databases/RKStorage")
val servers = File(dataDir, "files/servers")
val cache = externalFilesDir?.let { File(it, "s") }
if (!database.exists() && !servers.exists() && cache?.exists() != true) return
deleteDatabase(database)
deleteTree(servers)
cache?.let(::deleteTree)
}
private fun deleteDatabase(database: File) {
listOf("", "-wal", "-shm", "-journal").forEach { deleteTree(File(database.path + it)) }
}
private fun deleteTree(target: File) {
if (!target.exists()) return
val removed = target.deleteRecursively()
Log.i("legacy", "removed ${target.path} ($removed)")
}
}
@@ -261,6 +261,15 @@ class PlaybackController(
artistId: String, artistId: String,
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId)) ) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
fun shuffleGenre(
sourceId: Long,
genre: String,
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
private fun playContext( private fun playContext(
entry: QueueEntry, entry: QueueEntry,
start: Long, start: Long,
@@ -12,8 +12,13 @@ import androidx.media3.common.MediaItem
import androidx.media3.common.Player import androidx.media3.common.Player
import androidx.media3.common.util.ExperimentalApi import androidx.media3.common.util.ExperimentalApi
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultDataSource import androidx.media3.datasource.DefaultDataSource
import androidx.media3.datasource.cache.Cache
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
@@ -41,6 +46,7 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.core.context.GlobalContext import org.koin.core.context.GlobalContext
import java.io.File
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class PlaybackService : MediaSessionService() { class PlaybackService : MediaSessionService() {
@@ -91,6 +97,7 @@ class PlaybackService : MediaSessionService() {
val sources = GlobalContext.get().get<SourceRepository>() val sources = GlobalContext.get().get<SourceRepository>()
sourceRepository = sources sourceRepository = sources
library = GlobalContext.get().get<LibraryRepository>() library = GlobalContext.get().get<LibraryRepository>()
val cache = StreamCache.open(this)
val exoPlayer = val exoPlayer =
ExoPlayer ExoPlayer
.Builder(this) .Builder(this)
@@ -106,7 +113,11 @@ class PlaybackService : MediaSessionService() {
DefaultMediaSourceFactory(this) DefaultMediaSourceFactory(this)
.setEnableClippingInMediaPeriod(true) .setEnableClippingInMediaPeriod(true)
.setDataSourceFactory( .setDataSourceFactory(
mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value)), mediaDataSourceFactory(
this,
cache,
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
),
), ),
).build() ).build()
exoPlayer.addListener( exoPlayer.addListener(
@@ -216,8 +227,46 @@ class PlaybackService : MediaSessionService() {
} }
} }
// Process-scoped so it is never released while a loader thread is still committing to it, and so
// a recreated service does not try to lock the directory a second time.
@OptIn(UnstableApi::class)
private object StreamCache {
@Volatile
private var instance: SimpleCache? = null
fun open(context: Context): SimpleCache? =
instance ?: synchronized(this) {
instance ?: runCatching {
SimpleCache(
File(context.cacheDir, STREAM_CACHE_DIR),
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
StandaloneDatabaseProvider(context),
)
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
.getOrNull()
?.also { instance = it }
}
private const val STREAM_CACHE_DIR = "stream-cache"
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
}
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
internal fun mediaDataSourceFactory( internal fun mediaDataSourceFactory(
context: Context, context: Context,
cache: Cache?,
upstream: DataSource.Factory, upstream: DataSource.Factory,
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream)) ): DataSource.Factory {
val known = KnownLengthDataSourceFactory(upstream)
val source: DataSource.Factory =
if (cache == null) {
known
} else {
CacheDataSource
.Factory()
.setCache(cache)
.setUpstreamDataSourceFactory(known)
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
}
return DefaultDataSource.Factory(context, source)
}
@@ -59,6 +59,7 @@ class ScrobblePolicy(
item?.id != songId || item?.id != songId ||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS) (item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
if (restarted) { if (restarted) {
val alreadyAnnounced = nowPlayingSent && item?.id == songId
songId = item?.id songId = item?.id
lastItemId = null lastItemId = null
playedMs = 0 playedMs = 0
@@ -68,7 +69,7 @@ class ScrobblePolicy(
if (item == null || !isPlaying) return null if (item == null || !isPlaying) return null
nowPlayingSent = true nowPlayingSent = true
startedAt = now() startedAt = now()
return Scrobble.NowPlaying(item.id) return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
} }
lastPositionMs = positionMs lastPositionMs = positionMs
lastItemId = item?.id lastItemId = item?.id
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -77,6 +78,7 @@ import com.subtracks.ui.theme.ArtworkTheme
import com.subtracks.ui.theme.HeroGradient import com.subtracks.ui.theme.HeroGradient
import com.subtracks.ui.theme.baseArtworkColors import com.subtracks.ui.theme.baseArtworkColors
import com.subtracks.ui.theme.heroBarColor import com.subtracks.ui.theme.heroBarColor
import kotlinx.coroutines.launch
private const val FADE_DISTANCE_DP = 64 private const val FADE_DISTANCE_DP = 64
@@ -102,6 +104,7 @@ fun HeroDetailScaffold(
ArtworkTheme(effectiveArtwork) { ArtworkTheme(effectiveArtwork) {
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) { BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
val density = LocalDensity.current val density = LocalDensity.current
val screenHeightPx = with(density) { maxHeight.toPx() } val screenHeightPx = with(density) { maxHeight.toPx() }
@@ -195,7 +198,8 @@ fun HeroDetailScaffold(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, indication = null,
) {}, onClickLabel = stringResource(R.string.scroll_to_top),
) { scope.launch { listState.animateScrollToItem(0) } },
) )
} }
} }
@@ -0,0 +1,33 @@
package com.subtracks.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Shuffle
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import com.subtracks.R
@Composable
fun ShuffleFab(
onClick: () -> Unit,
modifier: Modifier = Modifier,
visible: Boolean = true,
) {
AnimatedVisibility(
visible = visible,
enter = fadeIn() + scaleIn(),
exit = fadeOut() + scaleOut(),
modifier = modifier,
) {
FloatingActionButton(onClick = onClick) {
Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play))
}
}
}
@@ -88,3 +88,34 @@ fun rememberViewportFill(state: LazyGridState): Dp {
} }
return with(density) { fillPx.toDp() } return with(density) { fillPx.toDp() }
} }
/**
* Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes
* non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap)
* should move the list, so a stale scroll position survives tab switches.
*/
@Composable
fun ScrollToTopOnRequest(
request: Any?,
state: LazyListState,
) {
var previous by remember { mutableStateOf(request) }
LaunchedEffect(request) {
val stale = previous
previous = request
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
}
}
@Composable
fun ScrollToTopOnRequest(
request: Any?,
state: LazyGridState,
) {
var previous by remember { mutableStateOf(request) }
LaunchedEffect(request) {
val stale = previous
previous = request
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
}
}
@@ -14,7 +14,9 @@ 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.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
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
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@@ -59,8 +62,10 @@ import com.subtracks.ui.components.CoverArt
import com.subtracks.ui.components.ItemActions import com.subtracks.ui.components.ItemActions
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ShuffleFab
import com.subtracks.ui.library.PlaylistsContent import com.subtracks.ui.library.PlaylistsContent
import com.subtracks.ui.library.SongRow import com.subtracks.ui.library.SongRow
import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
@@ -110,6 +115,7 @@ fun HomeListRoute(
onPlaylistClick = onPlaylistClick, onPlaylistClick = onPlaylistClick,
onSongClick = viewModel::play, onSongClick = viewModel::play,
onLongClick = { contextMenuHost?.show(it, actions) }, onLongClick = { contextMenuHost?.show(it, actions) },
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
onGenreClick = onGenreClick, onGenreClick = onGenreClick,
onDecadeClick = onDecadeClick, onDecadeClick = onDecadeClick,
) )
@@ -134,11 +140,14 @@ fun HomeListScreen(
onPlaylistClick: (Playlist) -> Unit = {}, onPlaylistClick: (Playlist) -> Unit = {},
onSongClick: (Song) -> Unit = {}, onSongClick: (Song) -> Unit = {},
onLongClick: (MenuTarget) -> Unit = {}, onLongClick: (MenuTarget) -> Unit = {},
onShuffle: (() -> Unit)? = null,
onGenreClick: (String) -> Unit = {}, onGenreClick: (String) -> Unit = {},
onDecadeClick: (Long) -> Unit = {}, onDecadeClick: (Long) -> Unit = {},
) { ) {
val now = remember { System.currentTimeMillis() } val now = remember { System.currentTimeMillis() }
val resources = LocalResources.current val resources = LocalResources.current
val scope = rememberCoroutineScope()
val songListState = rememberLazyListState()
Scaffold( Scaffold(
modifier = modifier, modifier = modifier,
topBar = { topBar = {
@@ -149,8 +158,21 @@ fun HomeListScreen(
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back)) Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
} }
}, },
modifier =
if (onShuffle != null) {
Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) {
scope.launch { songListState.animateScrollToItem(0) }
}
} else {
Modifier
},
) )
}, },
floatingActionButton = {
if (onShuffle != null && songs.itemCount > 0) {
ShuffleFab(onClick = onShuffle)
}
},
) { padding -> ) { padding ->
val downloaded = request.downloaded val downloaded = request.downloaded
if (downloaded != null) { if (downloaded != null) {
@@ -184,6 +206,7 @@ fun HomeListScreen(
playingSongId = playingSongId, playingSongId = playingSongId,
onSongClick = onSongClick, onSongClick = onSongClick,
onLongClick = onLongClick, onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} }
@@ -237,6 +260,7 @@ fun HomeListScreen(
playingSongId = playingSongId, playingSongId = playingSongId,
onSongClick = onSongClick, onSongClick = onSongClick,
onLongClick = onLongClick, onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} }
@@ -254,6 +278,7 @@ fun HomeListScreen(
playingSongId = playingSongId, playingSongId = playingSongId,
onSongClick = onSongClick, onSongClick = onSongClick,
onLongClick = onLongClick, onLongClick = onLongClick,
listState = songListState,
modifier = Modifier.padding(padding), modifier = Modifier.padding(padding),
) )
} else { } else {
@@ -297,6 +322,7 @@ private fun SongsList(
playingSongId: String?, playingSongId: String?,
onSongClick: (Song) -> Unit, onSongClick: (Song) -> Unit,
onLongClick: (MenuTarget) -> Unit, onLongClick: (MenuTarget) -> Unit,
listState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) { if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
@@ -304,8 +330,9 @@ private fun SongsList(
return return
} }
LazyColumn( LazyColumn(
state = listState,
modifier = modifier.fillMaxSize(), modifier = modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 24.dp), contentPadding = PaddingValues(bottom = 88.dp),
) { ) {
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index -> items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
val item = songs[index] val item = songs[index]
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
data class HomeListRequest( data class HomeListRequest(
val title: String, val title: String,
@@ -51,6 +53,11 @@ class HomeListViewModel(
) : ViewModel() { ) : ViewModel() {
private val sourceId = libraryRepository.activeSourceId.filterNotNull() private val sourceId = libraryRepository.activeSourceId.filterNotNull()
val canShuffle: Boolean =
request.genre != null ||
request.section == HomeSection.RecentlyStarredSongs ||
request.downloaded == OfflineListKind.Songs
val albums: Flow<PagingData<Album>> = val albums: Flow<PagingData<Album>> =
sourceId sourceId
.flatMapLatest { id -> .flatMapLatest { id ->
@@ -173,4 +180,18 @@ class HomeListViewModel(
} }
} }
} }
fun shuffle() {
val genre = request.genre
val downloaded = request.downloaded
val starred = request.section == HomeSection.RecentlyStarredSongs
viewModelScope.launch {
val id = sourceId.first()
when {
genre != null -> playbackController.shuffleGenre(id, genre)
downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id)
starred -> playbackController.shuffleStarred(id)
}
}
}
} }
@@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -56,6 +57,7 @@ fun AlbumsContent(
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -78,6 +80,7 @@ fun AlbumsContent(
else -> { else -> {
val gridState = rememberLazyGridState() val gridState = rememberLazyGridState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) } ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, gridState)
val fill = rememberViewportFill(gridState) val fill = rememberViewportFill(gridState)
LazyVerticalGrid( LazyVerticalGrid(
state = gridState, state = gridState,
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
import com.subtracks.ui.theme.PrefetchArtworkSeeds import com.subtracks.ui.theme.PrefetchArtworkSeeds
@@ -53,6 +54,7 @@ fun ArtistsContent(
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -70,6 +72,7 @@ fun ArtistsContent(
else -> { else -> {
val listState = rememberLazyListState() val listState = rememberLazyListState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) } ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, listState)
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -418,6 +418,7 @@ fun LibraryScreen(
) )
var showOptions by rememberSaveable { mutableStateOf(false) } var showOptions by rememberSaveable { mutableStateOf(false) }
var searchActive by rememberSaveable { mutableStateOf(false) } var searchActive by rememberSaveable { mutableStateOf(false) }
var topRequest by remember { mutableIntStateOf(0) }
val searchFocus = remember { FocusRequester() } val searchFocus = remember { FocusRequester() }
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard. // Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
@@ -470,6 +471,7 @@ fun LibraryScreen(
) { page -> ) { page ->
val pageTab = LibraryTab.entries[page] val pageTab = LibraryTab.entries[page]
val resetKey = resetKeys[pageTab] val resetKey = resetKeys[pageTab]
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
val pullToRefreshState = rememberPullToRefreshState() val pullToRefreshState = rememberPullToRefreshState()
PullToRefreshBox( PullToRefreshBox(
isRefreshing = syncing, isRefreshing = syncing,
@@ -507,6 +509,7 @@ fun LibraryScreen(
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = albumDownloads, downloadStatuses = albumDownloads,
scrollToTopKey = scrollToTopKey,
) )
} }
@@ -526,6 +529,7 @@ fun LibraryScreen(
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = artistDownloads, downloadStatuses = artistDownloads,
scrollToTopKey = scrollToTopKey,
) )
} }
@@ -545,6 +549,7 @@ fun LibraryScreen(
topInset = listTopInset, topInset = listTopInset,
onSync = onSync, onSync = onSync,
downloadStatuses = playlistDownloads, downloadStatuses = playlistDownloads,
scrollToTopKey = scrollToTopKey,
) )
} }
} }
@@ -570,7 +575,9 @@ fun LibraryScreen(
) { ) {
LibraryTabs( LibraryTabs(
pagerState = pagerState, pagerState = pagerState,
onTabSelected = onTabSelected, onTabSelected = { tab ->
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
},
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
offline = offline, offline = offline,
onExitOffline = onExitOffline, onExitOffline = onExitOffline,
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
import com.subtracks.ui.components.LoadingState import com.subtracks.ui.components.LoadingState
import com.subtracks.ui.components.MenuTarget import com.subtracks.ui.components.MenuTarget
import com.subtracks.ui.components.ResetScrollOnChange import com.subtracks.ui.components.ResetScrollOnChange
import com.subtracks.ui.components.ScrollToTopOnRequest
import com.subtracks.ui.components.rememberViewportFill import com.subtracks.ui.components.rememberViewportFill
@Composable @Composable
@@ -52,6 +53,7 @@ fun PlaylistsContent(
topInset: Dp = 0.dp, topInset: Dp = 0.dp,
onSync: () -> Unit = {}, onSync: () -> Unit = {},
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(), downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
scrollToTopKey: Any? = null,
) { ) {
when { when {
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> { items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
@@ -74,6 +76,7 @@ fun PlaylistsContent(
else -> { else -> {
val listState = rememberLazyListState() val listState = rememberLazyListState()
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) } ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
ScrollToTopOnRequest(scrollToTopKey, listState)
val fill = rememberViewportFill(listState) val fill = rememberViewportFill(listState)
LazyColumn( LazyColumn(
state = listState, state = listState,
@@ -388,7 +388,16 @@ fun SettingsScreen(
ListItem( ListItem(
headlineContent = { Text(stringResource(R.string.app_name)) }, headlineContent = { Text(stringResource(R.string.app_name)) },
supportingContent = { supportingContent = {
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME)) Row(verticalAlignment = Alignment.CenterVertically) {
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
if (BuildConfig.COMMIT_HASH.isNotEmpty()) {
Spacer(Modifier.width(8.dp))
Text(
BuildConfig.COMMIT_HASH,
color = MaterialTheme.colorScheme.outline,
)
}
}
}, },
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) }, leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
colors = ListItemDefaults.colors(containerColor = Color.Transparent), colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+1
View File
@@ -20,6 +20,7 @@
<string name="controls_more">More</string> <string name="controls_more">More</string>
<string name="navigation_back">Back</string> <string name="navigation_back">Back</string>
<string name="scroll_to_top">Scroll to top</string>
<string name="navigation_tabs_home">Home</string> <string name="navigation_tabs_home">Home</string>
<string name="navigation_tabs_library">Library</string> <string name="navigation_tabs_library">Library</string>
<string name="navigation_tabs_settings">Settings</string> <string name="navigation_tabs_settings">Settings</string>
@@ -0,0 +1,67 @@
package com.subtracks.data
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
class LegacyDataCleanupTest {
@get:Rule
val tmp = TemporaryFolder()
@Test
fun removesThe1xLeftoversAndLeavesTheNativeData() {
val dataDir = tmp.newFolder("data")
val external = tmp.newFolder("external")
val legacyDatabase = file(dataDir, "databases/RKStorage")
val legacyDatabaseWal = file(dataDir, "databases/RKStorage-wal")
val legacyServers = file(dataDir, "files/servers/0.json")
val legacyCache = file(external, "s/0/coverArt/cover")
val nativeDatabase = file(dataDir, "databases/subtracks.db")
val nativeDownload = file(external, "Music/downloads/song.mp3")
LegacyDataCleanup.run(dataDir, external)
assertFalse(legacyDatabase.exists())
assertFalse(legacyDatabaseWal.exists())
assertFalse(legacyServers.exists())
assertFalse(legacyCache.exists())
assertTrue(nativeDatabase.exists())
assertTrue(nativeDownload.exists())
}
@Test
fun leavesTheNativeDataAloneOnAFreshInstall() {
val dataDir = tmp.newFolder("data2")
val external = tmp.newFolder("external2")
val nativeDatabase = file(dataDir, "databases/subtracks.db")
val nativeDownload = file(external, "Music/downloads/song.mp3")
LegacyDataCleanup.run(dataDir, external)
assertTrue(nativeDatabase.exists())
assertTrue(nativeDownload.exists())
}
@Test
fun cleansUpEvenWhenExternalStorageIsUnavailable() {
val dataDir = tmp.newFolder("data3")
val legacyDatabase = file(dataDir, "databases/RKStorage")
LegacyDataCleanup.run(dataDir, null)
assertFalse(legacyDatabase.exists())
}
private fun file(
dir: File,
path: String,
): File =
File(dir, path).apply {
parentFile?.mkdirs()
createNewFile()
}
}
@@ -2,7 +2,12 @@ package com.subtracks.playback
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.media3.common.C
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DataSpec import androidx.media3.datasource.DataSpec
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.io.ByteArrayOutputStream
import java.io.File import java.io.File
import java.nio.charset.StandardCharsets
@RunWith(AndroidJUnit4::class) @RunWith(AndroidJUnit4::class)
class MediaDataSourceTest { class MediaDataSourceTest {
private val context = ApplicationProvider.getApplicationContext<Context>() private val context = ApplicationProvider.getApplicationContext<Context>()
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource() private fun dataSource(cache: SimpleCache? = null) =
mediaDataSourceFactory(context, cache, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
@Test @Test
fun aDownloadedFileIsReadThroughTheStreamingChain() { fun aDownloadedFileIsReadThroughTheStreamingChain() {
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
assertEquals(1_000_000L, length) assertEquals(1_000_000L, length)
} }
@Test
fun reopeningAStreamIsServedFromTheCache() {
val server = MockWebServer()
server.enqueue(MockResponse().setBody("0123456789"))
server.start()
val url = "http://${server.hostName}:${server.port}/rest/stream"
val cache =
SimpleCache(
File(context.cacheDir, "stream-cache-${System.nanoTime()}"),
NoOpCacheEvictor(),
StandaloneDatabaseProvider(context),
)
val first = readAll(dataSource(cache), url)
val second = readAll(dataSource(cache), url)
val requests = server.requestCount
server.shutdown()
cache.release()
assertEquals("0123456789", String(first, StandardCharsets.UTF_8))
assertEquals(first.toList(), second.toList())
assertEquals(1, requests)
}
private fun readAll(
dataSource: DataSource,
url: String,
): ByteArray {
dataSource.open(DataSpec(Uri.parse(url)))
val out = ByteArrayOutputStream()
val buffer = ByteArray(1024)
while (true) {
val read = dataSource.read(buffer, 0, buffer.size)
if (read == C.RESULT_END_OF_INPUT) break
out.write(buffer, 0, read)
}
dataSource.close()
return out.toByteArray()
}
} }
@@ -4,6 +4,7 @@ import android.content.Context
import android.content.res.Resources import android.content.res.Resources
import androidx.media3.common.PlaybackException import androidx.media3.common.PlaybackException
import androidx.room3.Room import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -2391,6 +2392,61 @@ class PlaybackControllerTest {
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet()) assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
} }
@Test
fun shuffleGenrePlaysOnlyThatGenresSongs() {
seedGenre()
controller.shuffleGenre(1, "Rock")
await { controller.state.value.item != null && controller.state.value.shuffle }
assertTrue(controller.state.value.shuffle)
assertEquals(
QueueKind.Genre,
controller.state.value.context
?.kind,
)
assertEquals(
"Rock",
controller.state.value.context
?.refId,
)
assertEquals(setOf("r1", "r2"), handle.items.map { it.id }.toSet())
}
@Test
fun shuffleStarredPlaysOnlyStarredSongs() {
seedStarred()
controller.shuffleStarred(1)
await { controller.state.value.item != null && controller.state.value.shuffle }
assertTrue(controller.state.value.shuffle)
assertEquals(
QueueKind.Starred,
controller.state.value.context
?.kind,
)
assertEquals(setOf("t1", "t2"), handle.items.map { it.id }.toSet())
}
@Test
fun shuffleDownloadedPlaysOnlyDownloadedSongs() {
seedAlbum(3, sourceId = 1)
markDownloaded(1, "s1")
markDownloaded(1, "s3")
controller.shuffleDownloaded(1)
await { controller.state.value.item != null && controller.state.value.shuffle }
assertTrue(controller.state.value.shuffle)
assertEquals(
QueueKind.Downloaded,
controller.state.value.context
?.kind,
)
assertEquals(setOf("s1", "s3"), handle.items.map { it.id }.toSet())
}
@Test @Test
fun artistQueueRowsFollowTheFlatShuffleIdOrder() { fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
seedArtist() seedArtist()
@@ -3100,6 +3156,39 @@ class PlaybackControllerTest {
} }
} }
private fun seedGenre() {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
db.libraryDao().upsertSongs(
listOf(
Song(1, "r1", "al1", "ar1", "Rock 1", "Album", "Artist", 100, 1, 1, null, null),
Song(1, "r2", "al1", "ar1", "Rock 2", "Album", "Artist", 100, 2, 1, null, null),
Song(1, "j1", "al1", "ar1", "Jazz 1", "Album", "Artist", 100, 3, 1, null, null),
),
)
db.useWriterConnection { connection ->
listOf("r1" to "Rock", "r2" to "Rock", "j1" to "Jazz").forEach { (songId, genre) ->
connection.usePrepared(
"INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')",
) { it.step() }
}
}
}
}
private fun seedStarred() {
runBlocking {
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
db.libraryDao().upsertSongs(
listOf(
Song(1, "t1", "al1", "ar1", "Star 1", "Album", "Artist", 100, 1, 1, 1_700_000_000L, null),
Song(1, "t2", "al1", "ar1", "Star 2", "Album", "Artist", 100, 2, 1, 1_700_000_000L, null),
Song(1, "n1", "al1", "ar1", "Plain 1", "Album", "Artist", 100, 3, 1, null, null),
),
)
}
}
private fun seedAlbumTracks( private fun seedAlbumTracks(
albumId: String, albumId: String,
count: Int, count: Int,
@@ -130,15 +130,54 @@ class ScrobblePolicyTest {
} }
@Test @Test
fun restartingTheSameTrackScrobblesAgain() { fun restartingTheSameTrackResubmitsWithoutReannouncing() {
val item = track(durationMs = 200_000) val item = track(durationMs = 200_000)
play(item, toPositionMs = 100_000) play(item, toPositionMs = 100_000)
clock = 120_000L clock = 120_000L
val repeat = play(item, toPositionMs = 100_000) val repeat = play(item, toPositionMs = 100_000)
assertEquals(Scrobble.NowPlaying("s1"), repeat.first()) assertEquals(emptyList<Scrobble>(), repeat.filterIsInstance<Scrobble.NowPlaying>())
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last()) assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
} }
@Test
fun doesNotReannounceTheTrackItJustLeftWhenItsPositionResets() {
val events = mutableListOf<Scrobble>()
events += play(track(id = "s1"), toPositionMs = 100_000)
events +=
listOfNotNull(
policy.advance(track(id = "s1"), 200_000, isPlaying = true, positionMs = 0),
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
)
assertEquals(
listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")),
events,
)
}
@Test
fun doesNotReannounceWhenTheNewTracksPositionJittersBackToZero() {
val events = mutableListOf<Scrobble>()
events += play(track(id = "s1"), toPositionMs = 100_000)
events +=
listOfNotNull(
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 500),
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
)
assertEquals(
listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")),
events,
)
}
@Test
fun announcesWhenATrackLoadedWhilePausedStartsFromTheBeginning() {
val item = track(id = "s1")
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 0))
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 2_500))
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
}
@Test @Test
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() { fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
val first = track(id = "s1") val first = track(id = "s1")
@@ -190,4 +190,34 @@ class ScrobblerTest {
enabled.value = true enabled.value = true
assertEquals(listOf(Call.NowPlaying("s1")), sink.calls) assertEquals(listOf(Call.NowPlaying("s1")), sink.calls)
} }
@Test
fun aTransitionSendsNowPlayingOnceForTheNewTrack() =
runTest {
val sink = RecordingSink()
val scrobbler = Scrobbler(sink = sink, enabled = flowOf(true), scope = testScope(), policy = ScrobblePolicy(now = { 1_000L }))
val state = MutableStateFlow(PlaybackState())
val position = MutableStateFlow(0L)
scrobbler.attach(state, position)
val first = QueueItem("s1", "One", "Artist", "Album", null, durationMs = 30_000)
val second = QueueItem("s2", "Two", "Artist", "Album", null, durationMs = 30_000)
state.value = PlaybackState(item = first, isPlaying = true)
var tick = 500L
while (tick <= 15_000L) {
position.value = tick
tick += 500
}
position.value = 0L
state.value = PlaybackState(item = second, isPlaying = true)
assertEquals(
listOf(
Call.NowPlaying("s1"),
Call.Scrobble("s1", 1_000L),
Call.NowPlaying("s2"),
),
sink.calls,
)
}
} }
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
@@ -55,4 +56,33 @@ class ViewportFillTest {
assertEquals(initial, fillHeight) assertEquals(initial, fillHeight)
} }
@Test
fun scrollToTopOnRequestMovesOnlyOnANewRequest() {
lateinit var state: LazyListState
val request = mutableStateOf<Any?>(null)
composeRule.setContent {
SubtracksTheme {
state = rememberLazyListState()
ScrollToTopOnRequest(request.value, state)
LazyColumn(state = state, modifier = Modifier.fillMaxSize()) {
items(50) { Text("item $it", modifier = Modifier.height(56.dp)) }
}
}
}
composeRule.waitForIdle()
composeRule.runOnIdle { runBlocking { state.scrollToItem(10) } }
composeRule.waitForIdle()
assertEquals(10, state.firstVisibleItemIndex)
composeRule.runOnIdle { request.value = 0 }
composeRule.waitForIdle()
assertEquals("selecting a page must keep its scroll position", 10, state.firstVisibleItemIndex)
composeRule.runOnIdle { request.value = 1 }
composeRule.mainClock.advanceTimeBy(2_000)
composeRule.waitForIdle()
assertEquals("a new request should scroll back to the top", 0, state.firstVisibleItemIndex)
}
} }
+1 -1
View File
@@ -88,7 +88,7 @@ Playback is a *context* plus a manual *up next* list. `queue_entries` is the con
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from. Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet. The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
+2
View File
@@ -55,7 +55,9 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose",
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" } androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
androidx-media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
androidx-media3-database = { group = "androidx.media3", name = "media3-database", version.ref = "media3" }
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" } coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
@@ -1 +0,0 @@
539d294b0eb8bf9d7d09deb9819ff4af00c48259db496062629defc9289291bb
-373
View File
@@ -1,373 +0,0 @@
Categories:
- Multimedia
License: GPL-3.0-only
AuthorName: austinried
AuthorEmail: subtracks@functionkey.xyz
WebSite: https://github.com/austinried/subtracks
SourceCode: https://github.com/austinried/subtracks
IssueTracker: https://github.com/austinried/subtracks/issues
Donate: https://ko-fi.com/austinried
AutoName: Subtracks
RepoType: git
Repo: https://github.com/austinried/subtracks
Builds:
- versionName: 1.0.1
versionCode: 3
commit: v1.0.1
subdir: android/app
sudo:
- sysctl fs.inotify.max_user_watches=524288
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
| sha256sum -c -
- tar xJf node.tar.xz
- cp -a node-v14.17.5-linux-x64/. /usr/local/
- npm -g install yarn
gradle:
- yes
srclibs:
- react-native-reanimated@2.2.0
prebuild:
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
- cd ../..
- yarn install
- cd $$react-native-reanimated$$
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
- yarn add react-native@0.64.1
scanignore:
- android/build.gradle
- node_modules/jsc-android
- node_modules/hermes-engine
- node_modules/react-native/android/com/facebook/react/react-native/*/
- node_modules/react-native-safe-area-context/android/build.gradle
- node_modules/react-native-screens/android/build.gradle
- node_modules/react-native-webview/android/build.gradle
- node_modules/react-native/template/android/build.gradle
- node_modules/react-native-image-colors/android/build.gradle
- node_modules/react-native-track-player/android/build.gradle
- node_modules/react-native-vector-icons/android/build.gradle
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
- node_modules/@react-native-community/masked-view/android/build.gradle
- node_modules/@react-native-community/netinfo/android/build.gradle
scandelete:
- node_modules
build:
- pushd $$react-native-reanimated$$
- FOR_HERMES=True gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-64-hermes.aar
ndk: r21e
- versionName: 1.1.0
versionCode: 4
commit: v1.1.0
subdir: android/app
submodules: true
sudo:
- sysctl fs.inotify.max_user_watches=524288
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
| sha256sum -c -
- tar xJf node.tar.xz
- cp -a node-v14.17.5-linux-x64/. /usr/local/
- npm -g install yarn
gradle:
- yes
srclibs:
- react-native-reanimated@2.2.0
prebuild:
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
- cd ../..
- mkdir .yarncache
- yarn install --cache-folder ./.yarncache
- rm -rf .yarncache
- cd $$react-native-reanimated$$
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
- yarn add react-native@0.64.1
scanignore:
- android/build.gradle
- node_modules/jsc-android
- node_modules/hermes-engine
- node_modules/react-native/android/com/facebook/react/react-native/*/
- node_modules/react-native-safe-area-context/android/build.gradle
- node_modules/react-native-screens/android/build.gradle
- node_modules/react-native-webview/android/build.gradle
- node_modules/react-native/template/android/build.gradle
- node_modules/react-native-image-colors/android/build.gradle
- node_modules/react-native-track-player/android/build.gradle
- node_modules/react-native-vector-icons/android/build.gradle
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
- node_modules/@react-native-community/masked-view/android/build.gradle
- node_modules/@react-native-community/netinfo/android/build.gradle
scandelete:
- node_modules
- submodules
build:
- ../../scripts/prepare-exoplayer-ffmpeg.sh
- pushd $$react-native-reanimated$$
- FOR_HERMES=True gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-64-hermes.aar
ndk: r21e
- versionName: 1.1.1
versionCode: 5
commit: 336e106e0152a479490a6c619fc0ee53461a2cd8
subdir: android/app
submodules: true
sudo:
- sysctl fs.inotify.max_user_watches=524288
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
| sha256sum -c -
- tar xJf node.tar.xz
- cp -a node-v14.17.5-linux-x64/. /usr/local/
- npm -g install yarn
gradle:
- yes
srclibs:
- react-native-reanimated@2.2.0
prebuild:
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
- cd ../..
- mkdir .yarncache
- yarn install --cache-folder ./.yarncache
- rm -rf .yarncache
- cd $$react-native-reanimated$$
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
- yarn add react-native@0.64.1
scanignore:
- android/build.gradle
- node_modules/jsc-android
- node_modules/hermes-engine
- node_modules/react-native/android/com/facebook/react/react-native/*/
- node_modules/react-native-safe-area-context/android/build.gradle
- node_modules/react-native-screens/android/build.gradle
- node_modules/react-native-webview/android/build.gradle
- node_modules/react-native/template/android/build.gradle
- node_modules/react-native-image-colors/android/build.gradle
- node_modules/react-native-track-player/android/build.gradle
- node_modules/react-native-vector-icons/android/build.gradle
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
- node_modules/@react-native-community/masked-view/android/build.gradle
- node_modules/@react-native-community/netinfo/android/build.gradle
scandelete:
- node_modules
- submodules
build:
- ../../scripts/prepare-exoplayer-ffmpeg.sh
- pushd $$react-native-reanimated$$
- FOR_HERMES=True gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-64-hermes.aar
ndk: r21e
- versionName: 1.1.3
versionCode: 7
commit: 5a2ab98af55764c0ef4c73d2640a2d8c5c3b0609
subdir: android/app
submodules: true
sudo:
- sysctl fs.inotify.max_user_watches=524288
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
| sha256sum -c -
- tar xJf node.tar.xz
- cp -a node-v14.17.5-linux-x64/. /usr/local/
- npm -g install yarn
- apt-get update
- apt-get install -y wget
- apt-get install -y openjdk-11-jdk-headless
- update-alternatives --auto java
gradle:
- yes
srclibs:
- react-native-reanimated@2.3.1
prebuild:
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
- cd ../..
- mkdir .yarncache
- yarn install --network-concurrency 1 --cache-folder ./.yarncache
- rm -rf .yarncache
- cd $$react-native-reanimated$$
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
- yarn add react-native@0.67.1
scanignore:
- android/build.gradle
- node_modules/jsc-android
- node_modules/hermes-engine
- node_modules/react-native/android/com/facebook/react/react-native/*/
- node_modules/react-native-safe-area-context/android/build.gradle
- node_modules/react-native-screens/android/build.gradle
- node_modules/react-native-webview/android/build.gradle
- node_modules/react-native/template/android/build.gradle
- node_modules/react-native-image-colors/android/build.gradle
- node_modules/react-native-get-random-values/android/build.gradle
- node_modules/react-native-track-player/android/build.gradle
- node_modules/react-native-vector-icons/android/build.gradle
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
- node_modules/@react-native-community/masked-view/android/build.gradle
- node_modules/@react-native-community/netinfo/android/build.gradle
scandelete:
- node_modules
- submodules
build:
- ../../scripts/prepare-exoplayer-ffmpeg.sh
- pushd $$react-native-reanimated$$
- FOR_HERMES=True gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-hermes.aar
- pushd $$react-native-reanimated$$
- gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-jsc.aar
ndk: r21e
- versionName: 1.2.0
versionCode: 8
commit: 4905f75564bf8f6b96597a91d3d8f39b1551a56e
subdir: android/app
submodules: true
sudo:
- sysctl fs.inotify.max_user_watches=524288
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
| sha256sum -c -
- tar xJf node.tar.xz --strip-components=1 -C /usr/local/
- npm -g install yarn
- apt-get update
- apt-get install -y openjdk-11-jdk-headless
- update-alternatives --auto java
gradle:
- yes
srclibs:
- react-native-reanimated@2.3.1
prebuild:
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
- cd ../..
- mkdir .yarncache
- yarn install --network-concurrency 1 --cache-folder ./.yarncache
- rm -rf .yarncache
- cd $$react-native-reanimated$$
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
- yarn add react-native@0.67.4
scanignore:
- android/build.gradle
- node_modules/jsc-android
- node_modules/hermes-engine
- node_modules/react-native/android/com/facebook/react/react-native/*/
- node_modules/react-native-safe-area-context/android/build.gradle
- node_modules/react-native-screens/android/build.gradle
- node_modules/react-native-webview/android/build.gradle
- node_modules/react-native/template/android/build.gradle
- node_modules/react-native-image-colors/android/build.gradle
- node_modules/react-native-blob-util/android/build.gradle
- node_modules/react-native-track-player/android/build.gradle
- node_modules/react-native-vector-icons/android/build.gradle
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
- node_modules/@react-native-community/masked-view/android/build.gradle
- node_modules/@react-native-community/netinfo/android/build.gradle
scandelete:
- node_modules
- submodules
build:
- ../../scripts/prepare-exoplayer-ffmpeg.sh
- pushd $$react-native-reanimated$$
- FOR_HERMES=True gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-hermes.aar
- pushd $$react-native-reanimated$$
- gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-jsc.aar
ndk: r21e
- versionName: 1.3.0
versionCode: 9
commit: 2bf3e8853de705bb49aa4175b1ad5a9b5bb431b2
subdir: android/app
submodules: true
sudo:
- sysctl fs.inotify.max_user_watches=524288
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
| sha256sum -c -
- tar xJf node.tar.xz --strip-components=1 -C /usr/local/
- npm -g install yarn
- apt-get update
- apt-get install -y openjdk-11-jdk-headless
- update-alternatives --auto java
gradle:
- yes
srclibs:
- react-native-reanimated@2.3.1
prebuild:
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
- cd ../..
- mkdir .yarncache
- yarn install --network-concurrency 1 --cache-folder ./.yarncache
- rm -rf .yarncache
- cd $$react-native-reanimated$$
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
- yarn add react-native@0.67.4
scanignore:
- android/build.gradle
- node_modules/jsc-android
- node_modules/hermes-engine
- node_modules/react-native/android/com/facebook/react/react-native/*/
- node_modules/react-native-safe-area-context/android/build.gradle
- node_modules/react-native-screens/android/build.gradle
- node_modules/react-native-webview/android/build.gradle
- node_modules/react-native/template/android/build.gradle
- node_modules/react-native-image-colors/android/build.gradle
- node_modules/react-native-blob-util/android/build.gradle
- node_modules/react-native-localize/android/build.gradle
- node_modules/react-native-track-player/android/build.gradle
- node_modules/react-native-vector-icons/android/build.gradle
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
- node_modules/@react-native-community/masked-view/android/build.gradle
- node_modules/@react-native-community/netinfo/android/build.gradle
scandelete:
- node_modules
- submodules
build:
- ../../scripts/prepare-exoplayer-ffmpeg.sh
- pushd $$react-native-reanimated$$
- FOR_HERMES=True gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-hermes.aar
- pushd $$react-native-reanimated$$
- gradle :assembleRelease
- popd
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-jsc.aar
ndk: r21e
- versionName: 3.0.0
versionCode: 13
commit: v3.0.0
subdir: app
gradle:
- yes
AutoUpdateMode: Version
UpdateCheckMode: Tags
CurrentVersion: 3.0.0
CurrentVersionCode: 13