mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 23:39:15 +01:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bbc29a1d6 | ||
|
|
0a3e839da0 | ||
|
|
e0a09b724b | ||
|
|
e806eaef89 | ||
|
|
0cef3657bc | ||
|
|
20c6ba526f | ||
|
|
cfb663c250 | ||
|
|
ac1e62fc02 | ||
|
|
adabe21d63 | ||
|
|
0bdac8fa22 |
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
Reference in New Issue
Block a user