mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 23:39:15 +01:00
Compare commits
12
Commits
15d77f998f
...
v3.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e396671ac7 | ||
|
|
8e7431cc2e | ||
|
|
7bbc29a1d6 | ||
|
|
0a3e839da0 | ||
|
|
e0a09b724b | ||
|
|
e806eaef89 | ||
|
|
0cef3657bc | ||
|
|
20c6ba526f | ||
|
|
cfb663c250 | ||
|
|
ac1e62fc02 | ||
|
|
adabe21d63 | ||
|
|
0bdac8fa22 |
@@ -48,6 +48,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
||||
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
||||
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
||||
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
||||
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
|
||||
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
||||
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
||||
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
||||
|
||||
@@ -14,6 +14,19 @@ ktlint {
|
||||
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 {
|
||||
namespace = "com.subtracks"
|
||||
compileSdk = 37
|
||||
@@ -25,6 +38,7 @@ android {
|
||||
targetSdk = 37
|
||||
versionCode = 13
|
||||
versionName = "3.0.0"
|
||||
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
@@ -36,6 +50,12 @@ android {
|
||||
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 {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
@@ -238,7 +258,9 @@ dependencies {
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.androidx.media3.exoplayer)
|
||||
implementation(libs.androidx.media3.session)
|
||||
implementation(libs.androidx.media3.datasource)
|
||||
implementation(libs.androidx.media3.datasource.okhttp)
|
||||
implementation(libs.androidx.media3.database)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.okhttp)
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.subtracks.data.sync.AutoSync
|
||||
import com.subtracks.ui.NowPlayingLauncher
|
||||
import com.subtracks.ui.SubtracksRoot
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||
|
||||
private val requestNotifications =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
autoSync.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
autoSync.setForeground(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermission()
|
||||
|
||||
@@ -5,6 +5,7 @@ import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.annotation.DelicateCoilApi
|
||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||
import com.subtracks.data.LegacyDataCleanup
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.ArtworkSeedStore
|
||||
import com.subtracks.di.appModule
|
||||
@@ -25,6 +26,7 @@ class SubtracksApp : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.init(this)
|
||||
LegacyDataCleanup.run(this)
|
||||
stopKoin()
|
||||
SingletonImageLoader.reset()
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
@@ -48,6 +49,10 @@ data class StreamQuality(
|
||||
|
||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||
|
||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||
|
||||
enum class SyncMode { Off, WifiOnly, Always }
|
||||
|
||||
data class ArtworkSeedValue(
|
||||
val cacheKey: String,
|
||||
val primary: Int,
|
||||
@@ -105,6 +110,34 @@ class UserPreferences(
|
||||
|
||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||
|
||||
fun syncMode(): Flow<SyncMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
|
||||
}
|
||||
|
||||
suspend fun setSyncMode(mode: SyncMode) {
|
||||
store.edit { prefs -> prefs[syncModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||
|
||||
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||
|
||||
suspend fun setLastSyncAt(value: Long) {
|
||||
store.edit { prefs -> prefs[lastSyncAtKey] = value }
|
||||
}
|
||||
|
||||
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
|
||||
|
||||
fun syncIntervalMinutes(): Flow<Int> =
|
||||
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
|
||||
|
||||
suspend fun setSyncIntervalMinutes(value: Int) {
|
||||
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
|
||||
}
|
||||
|
||||
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
|
||||
|
||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||
|
||||
suspend fun setScrobbling(enabled: Boolean) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
|
||||
|
||||
internal fun shouldAutoSync(
|
||||
mode: SyncMode,
|
||||
network: NetworkMode,
|
||||
offline: Boolean,
|
||||
hasSource: Boolean,
|
||||
lastSyncAt: Long,
|
||||
now: Long,
|
||||
intervalMs: Long,
|
||||
): Boolean {
|
||||
val allowed =
|
||||
when (mode) {
|
||||
SyncMode.Off -> false
|
||||
SyncMode.WifiOnly -> network == NetworkMode.Wifi
|
||||
SyncMode.Always -> true
|
||||
}
|
||||
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
|
||||
}
|
||||
|
||||
class AutoSync(
|
||||
private val preferences: UserPreferences,
|
||||
private val networkMode: Flow<NetworkMode>,
|
||||
private val hasSource: suspend () -> Boolean,
|
||||
private val requestSync: () -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
|
||||
) {
|
||||
@Volatile
|
||||
private var lastNetwork: NetworkMode? = null
|
||||
private var loop: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch { networkMode.collect { lastNetwork = it } }
|
||||
}
|
||||
|
||||
fun setForeground(active: Boolean) {
|
||||
if (active) start() else stop()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (loop?.isActive == true) return
|
||||
loop =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val intervalMs =
|
||||
try {
|
||||
checkAndRequest()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w("sync", "auto-sync check failed", failure)
|
||||
maxPollMs
|
||||
}
|
||||
delay(intervalMs.coerceAtMost(maxPollMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
loop?.cancel()
|
||||
loop = null
|
||||
}
|
||||
|
||||
private suspend fun checkAndRequest(): Long {
|
||||
val mode = preferences.syncMode().first()
|
||||
val network = lastNetwork ?: networkMode.first()
|
||||
val offline = preferences.offlineMode().first()
|
||||
val hasSource = hasSource()
|
||||
val lastSyncAt = preferences.lastSyncAt().first()
|
||||
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
|
||||
val time = now()
|
||||
val due =
|
||||
shouldAutoSync(
|
||||
mode = mode,
|
||||
network = network,
|
||||
offline = offline,
|
||||
hasSource = hasSource,
|
||||
lastSyncAt = lastSyncAt,
|
||||
now = time,
|
||||
intervalMs = intervalMs,
|
||||
)
|
||||
Log.d(
|
||||
"sync",
|
||||
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
|
||||
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
|
||||
)
|
||||
if (due && currentCoroutineContext().isActive) requestSync()
|
||||
return intervalMs
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.subtracks.data.sync
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
@@ -13,14 +14,18 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface SyncStatus {
|
||||
data object Idle : SyncStatus
|
||||
|
||||
data object Running : SyncStatus
|
||||
data class Running(
|
||||
val silent: Boolean,
|
||||
) : SyncStatus
|
||||
|
||||
data object Success : SyncStatus
|
||||
|
||||
@@ -34,31 +39,47 @@ class SyncManager(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val queueRepository: QueueRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val preferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
private val requests = Channel<Boolean>(Channel.CONFLATED)
|
||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (ignored in requests) runSync()
|
||||
for (silent in requests) {
|
||||
try {
|
||||
runSync(silent)
|
||||
} catch (cancellation: CancellationException) {
|
||||
if (!currentCoroutineContext().isActive) throw cancellation
|
||||
_status.value = SyncStatus.Idle
|
||||
Log.w("sync", "sync interrupted", cancellation)
|
||||
} catch (failure: Throwable) {
|
||||
_status.value = SyncStatus.Idle
|
||||
Log.w("sync", "unexpected failure", failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSync() {
|
||||
requests.trySend(Unit)
|
||||
fun requestSync(silent: Boolean = false) {
|
||||
requests.trySend(silent)
|
||||
}
|
||||
|
||||
private suspend fun runSync() {
|
||||
private suspend fun runSync(silent: Boolean) {
|
||||
if (sourceRepository.offline.value) {
|
||||
val offline = UiMessage(R.string.sync_offline)
|
||||
showMessage(offline)
|
||||
_status.value = SyncStatus.Failed(offline)
|
||||
if (!silent) {
|
||||
val offline = UiMessage(R.string.sync_offline)
|
||||
showMessage(offline)
|
||||
_status.value = SyncStatus.Failed(offline)
|
||||
}
|
||||
return
|
||||
}
|
||||
_status.value = SyncStatus.Running
|
||||
_status.value = SyncStatus.Running(silent)
|
||||
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||
val startedAt = System.nanoTime()
|
||||
val result =
|
||||
try {
|
||||
@@ -88,7 +109,7 @@ class SyncManager(
|
||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||
}
|
||||
}
|
||||
showMessage(message)
|
||||
if (!silent) showMessage(message)
|
||||
SyncStatus.Failed(message)
|
||||
}
|
||||
queueRepository.invalidateLibraryCache()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.subtracks.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SyncNotifier(
|
||||
private val context: Context,
|
||||
private val status: Flow<SyncStatus>,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val manager = NotificationManagerCompat.from(context)
|
||||
|
||||
fun start() {
|
||||
createChannel()
|
||||
scope.launch {
|
||||
status.collect { syncStatus ->
|
||||
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||
manager.notify(ID, build())
|
||||
} else {
|
||||
manager.cancel(ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(): Notification =
|
||||
NotificationCompat
|
||||
.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||
.setContentText(context.getString(R.string.sync_notification_text))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setContentIntent(launchIntent())
|
||||
.build()
|
||||
|
||||
private fun launchIntent(): PendingIntent? =
|
||||
context.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.sync_notification_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ID = 2
|
||||
private const val CHANNEL_ID = "sync"
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ import com.subtracks.data.repo.PendingServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.sync.AutoSync
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncNotifier
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
@@ -96,7 +98,16 @@ fun appModule(
|
||||
}
|
||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||
single {
|
||||
AutoSync(
|
||||
preferences = get(),
|
||||
networkMode = networkMode(context.applicationContext),
|
||||
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||
scope = get(),
|
||||
)
|
||||
}
|
||||
single { QueueRepository(get()) }
|
||||
single { ArtworkStore(downloadsRoot(context)) }
|
||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||
@@ -115,6 +126,7 @@ fun appModule(
|
||||
).also { it.start() }
|
||||
}
|
||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||
single(createdAtStart = true) {
|
||||
|
||||
@@ -261,6 +261,15 @@ class PlaybackController(
|
||||
artistId: String,
|
||||
) = 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(
|
||||
entry: QueueEntry,
|
||||
start: Long,
|
||||
|
||||
@@ -12,8 +12,13 @@ import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.util.ExperimentalApi
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.database.StandaloneDatabaseProvider
|
||||
import androidx.media3.datasource.DataSource
|
||||
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.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
@@ -41,6 +46,7 @@ import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.context.GlobalContext
|
||||
import java.io.File
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class PlaybackService : MediaSessionService() {
|
||||
@@ -91,6 +97,7 @@ class PlaybackService : MediaSessionService() {
|
||||
val sources = GlobalContext.get().get<SourceRepository>()
|
||||
sourceRepository = sources
|
||||
library = GlobalContext.get().get<LibraryRepository>()
|
||||
val cache = StreamCache.open(this)
|
||||
val exoPlayer =
|
||||
ExoPlayer
|
||||
.Builder(this)
|
||||
@@ -106,7 +113,11 @@ class PlaybackService : MediaSessionService() {
|
||||
DefaultMediaSourceFactory(this)
|
||||
.setEnableClippingInMediaPeriod(true)
|
||||
.setDataSourceFactory(
|
||||
mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value)),
|
||||
mediaDataSourceFactory(
|
||||
this,
|
||||
cache,
|
||||
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||
),
|
||||
),
|
||||
).build()
|
||||
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)
|
||||
internal fun mediaDataSourceFactory(
|
||||
context: Context,
|
||||
cache: Cache?,
|
||||
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 != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||
if (restarted) {
|
||||
val alreadyAnnounced = nowPlayingSent && item?.id == songId
|
||||
songId = item?.id
|
||||
lastItemId = null
|
||||
playedMs = 0
|
||||
@@ -68,7 +69,7 @@ class ScrobblePolicy(
|
||||
if (item == null || !isPlaying) return null
|
||||
nowPlayingSent = true
|
||||
startedAt = now()
|
||||
return Scrobble.NowPlaying(item.id)
|
||||
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
|
||||
}
|
||||
lastPositionMs = positionMs
|
||||
lastItemId = item?.id
|
||||
|
||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
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.baseArtworkColors
|
||||
import com.subtracks.ui.theme.heroBarColor
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val FADE_DISTANCE_DP = 64
|
||||
|
||||
@@ -102,6 +104,7 @@ fun HeroDetailScaffold(
|
||||
ArtworkTheme(effectiveArtwork) {
|
||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||
val listState = rememberLazyListState()
|
||||
val scope = rememberCoroutineScope()
|
||||
val fill = rememberViewportFill(listState)
|
||||
val density = LocalDensity.current
|
||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||
@@ -195,7 +198,8 @@ fun HeroDetailScaffold(
|
||||
.clickable(
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
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() }
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ShuffleFab
|
||||
import com.subtracks.ui.library.PlaylistsContent
|
||||
import com.subtracks.ui.library.SongRow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
@@ -110,6 +115,7 @@ fun HomeListRoute(
|
||||
onPlaylistClick = onPlaylistClick,
|
||||
onSongClick = viewModel::play,
|
||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||
onGenreClick = onGenreClick,
|
||||
onDecadeClick = onDecadeClick,
|
||||
)
|
||||
@@ -134,11 +140,14 @@ fun HomeListScreen(
|
||||
onPlaylistClick: (Playlist) -> Unit = {},
|
||||
onSongClick: (Song) -> Unit = {},
|
||||
onLongClick: (MenuTarget) -> Unit = {},
|
||||
onShuffle: (() -> Unit)? = null,
|
||||
onGenreClick: (String) -> Unit = {},
|
||||
onDecadeClick: (Long) -> Unit = {},
|
||||
) {
|
||||
val now = remember { System.currentTimeMillis() }
|
||||
val resources = LocalResources.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val songListState = rememberLazyListState()
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
@@ -149,8 +158,21 @@ fun HomeListScreen(
|
||||
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 ->
|
||||
val downloaded = request.downloaded
|
||||
if (downloaded != null) {
|
||||
@@ -184,6 +206,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
@@ -237,6 +260,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
}
|
||||
@@ -254,6 +278,7 @@ fun HomeListScreen(
|
||||
playingSongId = playingSongId,
|
||||
onSongClick = onSongClick,
|
||||
onLongClick = onLongClick,
|
||||
listState = songListState,
|
||||
modifier = Modifier.padding(padding),
|
||||
)
|
||||
} else {
|
||||
@@ -297,6 +322,7 @@ private fun SongsList(
|
||||
playingSongId: String?,
|
||||
onSongClick: (Song) -> Unit,
|
||||
onLongClick: (MenuTarget) -> Unit,
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||
@@ -304,8 +330,9 @@ private fun SongsList(
|
||||
return
|
||||
}
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(bottom = 24.dp),
|
||||
contentPadding = PaddingValues(bottom = 88.dp),
|
||||
) {
|
||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||
val item = songs[index]
|
||||
|
||||
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
data class HomeListRequest(
|
||||
val title: String,
|
||||
@@ -51,6 +53,11 @@ class HomeListViewModel(
|
||||
) : ViewModel() {
|
||||
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>> =
|
||||
sourceId
|
||||
.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -53,11 +52,6 @@ class HomeViewModel(
|
||||
private val syncManager: SyncManager,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val offline: StateFlow<Boolean> =
|
||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
@@ -141,7 +135,9 @@ class HomeViewModel(
|
||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
|
||||
private data class PlayRows(
|
||||
val recentAlbums: List<Album>,
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -56,6 +57,7 @@ fun AlbumsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -78,6 +80,7 @@ fun AlbumsContent(
|
||||
else -> {
|
||||
val gridState = rememberLazyGridState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||
val fill = rememberViewportFill(gridState)
|
||||
LazyVerticalGrid(
|
||||
state = gridState,
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||
|
||||
@@ -53,6 +54,7 @@ fun ArtistsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -70,6 +72,7 @@ fun ArtistsContent(
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Remove
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
@@ -243,6 +244,7 @@ fun LibraryRoute(
|
||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||
val listTab = selectedTab.listTab()
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (previousTab != selectedTab) {
|
||||
@@ -296,6 +298,7 @@ fun LibraryRoute(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||
syncing = syncing,
|
||||
refreshing = refreshing,
|
||||
bottomInset = bottomInset,
|
||||
artwork = artwork,
|
||||
albums = viewModel.albums,
|
||||
@@ -392,6 +395,7 @@ fun LibraryScreen(
|
||||
onExitOffline: () -> Unit = {},
|
||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||
syncing: Boolean = false,
|
||||
refreshing: Boolean = false,
|
||||
bottomInset: Dp = 0.dp,
|
||||
listQuery: ListQuery = ListQuery(""),
|
||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||
@@ -418,6 +422,7 @@ fun LibraryScreen(
|
||||
)
|
||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||
var topRequest by remember { mutableIntStateOf(0) }
|
||||
val searchFocus = remember { FocusRequester() }
|
||||
|
||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||
@@ -470,15 +475,16 @@ fun LibraryScreen(
|
||||
) { page ->
|
||||
val pageTab = LibraryTab.entries[page]
|
||||
val resetKey = resetKeys[pageTab]
|
||||
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
PullToRefreshBox(
|
||||
isRefreshing = syncing,
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = onSync,
|
||||
state = pullToRefreshState,
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = syncing,
|
||||
isRefreshing = refreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||
containerColor = headerColor,
|
||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||
@@ -507,6 +513,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = albumDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -526,6 +533,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = artistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -545,6 +553,7 @@ fun LibraryScreen(
|
||||
topInset = listTopInset,
|
||||
onSync = onSync,
|
||||
downloadStatuses = playlistDownloads,
|
||||
scrollToTopKey = scrollToTopKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -570,11 +579,14 @@ fun LibraryScreen(
|
||||
) {
|
||||
LibraryTabs(
|
||||
pagerState = pagerState,
|
||||
onTabSelected = onTabSelected,
|
||||
onTabSelected = { tab ->
|
||||
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||
},
|
||||
onOpenSettings = onOpenSettings,
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -841,6 +853,7 @@ internal fun LibraryTabs(
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
@@ -932,11 +945,20 @@ internal fun LibraryTabs(
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,12 @@ class LibraryViewModel(
|
||||
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.map { it is SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val refreshing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it is SyncStatus.Running && !it.silent }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
@@ -206,7 +211,9 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||
|
||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
||||
import com.subtracks.ui.components.LoadingState
|
||||
import com.subtracks.ui.components.MenuTarget
|
||||
import com.subtracks.ui.components.ResetScrollOnChange
|
||||
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
|
||||
@Composable
|
||||
@@ -52,6 +53,7 @@ fun PlaylistsContent(
|
||||
topInset: Dp = 0.dp,
|
||||
onSync: () -> Unit = {},
|
||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||
scrollToTopKey: Any? = null,
|
||||
) {
|
||||
when {
|
||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||
@@ -74,6 +76,7 @@ fun PlaylistsContent(
|
||||
else -> {
|
||||
val listState = rememberLazyListState()
|
||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||
val fill = rememberViewportFill(listState)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
|
||||
@@ -113,6 +113,8 @@ class QueueViewModel(
|
||||
var generation by mutableIntStateOf(0)
|
||||
private set
|
||||
|
||||
private var reorderPending = false
|
||||
|
||||
private var nextId = 0L
|
||||
private val mutex = Mutex()
|
||||
|
||||
@@ -249,12 +251,19 @@ class QueueViewModel(
|
||||
fun move(
|
||||
from: Long,
|
||||
to: Long,
|
||||
) {
|
||||
if (from == to) return
|
||||
): Boolean {
|
||||
if (from == to) return false
|
||||
if (reorderPending) return false
|
||||
reorderPending = true
|
||||
viewModelScope.launch {
|
||||
playbackController.move(from, to)
|
||||
mutex.withLock { reconcileLocked() }
|
||||
try {
|
||||
playbackController.move(from, to)
|
||||
mutex.withLock { reconcileLocked() }
|
||||
} finally {
|
||||
reorderPending = false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun remove(position: Long) {
|
||||
@@ -331,7 +340,7 @@ fun QueueScreen(
|
||||
onPlay: (Long) -> Unit,
|
||||
onRemove: (Long) -> Unit,
|
||||
onReorder: (Int, Int) -> Unit,
|
||||
onMove: (Long, Long) -> Unit,
|
||||
onMove: (Long, Long) -> Boolean,
|
||||
onLoadOlder: () -> Unit,
|
||||
onLoadNewer: () -> Unit,
|
||||
onUndo: () -> Unit,
|
||||
@@ -347,7 +356,6 @@ fun QueueScreen(
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||
var dragFrom by remember { mutableStateOf<Long?>(null) }
|
||||
val undoLabel = stringResource(R.string.undo)
|
||||
val queueReordered = stringResource(R.string.queue_reordered)
|
||||
val queueRemoved = stringResource(R.string.queue_removed)
|
||||
@@ -470,28 +478,14 @@ fun QueueScreen(
|
||||
null
|
||||
} else {
|
||||
Modifier.draggableHandle(
|
||||
onDragStarted = {
|
||||
dragId = row.id
|
||||
dragFrom = row.position
|
||||
},
|
||||
onDragStarted = { dragId = row.id },
|
||||
onDragStopped = {
|
||||
val id = dragId
|
||||
val from = dragFrom
|
||||
if (id != null && from != null) {
|
||||
val draggedIndex = rows.indexOfFirst { it.id == id }
|
||||
val to =
|
||||
if (draggedIndex < 0) {
|
||||
from
|
||||
} else {
|
||||
dropTarget(rows, draggedIndex, from)
|
||||
}
|
||||
if (to != from) {
|
||||
onMove(from, to)
|
||||
showUndo(queueReordered)
|
||||
dragId?.let { id ->
|
||||
rows.dropMoveFor(id)?.let { move ->
|
||||
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
||||
}
|
||||
}
|
||||
dragId = null
|
||||
dragFrom = null
|
||||
},
|
||||
)
|
||||
},
|
||||
@@ -563,6 +557,19 @@ internal fun dropTarget(
|
||||
return (before - if (before > from) 1 else 0) + 1
|
||||
}
|
||||
|
||||
internal data class QueueMove(
|
||||
val from: Long,
|
||||
val to: Long,
|
||||
)
|
||||
|
||||
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||
val index = indexOfFirst { it.id == draggedId }
|
||||
if (index < 0) return null
|
||||
val from = this[index].position
|
||||
val to = dropTarget(this, index, from)
|
||||
return if (to == from) null else QueueMove(from, to)
|
||||
}
|
||||
|
||||
@Suppress("ModifierParameter")
|
||||
@Composable
|
||||
private fun QueueRowItem(
|
||||
|
||||
@@ -65,7 +65,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.BuildConfig
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
@@ -82,7 +84,7 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||
|
||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency }
|
||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
@@ -99,6 +101,8 @@ fun SettingsRoute(
|
||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
|
||||
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
|
||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
@@ -112,6 +116,8 @@ fun SettingsRoute(
|
||||
wifiQuality = wifiQuality,
|
||||
mobileQuality = mobileQuality,
|
||||
syncConcurrency = syncConcurrency,
|
||||
syncMode = syncMode,
|
||||
syncInterval = syncIntervalMinutes,
|
||||
downloadQuality = downloadQuality,
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
@@ -122,6 +128,8 @@ fun SettingsRoute(
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
onMobileQualityChange = viewModel::setMobileQuality,
|
||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||
onSyncModeChange = viewModel::setSyncMode,
|
||||
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
|
||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
@@ -166,11 +174,15 @@ fun SettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
offline: Boolean = false,
|
||||
verboseLogging: Boolean = false,
|
||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||
onSyncConcurrencyChange: (Int) -> Unit,
|
||||
onSyncModeChange: (SyncMode) -> Unit = {},
|
||||
onSyncIntervalChange: (Int) -> Unit = {},
|
||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
@@ -288,6 +300,22 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.sync_interval)) },
|
||||
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -388,7 +416,16 @@ fun SettingsScreen(
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.app_name)) },
|
||||
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) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
@@ -466,6 +503,22 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SyncInterval -> {
|
||||
SyncIntervalDialog(
|
||||
selected = syncInterval,
|
||||
onSelect = onSyncIntervalChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SyncMode -> {
|
||||
SyncModeDialog(
|
||||
selected = syncMode,
|
||||
onSelect = onSyncModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -491,11 +544,56 @@ private fun bitrateLabel(
|
||||
|
||||
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
||||
|
||||
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
private fun concurrencyLabel(
|
||||
resources: Resources,
|
||||
value: Int,
|
||||
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
||||
|
||||
private fun syncIntervalLabel(
|
||||
resources: Resources,
|
||||
minutes: Int,
|
||||
): String =
|
||||
if (minutes >= 60 && minutes % 60 == 0) {
|
||||
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
|
||||
} else {
|
||||
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncIntervalDialog(
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableIntStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.sync_interval)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.sync_interval_prompt),
|
||||
options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConcurrencyDialog(
|
||||
selected: Int,
|
||||
@@ -529,6 +627,51 @@ private fun ConcurrencyDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val syncModeOptions = SyncMode.entries
|
||||
|
||||
private fun syncModeLabel(
|
||||
resources: Resources,
|
||||
mode: SyncMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
|
||||
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
|
||||
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncModeDialog(
|
||||
selected: SyncMode,
|
||||
onSelect: (SyncMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.sync_mode_prompt),
|
||||
options = syncModeOptions.map { it to syncModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QualityDialog(
|
||||
title: String,
|
||||
|
||||
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||
@@ -48,6 +50,16 @@ class SettingsViewModel(
|
||||
.syncConcurrency()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||
|
||||
val syncMode: StateFlow<SyncMode> =
|
||||
userPreferences
|
||||
.syncMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly)
|
||||
|
||||
val syncIntervalMinutes: StateFlow<Int> =
|
||||
userPreferences
|
||||
.syncIntervalMinutes()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
val scrobbling: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.scrobbling()
|
||||
@@ -92,6 +104,14 @@ class SettingsViewModel(
|
||||
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||
}
|
||||
|
||||
fun setSyncMode(mode: SyncMode) {
|
||||
viewModelScope.launch { userPreferences.setSyncMode(mode) }
|
||||
}
|
||||
|
||||
fun setSyncIntervalMinutes(value: Int) {
|
||||
viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) }
|
||||
}
|
||||
|
||||
fun setScrobbling(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name">subtracks</string>
|
||||
<string name="download_channel_description">Progress while songs download</string>
|
||||
<string name="sync_notification_channel">Sync</string>
|
||||
<string name="sync_notification_title">Syncing library</string>
|
||||
<string name="sync_notification_text">Keeping your library up to date</string>
|
||||
|
||||
<string name="actions_cancel">Cancel</string>
|
||||
<string name="actions_delete">Delete</string>
|
||||
@@ -20,6 +23,7 @@
|
||||
<string name="controls_more">More</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_library">Library</string>
|
||||
<string name="navigation_tabs_settings">Settings</string>
|
||||
@@ -200,6 +204,21 @@
|
||||
<string name="sync_concurrency">Sync concurrency</string>
|
||||
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
|
||||
<string name="sync_concurrency_sequential">1 (sequential)</string>
|
||||
<string name="settings_sync_auto_title">Sync automatically</string>
|
||||
<string name="sync_mode_off">Off</string>
|
||||
<string name="sync_mode_wifi">Wi-Fi only</string>
|
||||
<string name="sync_mode_always">Always</string>
|
||||
<string name="sync_mode_prompt">When to sync automatically</string>
|
||||
<string name="sync_interval">Automatic sync interval</string>
|
||||
<string name="sync_interval_prompt">How often to sync automatically</string>
|
||||
<plurals name="sync_interval_minutes">
|
||||
<item quantity="one">%1$d minute</item>
|
||||
<item quantity="other">%1$d minutes</item>
|
||||
</plurals>
|
||||
<plurals name="sync_interval_hours">
|
||||
<item quantity="one">%1$d hour</item>
|
||||
<item quantity="other">%1$d hours</item>
|
||||
</plurals>
|
||||
<string name="download_quality">Download quality</string>
|
||||
<string name="download_over_mobile">Download over mobile data</string>
|
||||
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import com.subtracks.awaitUntil
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class AutoSyncTest {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
cancelAndJoinBlocking(scope)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decidesWhetherASyncIsDue() {
|
||||
assertTrue(shouldSync())
|
||||
assertTrue(shouldSync(mode = SyncMode.WifiOnly))
|
||||
assertTrue(shouldSync(mode = SyncMode.Always, network = NetworkMode.Mobile))
|
||||
assertTrue(shouldSync(lastSyncAt = INTERVAL_MS * 2, now = INTERVAL_MS))
|
||||
|
||||
assertFalse(shouldSync(mode = SyncMode.Off))
|
||||
assertFalse(shouldSync(mode = SyncMode.WifiOnly, network = NetworkMode.Mobile))
|
||||
assertFalse(shouldSync(lastSyncAt = INTERVAL_MS - 1, now = INTERVAL_MS))
|
||||
assertFalse(shouldSync(offline = true))
|
||||
assertFalse(shouldSync(hasSource = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncsWhenTheAppIsActiveAndDue() {
|
||||
val preferences = fakeUserPreferences()
|
||||
runBlocking {
|
||||
preferences.setSyncMode(SyncMode.WifiOnly)
|
||||
preferences.setLastSyncAt(0L)
|
||||
}
|
||||
val requested = AtomicBoolean(false)
|
||||
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
|
||||
|
||||
autoSync.setForeground(true)
|
||||
|
||||
awaitUntil("auto sync requested") { requested.get() }
|
||||
autoSync.setForeground(false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun doesNotSyncWhenOff() {
|
||||
val preferences = fakeUserPreferences()
|
||||
runBlocking {
|
||||
preferences.setSyncMode(SyncMode.Off)
|
||||
preferences.setLastSyncAt(0L)
|
||||
}
|
||||
val requested = AtomicBoolean(false)
|
||||
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
|
||||
|
||||
autoSync.setForeground(true)
|
||||
runBlocking { delay(300) }
|
||||
autoSync.setForeground(false)
|
||||
|
||||
assertFalse(requested.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopsSyncingWhenBackgrounded() {
|
||||
val preferences = fakeUserPreferences()
|
||||
runBlocking {
|
||||
preferences.setSyncMode(SyncMode.Always)
|
||||
preferences.setLastSyncAt(0L)
|
||||
}
|
||||
val count = AtomicInteger(0)
|
||||
val autoSync =
|
||||
AutoSync(
|
||||
preferences = preferences,
|
||||
networkMode = flowOf(NetworkMode.Wifi),
|
||||
hasSource = { true },
|
||||
requestSync = { count.incrementAndGet() },
|
||||
scope = scope,
|
||||
now = { INTERVAL_MS + 1 },
|
||||
maxPollMs = 50L,
|
||||
)
|
||||
|
||||
autoSync.setForeground(true)
|
||||
awaitUntil("first sync") { count.get() >= 1 }
|
||||
autoSync.setForeground(false)
|
||||
|
||||
runBlocking { delay(200) }
|
||||
val afterStop = count.get()
|
||||
runBlocking { delay(300) }
|
||||
|
||||
assertEquals(afterStop, count.get())
|
||||
}
|
||||
|
||||
private fun autoSync(
|
||||
preferences: UserPreferences,
|
||||
requestSync: () -> Unit,
|
||||
now: () -> Long = { INTERVAL_MS + 1 },
|
||||
): AutoSync =
|
||||
AutoSync(
|
||||
preferences = preferences,
|
||||
networkMode = flowOf(NetworkMode.Wifi),
|
||||
hasSource = { true },
|
||||
requestSync = requestSync,
|
||||
scope = scope,
|
||||
now = now,
|
||||
maxPollMs = 10_000L,
|
||||
)
|
||||
|
||||
private fun shouldSync(
|
||||
mode: SyncMode = SyncMode.WifiOnly,
|
||||
network: NetworkMode = NetworkMode.Wifi,
|
||||
offline: Boolean = false,
|
||||
hasSource: Boolean = true,
|
||||
lastSyncAt: Long = 0L,
|
||||
now: Long = INTERVAL_MS,
|
||||
intervalMs: Long = INTERVAL_MS,
|
||||
): Boolean =
|
||||
shouldAutoSync(
|
||||
mode = mode,
|
||||
network = network,
|
||||
offline = offline,
|
||||
hasSource = hasSource,
|
||||
lastSyncAt = lastSyncAt,
|
||||
now = now,
|
||||
intervalMs = intervalMs,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MINUTES * 60_000L
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import com.subtracks.TEST_TIMEOUT_MS
|
||||
import com.subtracks.cancelAndJoinBlocking
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.NetworkServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
@@ -44,6 +45,7 @@ class SyncManagerTest {
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var queueRepository: QueueRepository
|
||||
private lateinit var manager: SyncManager
|
||||
private lateinit var preferences: UserPreferences
|
||||
private lateinit var scope: CoroutineScope
|
||||
private val messages = CopyOnWriteArrayList<String>()
|
||||
private lateinit var server: MockWebServer
|
||||
@@ -62,12 +64,14 @@ class SyncManagerTest {
|
||||
sourceRepository =
|
||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||
queueRepository = QueueRepository(db)
|
||||
preferences = fakeUserPreferences()
|
||||
manager =
|
||||
SyncManager(
|
||||
db,
|
||||
sourceRepository,
|
||||
queueRepository,
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
preferences = preferences,
|
||||
showMessage = { messages += it.resolve(resources) },
|
||||
scope = scope,
|
||||
)
|
||||
@@ -89,6 +93,16 @@ class SyncManagerTest {
|
||||
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||
|
||||
assertEquals(listOf("No server configured"), messages)
|
||||
assertTrue(preferences.lastSyncAt().first() > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSilentSyncDoesNotTellTheUser() =
|
||||
runBlocking {
|
||||
manager.requestSync(silent = true)
|
||||
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||
|
||||
assertTrue(messages.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -114,6 +128,7 @@ class SyncManagerTest {
|
||||
|
||||
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
|
||||
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
||||
assertTrue(preferences.lastSyncAt().first() > 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,7 +2,12 @@ package com.subtracks.playback
|
||||
|
||||
import android.content.Context
|
||||
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.cache.NoOpCacheEvictor
|
||||
import androidx.media3.datasource.cache.SimpleCache
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class MediaDataSourceTest {
|
||||
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
|
||||
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
||||
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
|
||||
|
||||
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 androidx.media3.common.PlaybackException
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
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())
|
||||
}
|
||||
|
||||
@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
|
||||
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
|
||||
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(
|
||||
albumId: String,
|
||||
count: Int,
|
||||
|
||||
@@ -130,15 +130,54 @@ class ScrobblePolicyTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restartingTheSameTrackScrobblesAgain() {
|
||||
fun restartingTheSameTrackResubmitsWithoutReannouncing() {
|
||||
val item = track(durationMs = 200_000)
|
||||
play(item, toPositionMs = 100_000)
|
||||
clock = 120_000L
|
||||
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())
|
||||
}
|
||||
|
||||
@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
|
||||
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
|
||||
val first = track(id = "s1")
|
||||
|
||||
@@ -190,4 +190,34 @@ class ScrobblerTest {
|
||||
enabled.value = true
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +506,7 @@ class AppScreenshotTest {
|
||||
onPlay = {},
|
||||
onRemove = {},
|
||||
onReorder = { _, _ -> },
|
||||
onMove = { _, _ -> },
|
||||
onMove = { _, _ -> true },
|
||||
onLoadOlder = {},
|
||||
onLoadNewer = {},
|
||||
onUndo = {},
|
||||
|
||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
@@ -55,4 +56,33 @@ class ViewportFillTest {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -325,6 +325,42 @@ class QueueViewModelTest {
|
||||
assertEquals((220L..399L).toList(), rowsSnapshot().map { it.position })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoringASecondMoveWhileTheFirstIsStillCommitting() {
|
||||
runBlocking { seedSongs(10) }
|
||||
controller.playAlbum(1, "al1", 0)
|
||||
await { controller.state.value.position == 0L }
|
||||
|
||||
viewModel.open()
|
||||
await { viewModel.ready }
|
||||
val before = rowsSnapshot().map { it.song.song.id }
|
||||
val from = rowsSnapshot().first().position
|
||||
|
||||
runOnMain {
|
||||
assertTrue(viewModel.move(from, from + 3))
|
||||
assertFalse(viewModel.move(from, from + 1))
|
||||
}
|
||||
|
||||
val expected = before.toMutableList().also { it.add(3, it.removeAt(0)) }
|
||||
await { rowsSnapshot().map { it.song.song.id } == expected }
|
||||
assertEquals(expected, rowsSnapshot().map { it.song.song.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun dropMoveTakesTheDraggedRowsCurrentPosition() {
|
||||
val rows =
|
||||
listOf(
|
||||
row(10).copy(id = 2L),
|
||||
row(11).copy(id = 3L),
|
||||
row(9).copy(id = 1L),
|
||||
)
|
||||
|
||||
val move = rows.dropMoveFor(1L)!!
|
||||
|
||||
assertEquals(9L, move.from)
|
||||
assertEquals(11L, move.to)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removingATrackUnderTheBlockReloadsRows() {
|
||||
runBlocking { seedSongs(10) }
|
||||
|
||||
@@ -156,7 +156,14 @@ class AddSourceViewModelTest {
|
||||
|
||||
private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
|
||||
val syncManager =
|
||||
SyncManager(db, sourceRepository, QueueRepository(db), NetworkServerActionSink(sourceRepository), scope = repoScope)
|
||||
SyncManager(
|
||||
db,
|
||||
sourceRepository,
|
||||
QueueRepository(db),
|
||||
NetworkServerActionSink(sourceRepository),
|
||||
preferences = fakeUserPreferences(),
|
||||
scope = repoScope,
|
||||
)
|
||||
val resources = ApplicationProvider.getApplicationContext<Context>().resources
|
||||
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ app/src/main/kotlin/com/subtracks
|
||||
repo/ SourceRepository, LibraryRepository
|
||||
source/ MusicSource abstraction
|
||||
subsonic/ SubsonicClient, SubsonicXml, SubsonicSource
|
||||
sync/ SyncService (stream + per-batch writes + prune), SyncManager (status)
|
||||
sync/ SyncService (stream + per-batch writes + prune), SyncManager (status), AutoSync (foreground timer), SyncNotifier
|
||||
```
|
||||
|
||||
## Layers
|
||||
@@ -65,12 +65,12 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic)
|
||||
- The remote side sits behind `MusicSource` so additional backends can be added later.
|
||||
- `SourceRepository` owns the configured servers and builds a `SubsonicSource` from the active one; it also serves cover-art URLs. `LibraryRepository` reads the local mirror.
|
||||
- The local side is Room; reads are `Flow`s, so the UI updates as sync writes.
|
||||
- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow<SyncStatus>`; `SyncService` does the actual work.
|
||||
- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow<SyncStatus>`; `SyncService` does the actual work. `AutoSync` runs a foreground-only timer that asks `SyncManager` for a silent sync once the configured interval has elapsed.
|
||||
- ViewModels are wired with Koin; screens are split into a stateful `*Route` (default `koinViewModel()`) and a stateless `*Screen` so the latter can be screenshot-tested directly.
|
||||
|
||||
## UI shell
|
||||
|
||||
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
|
||||
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The library syncs on pull-to-refresh; the pull indicator holds only while a manual sync runs, and a spinner rings the Settings action whenever any sync is running (an automatic sync shows only the spinner, plus a notification). There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
|
||||
|
||||
The Albums tab is a three-column grid of covers only. Sorting and filtering hang off a FAB, and Settings holds the server list and the network, playback, download and about sections rather than list sort or sync.
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -116,7 +116,9 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
|
||||
|
||||
## Sync
|
||||
|
||||
`SyncService.sync()` is serialized by `SyncManager`, which coalesces duplicate requests. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against.
|
||||
`SyncService.sync()` is serialized by `SyncManager`, which coalesces a request that arrives while one is already running into a single follow-up. The library and Home sync actions are no-ops while a sync runs (so repeated pull-to-refresh cannot pile up runs), while a programmatic request such as adding a server still gets its follow-up. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against.
|
||||
|
||||
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
|
||||
|
||||
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose",
|
||||
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
||||
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-database = { group = "androidx.media3", name = "media3-database", 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" }
|
||||
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