mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
Stores: stages named for what they do, both sizes, Cancel deletes
Epic's check of the files already there belongs to Manifest (Checking n/N), not a Verify at 100% before anything is fetched. Install measures its own write rate and ETA from the assembler's bytes and shows files n/N. A row shows the download as fetched (compressed) against its total, and the size on disk beside the location; Epic keeps the disk size for the card and page. Cancel asks twice and removes everything the install wrote - folder, sidecar, chunk caches, scratch - unless the folder is a finished install being repaired, which keeps itself; the row reads Cancelled for three seconds, then goes. Pause keeps everything. A failed row reads Failed with what is kept and offers Resume and Clear, Clear deleting as Cancel does; the game page offers Resume install and Clear for an unfinished install. Library sync is a thin bar and its count.
This commit is contained in:
1 parent
a5f608b9d7
commit
cb2f7c0109
14 files changed
+171
-57
No files matched your search
@@ -77,6 +77,23 @@ object StoreInstalls {
|
||||
StoresState.logLine("uninstalled \"${game.sidecar.title}\"")
|
||||
}
|
||||
|
||||
/**
|
||||
* What Cancel does: everything the install wrote goes - the game folder with its sidecar and
|
||||
* the caches in [caches] - unless the folder holds a finished install being repaired or updated;
|
||||
* then only the caches go and the working game stays. Off the caller's thread; the library
|
||||
* follows when it is done, so the game reads Install again.
|
||||
*/
|
||||
fun discard(context: Context, folder: File, caches: List<File>) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
val keep = StoreGameSidecar.read(folder)?.isInstalled == true
|
||||
caches.forEach { deleteTree(it) }
|
||||
if (!keep) deleteTree(folder)
|
||||
StoresState.logLine(if (keep) "removed the partial update of ${folder.name}" else "removed ${folder.name}")
|
||||
StoresState.notifyLibraryChanged(app)
|
||||
}, "store-cancel-clean").start()
|
||||
}
|
||||
|
||||
fun deleteTree(dir: File) {
|
||||
dir.listFiles()?.forEach { if (it.isDirectory) deleteTree(it) else it.delete() }
|
||||
dir.delete()
|
||||
|
||||
@@ -98,6 +98,17 @@ object StoresState {
|
||||
it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id)))
|
||||
}
|
||||
|
||||
/** Deletes an unfinished install's folder and caches, as Cancel would; drops its row if there is one. */
|
||||
fun clearUnfinished(context: Context, item: CatalogItem) {
|
||||
val app = context.applicationContext
|
||||
val key = "${item.store.id}:${item.id}"
|
||||
if (com.droiddeck.launcher.stores.download.DownloadQueue.entry(key) != null) { com.droiddeck.launcher.stores.download.DownloadQueue.clear(app, key); return }
|
||||
val folder = unfinished.firstOrNull {
|
||||
it.store == item.store && (it.id == item.id || (it.id == null && it.folder.name == StoreInstallRoot.folderName(item.title, item.id)))
|
||||
}?.folder ?: return
|
||||
StoreInstalls.discard(app, folder, listOf(java.io.File(folder, ".chunks"), java.io.File(folder, ".gog_chunks"), StoreInstallRoot.scratchDir(app, item.store, item.id)))
|
||||
}
|
||||
|
||||
fun installedGame(store: Store, id: String): InstalledStoreGame? = installed.firstOrNull { it.sidecar.store == store && it.sidecar.id == id }
|
||||
|
||||
/** The user's library and the shelves for [store], from the cache first and the network when stale. */
|
||||
|
||||
@@ -127,7 +127,7 @@ object AmazonBackend : StoreBackend {
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder) }, "amazon-cancel-clean").start()
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, emptyList())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -52,6 +52,8 @@ data class DownloadEntry(
|
||||
val stageTotal: Long = 0L,
|
||||
val stageItems: Int = 0,
|
||||
val stageItemsTotal: Int = 0,
|
||||
/** What the game takes on disk, when the store has said (the download itself is [bytesTotal], often compressed). */
|
||||
val diskBytes: Long = 0L,
|
||||
/** Stages already passed, one bit per [DownloadStage] ordinal; stages can come back (Epic checks files before it fetches). */
|
||||
val stagesPassed: Int = 0,
|
||||
) {
|
||||
|
||||
@@ -35,10 +35,13 @@ object DownloadQueue {
|
||||
val isCancelled: Boolean get() = cancelled.get()
|
||||
/** Enters [stage] (a repeat of the current one only updates the line); a new stage starts its own count from zero. */
|
||||
fun stage(stage: DownloadStage, detail: String = "") = enter(key, stage, stripSpeed(detail))
|
||||
/** The active stage's own count outside Download: an amount (bytes or items) and, when it counts files, the files. */
|
||||
fun stageProgress(done: Long, total: Long, items: Int = 0, itemsTotal: Int = 0) = update(key) {
|
||||
it.copy(stageDone = done, stageTotal = total, stageItems = items, stageItemsTotal = itemsTotal)
|
||||
}
|
||||
/**
|
||||
* The active stage's own count outside Download: an amount and, when it counts files, the
|
||||
* files. [bytes] = the amount is bytes written, so the stage gets its own rate and ETA.
|
||||
*/
|
||||
fun stageProgress(done: Long, total: Long, items: Int = 0, itemsTotal: Int = 0, bytes: Boolean = false) = stageCount(key, done, total, items, itemsTotal, bytes)
|
||||
/** The game's size on disk, once the store has said. */
|
||||
fun diskSize(bytes: Long) = update(key) { if (bytes > 0) it.copy(diskBytes = bytes) else it }
|
||||
/**
|
||||
* Bytes so far (negative = unchanged), the total (<= 0 = unchanged), a line. [speedBps] is the
|
||||
* engine's own figure: a burst rate at the network side, which jumps while the write side
|
||||
@@ -79,6 +82,34 @@ object DownloadQueue {
|
||||
/** How far back the shown speed looks: a few seconds, so the figure settles instead of following each file. */
|
||||
private const val SPEED_WINDOW_MS = 3000.0
|
||||
|
||||
private fun stageCount(key: String, done: Long, total: Long, files: Int, filesTotal: Int, bytes: Boolean) {
|
||||
synchronized(lock) {
|
||||
val item = items[key] ?: return
|
||||
val e = item.entry
|
||||
var speed = e.speedBps
|
||||
if (bytes) speed = sample(item, done)
|
||||
val eta = if (bytes && speed > 0 && total > done) (total - done) / speed else -1L
|
||||
item.entry = e.copy(stageDone = done, stageTotal = total, stageItems = files, stageItemsTotal = filesTotal, speedBps = if (bytes) speed else 0, etaSeconds = eta)
|
||||
publishLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/** One sample of the rate behind [done] (bytes so far in this stage), smoothed over [SPEED_WINDOW_MS]. Caller holds [lock]. */
|
||||
private fun sample(item: Item, done: Long): Long {
|
||||
val now = System.currentTimeMillis()
|
||||
if (item.speedAt == 0L) { item.speedAt = now; item.speedBytes = done; return item.speedEwma.toLong() }
|
||||
val dt = now - item.speedAt
|
||||
// Sampled no faster than every quarter second, as an exponential average over
|
||||
// SPEED_WINDOW_MS: one big file landing moves it, it does not define it.
|
||||
if (dt >= 250) {
|
||||
val inst = (done - item.speedBytes).coerceAtLeast(0) * 1000.0 / dt
|
||||
val alpha = 1.0 - Math.exp(-dt / SPEED_WINDOW_MS)
|
||||
item.speedEwma = if (item.speedEwma <= 0.0) inst else item.speedEwma + (inst - item.speedEwma) * alpha
|
||||
item.speedAt = now; item.speedBytes = done
|
||||
}
|
||||
return item.speedEwma.toLong()
|
||||
}
|
||||
|
||||
private fun measure(key: String, bytesDone: Long, bytesTotal: Long, detail: String?) {
|
||||
synchronized(lock) {
|
||||
val item = items[key] ?: return
|
||||
@@ -86,21 +117,7 @@ object DownloadQueue {
|
||||
val e = item.entry
|
||||
val done = if (bytesDone >= 0) bytesDone else e.bytesDone
|
||||
val total = if (bytesTotal > 0) bytesTotal else e.bytesTotal
|
||||
val now = System.currentTimeMillis()
|
||||
if (bytesDone >= 0) {
|
||||
if (item.speedAt == 0L) { item.speedAt = now; item.speedBytes = done }
|
||||
else {
|
||||
val dt = now - item.speedAt
|
||||
// Sampled no faster than every quarter second, as an exponential average over
|
||||
// SPEED_WINDOW_MS: one big file landing moves it, it does not define it.
|
||||
if (dt >= 250) {
|
||||
val inst = (done - item.speedBytes).coerceAtLeast(0) * 1000.0 / dt
|
||||
val alpha = 1.0 - Math.exp(-dt / SPEED_WINDOW_MS)
|
||||
item.speedEwma = if (item.speedEwma <= 0.0) inst else item.speedEwma + (inst - item.speedEwma) * alpha
|
||||
item.speedAt = now; item.speedBytes = done
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bytesDone >= 0) sample(item, done)
|
||||
val speed = item.speedEwma.toLong()
|
||||
val eta = if (speed > 0 && total > done) (total - done) / speed else -1L
|
||||
item.entry = e.copy(state = DownloadState.RUNNING, stage = DownloadStage.DOWNLOAD, bytesDone = done, bytesTotal = total, detail = detail ?: e.detail, speedBps = speed, etaSeconds = eta)
|
||||
@@ -198,6 +215,22 @@ object DownloadQueue {
|
||||
StoresState.logLine("cancelled \"${item.entry.name}\"")
|
||||
StoreDownloadService.finish(app, key)
|
||||
advance(app)
|
||||
// The row says Cancelled for a moment, then goes; the game reads Install again.
|
||||
Thread({ Thread.sleep(3000); synchronized(lock) { if (items[key]?.entry?.state == DownloadState.CANCELLED) { items.remove(key); publishLocked() } } }, "store-dl-cancelled").start()
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear on a failed or paused row: deletes what it kept exactly as Cancel does (the job's own
|
||||
* cleanup), then drops the row. An installed or cancelled row is only dropped.
|
||||
*/
|
||||
fun clear(context: Context, key: String) {
|
||||
val item = synchronized(lock) { items[key] } ?: return
|
||||
if (item.entry.state == DownloadState.RUNNING || item.entry.state == DownloadState.QUEUED) return
|
||||
if (item.entry.state == DownloadState.FAILED || item.entry.state == DownloadState.PAUSED) {
|
||||
Thread({ runCatching { item.factory().cancel(deleteFiles = true) } }, "store-dl-clear").start()
|
||||
}
|
||||
synchronized(lock) { items.remove(key); publishLocked() }
|
||||
StoreDownloadService.finish(context.applicationContext, key)
|
||||
}
|
||||
|
||||
/** Drops a finished, failed or cancelled row from the list. */
|
||||
|
||||
@@ -121,15 +121,19 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
||||
when {
|
||||
message.startsWith("Writing") || message.startsWith("Complete") -> handle.stage(DownloadStage.INSTALL, message)
|
||||
message.startsWith("Downloading chunks") -> { if (!downloading) { downloading = true; handle.stage(DownloadStage.DOWNLOAD, message) } else handle.progress(-1, -1, message) }
|
||||
message.startsWith("Verifying") -> handle.stage(DownloadStage.VERIFY, message)
|
||||
message.startsWith("Checking") -> handle.stage(DownloadStage.MANIFEST, message)
|
||||
else -> handle.stage(DownloadStage.MANIFEST, message)
|
||||
}
|
||||
}
|
||||
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
|
||||
override fun onLog(line: String) { handle.log(line) }
|
||||
override fun onSizes(downloadBytes: Long, diskBytes: Long) {
|
||||
handle.diskSize(diskBytes)
|
||||
EpicLibrary.rememberSize(app, item.id, diskBytes)
|
||||
}
|
||||
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
|
||||
handle.stage(if (stage == "verify") DownloadStage.VERIFY else DownloadStage.INSTALL)
|
||||
handle.stageProgress(done, total, items, itemsTotal)
|
||||
handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL })
|
||||
handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install")
|
||||
}
|
||||
}) ?: return null
|
||||
if (cancelled.get()) return null
|
||||
@@ -151,7 +155,7 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder); scratch?.let { StoreInstalls.deleteTree(it) } }, "epic-cancel-clean").start()
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, listOfNotNull(File(folder, ".chunks"), scratch, StoreInstallRoot.scratchDir(app, Store.EPIC, item.id)))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,11 +57,14 @@ public final class EpicDownloadManager {
|
||||
default void onBytes(long done, long total, long speedBps) {}
|
||||
default void onLog(String line) {}
|
||||
/**
|
||||
* The active stage's own count: {@code stage} is "verify" or "install", {@code done} of
|
||||
* The active stage's own count: {@code stage} is "check" (the files already there, before
|
||||
* anything is fetched), "verify" or "install" (bytes written), {@code done} of
|
||||
* {@code total} an amount (bytes, or items when there are no bytes), {@code items} of
|
||||
* {@code itemsTotal} the files, when it counts files.
|
||||
*/
|
||||
default void onStage(String stage, long done, long total, int items, int itemsTotal) {}
|
||||
/** What this run fetches (compressed) and what the game takes on disk. */
|
||||
default void onSizes(long downloadBytes, long diskBytes) {}
|
||||
}
|
||||
|
||||
private EpicDownloadManager() {}
|
||||
@@ -157,19 +160,19 @@ public final class EpicDownloadManager {
|
||||
final long planned = installBytes;
|
||||
// Free space is checked after the delta pass, against what is still missing (below).
|
||||
|
||||
cb.onProgress("Verifying existing files…", 0);
|
||||
cb.onProgress("Checking files…", 0);
|
||||
List<FileInfo> pending = new ArrayList<>(selected.size());
|
||||
int checked = 0, good = 0;
|
||||
long checkedBytes = 0;
|
||||
cb.onStage("verify", 0, installBytes, 0, selected.size());
|
||||
cb.onStage("check", 0, installBytes, 0, selected.size());
|
||||
for (FileInfo f : selected) {
|
||||
if (cancel.get()) return null;
|
||||
File out = new File(installDir, f.filename.replace("\\", "/"));
|
||||
if (fileExistsWithCorrectHash(out, f.fileSize(), f.sha1)) good++; else pending.add(f);
|
||||
checked++;
|
||||
checkedBytes += f.fileSize();
|
||||
if ((checked & 15) == 0 || checked == selected.size()) cb.onStage("verify", checkedBytes, installBytes, checked, selected.size());
|
||||
if ((checked & 63) == 0) cb.onProgress("Verifying existing files… (" + checked + "/" + selected.size() + ")", 0);
|
||||
if ((checked & 15) == 0 || checked == selected.size()) cb.onStage("check", checkedBytes, installBytes, checked, selected.size());
|
||||
if ((checked & 63) == 0) cb.onProgress("Checking files… (" + checked + "/" + selected.size() + ")", 0);
|
||||
}
|
||||
cb.onLog("epic: delta " + good + " up to date, " + pending.size() + " to download");
|
||||
if (pending.isEmpty()) {
|
||||
@@ -204,6 +207,7 @@ public final class EpicDownloadManager {
|
||||
final AtomicLong lastSpeedMs = new AtomicLong(System.currentTimeMillis());
|
||||
final AtomicLong lastSpeedBytes = new AtomicLong(0);
|
||||
final AtomicLong speedBps = new AtomicLong(0);
|
||||
cb.onSizes(fTotalBytes, planned);
|
||||
cb.onBytes(0, fTotalBytes, 0);
|
||||
cb.onLog("epic: " + fmt(fTotalBytes) + " in " + totalChunks + " chunks");
|
||||
|
||||
|
||||
@@ -96,8 +96,8 @@ object GogBackend : StoreBackend {
|
||||
override fun onBytes(done: Long, total: Long, speedBps: Long) { handle.progress(done, total, null, speedBps) }
|
||||
override fun onLog(line: String) { handle.log(line) }
|
||||
override fun onStage(stage: String, done: Long, total: Long, items: Int, itemsTotal: Int) {
|
||||
handle.stage(if (stage == "verify") DownloadStage.VERIFY else DownloadStage.INSTALL)
|
||||
handle.stageProgress(done, total, items, itemsTotal)
|
||||
handle.stage(when (stage) { "check" -> DownloadStage.MANIFEST; "verify" -> DownloadStage.VERIFY; else -> DownloadStage.INSTALL })
|
||||
handle.stageProgress(done, total, items, itemsTotal, bytes = stage == "install")
|
||||
}
|
||||
}, cancelled) ?: return null
|
||||
if (cancelled.get()) return null
|
||||
@@ -114,7 +114,7 @@ object GogBackend : StoreBackend {
|
||||
|
||||
override fun cancel(deleteFiles: Boolean) {
|
||||
cancelled.set(true)
|
||||
if (deleteFiles) Thread({ StoreInstalls.deleteTree(folder) }, "gog-cancel-clean").start()
|
||||
if (deleteFiles) StoreInstalls.discard(app, folder, listOf(File(folder, ".gog_chunks")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -903,7 +903,7 @@ public final class GogDownloadManager {
|
||||
for (File f : files) {
|
||||
deleteDir(f);
|
||||
done++;
|
||||
if ((done & 31) == 0 || done == total) cb.onStage("install", done, total, done, total);
|
||||
if ((done & 31) == 0 || done == total) cb.onStage("cleanup", done, total, done, total);
|
||||
}
|
||||
dir.delete();
|
||||
}
|
||||
|
||||
@@ -88,12 +88,15 @@ internal fun StoreGameDetail(store: Store, key: String, s: FrontEndState, a: Fro
|
||||
}
|
||||
}
|
||||
download != null -> {
|
||||
ActionChip(downloadLabel(download.state, download.stage, download.percent), ok = false)
|
||||
ActionChip(downloadLabel(download), ok = false)
|
||||
if (download.state != DownloadState.PAUSED) SecondaryButton(stringResource(R.string.stores_dl_pause)) { com.droiddeck.launcher.stores.download.DownloadQueue.pause(download.key) }
|
||||
else PrimaryButton(stringResource(R.string.stores_dl_resume), main = true) { com.droiddeck.launcher.stores.download.DownloadQueue.resume(ctx, download.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_cancel)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm)) { com.droiddeck.launcher.stores.download.DownloadQueue.cancel(ctx, download.key) }
|
||||
}
|
||||
item?.owned == true && StoresState.isUnfinished(item) -> {
|
||||
PrimaryButton(stringResource(R.string.stores_resume_install), main = true) { StoresState.requestInstall(ctx, item) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { StoresState.clearUnfinished(ctx, item) }
|
||||
}
|
||||
item?.owned == true && StoresState.isUnfinished(item) -> PrimaryButton(stringResource(R.string.stores_resume_install), main = true) { StoresState.requestInstall(ctx, item) }
|
||||
item?.owned == true -> PrimaryButton(
|
||||
if (item.sizeBytes > 0) stringResource(R.string.stores_install_size, formatBytes(item.sizeBytes)) else stringResource(R.string.stores_install), main = true,
|
||||
) { StoresState.requestInstall(ctx, item) }
|
||||
|
||||
@@ -98,18 +98,20 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(d.name, fontSize = 14.sp, fontWeight = FontWeight.Bold, color = ink, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
SourceChip(d.store.id, small = true)
|
||||
if (d.location.isNotBlank()) Text(d.location, fontSize = 11.sp, color = dim, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
val where = listOfNotNull(d.location.takeIf { it.isNotBlank() }, d.diskBytes.takeIf { it > 0 }?.let { formatBytes(it) }).joinToString(" · ")
|
||||
if (where.isNotEmpty()) Text(where, fontSize = 11.sp, color = dim, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
StageBar(d)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
val meta = when (d.state) {
|
||||
DownloadState.INSTALLED -> stringResource(R.string.stores_installed_chip)
|
||||
DownloadState.CANCELLED -> stringResource(R.string.stores_dl_cancelled)
|
||||
DownloadState.FAILED -> d.error?.let { stringResource(R.string.stores_dl_failed_reason, it) } ?: stringResource(R.string.stores_dl_failed)
|
||||
// The reason is in the engine log; the row only says what is kept for Resume.
|
||||
DownloadState.FAILED -> if (d.bytesDone > 0) stringResource(R.string.stores_dl_failed_kept, formatBytes(d.bytesDone)) else stringResource(R.string.stores_dl_failed)
|
||||
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
|
||||
DownloadState.QUEUED -> if (d.queuePosition > 0) stringResource(R.string.stores_dl_queued_at, d.queuePosition) else stringResource(R.string.stores_dl_queued)
|
||||
DownloadState.RUNNING -> {
|
||||
val head = if (d.stageFraction >= 0f) stringResource(R.string.stores_dl_active_percent, activeStageLabel(d.stage), d.percent) else activeStageLabel(d.stage)
|
||||
val head = downloadLabel(d)
|
||||
when {
|
||||
d.stage == DownloadStage.DOWNLOAD && d.bytesTotal > 0 -> stringResource(R.string.stores_dl_with, head, stringResource(R.string.stores_dl_amount, formatBytes(d.bytesDone), formatBytes(d.bytesTotal)))
|
||||
d.stageItemsTotal > 0 -> stringResource(R.string.stores_dl_with, head, stringResource(R.string.stores_dl_items, d.stageItems, d.stageItemsTotal))
|
||||
@@ -118,7 +120,7 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
|
||||
}
|
||||
}
|
||||
Text(meta, fontSize = 12.sp, color = dim, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false))
|
||||
if (d.state == DownloadState.RUNNING && d.stage == DownloadStage.DOWNLOAD && d.speedBps > 0) {
|
||||
if (d.state == DownloadState.RUNNING && (d.stage == DownloadStage.DOWNLOAD || d.stage == DownloadStage.INSTALL) && d.speedBps > 0) {
|
||||
Text(formatSpeed(d.speedBps), fontSize = 12.sp, color = dim, maxLines = 1)
|
||||
if (d.etaSeconds >= 0) Text(eta(d.etaSeconds), fontSize = 12.sp, color = dim, maxLines = 1)
|
||||
}
|
||||
@@ -129,15 +131,15 @@ private fun DownloadCard(d: DownloadEntry, s: FrontEndState, a: FrontEndActions)
|
||||
when (d.state) {
|
||||
DownloadState.RUNNING, DownloadState.QUEUED -> {
|
||||
SecondaryButton(stringResource(R.string.stores_dl_pause), compact = true) { DownloadQueue.pause(d.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.cancel(ctx, d.key) }
|
||||
}
|
||||
DownloadState.PAUSED -> {
|
||||
PrimaryButton(stringResource(R.string.stores_dl_resume), compact = true) { DownloadQueue.resume(ctx, d.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_cancel), compact = true) { DownloadQueue.cancel(ctx, d.key) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_cancel), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.cancel(ctx, d.key) }
|
||||
}
|
||||
DownloadState.FAILED -> {
|
||||
PrimaryButton(stringResource(R.string.stores_dl_retry), compact = true) { DownloadQueue.retry(ctx, d.key) }
|
||||
SecondaryButton(stringResource(R.string.stores_dl_clear), compact = true) { DownloadQueue.dismiss(d.key) }
|
||||
PrimaryButton(stringResource(R.string.stores_dl_resume), compact = true) { DownloadQueue.retry(ctx, d.key) }
|
||||
ConfirmButton(stringResource(R.string.stores_dl_clear), stringResource(R.string.stores_dl_cancel_confirm), compact = true) { DownloadQueue.clear(ctx, d.key) }
|
||||
}
|
||||
DownloadState.INSTALLED -> {
|
||||
PrimaryButton(stringResource(R.string.stores_play), compact = true, icon = Icons.Filled.PlayArrow) { launchStoreGame(ctx, d.store, d.id, s, a) }
|
||||
@@ -175,15 +177,6 @@ private fun StageBar(d: DownloadEntry) {
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun activeStageLabel(stage: DownloadStage): String = when (stage) {
|
||||
DownloadStage.MANIFEST -> stringResource(R.string.stores_stage_manifest)
|
||||
DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_downloading)
|
||||
DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verifying)
|
||||
DownloadStage.INSTALL -> stringResource(R.string.stores_stage_installing)
|
||||
DownloadStage.DONE -> stringResource(R.string.stores_stage_done)
|
||||
}
|
||||
|
||||
private fun eta(seconds: Long): String = when {
|
||||
seconds < 60 -> "<1 min"
|
||||
seconds < 3600 -> "~${seconds / 60} min"
|
||||
|
||||
@@ -312,7 +312,7 @@ private fun Storefront(
|
||||
},
|
||||
)
|
||||
}
|
||||
StoresState.status[store]?.let { Rise(3) { Text(it, fontSize = 12.sp, color = colors.onSurfaceVariant, modifier = Modifier.padding(bottom = 6.dp)) } }
|
||||
StoresState.status[store]?.let { line -> Rise(3) { SyncBar(line) } }
|
||||
StoresState.problems[store]?.let { Rise(3) { Box(Modifier.padding(bottom = 8.dp)) { Note(it) } } }
|
||||
// The first card drawn on the tab is where a pad lands after a tab or chip change.
|
||||
var firstPlaced = false
|
||||
@@ -452,6 +452,21 @@ private fun GameCard(item: CatalogItem, store: Store, s: FrontEndState, a: Front
|
||||
}
|
||||
}
|
||||
|
||||
/** A store's sync as a thin bar and its count ("25/55"); a moving bar when it has no count yet. */
|
||||
@Composable
|
||||
private fun SyncBar(line: String) {
|
||||
val count = Regex("(\\d+)\\s*/\\s*(\\d+)").find(line)
|
||||
val done = count?.groupValues?.get(1)?.toIntOrNull()
|
||||
val total = count?.groupValues?.get(2)?.toIntOrNull()?.takeIf { it > 0 }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp)) {
|
||||
Box(Modifier.weight(1f)) {
|
||||
if (done != null && total != null) androidx.compose.material3.LinearProgressIndicator(progress = { (done.toFloat() / total).coerceIn(0f, 1f) }, modifier = Modifier.fillMaxWidth().height(3.dp))
|
||||
else androidx.compose.material3.LinearProgressIndicator(modifier = Modifier.fillMaxWidth().height(3.dp))
|
||||
}
|
||||
if (done != null && total != null) Text("$done/$total", fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1)
|
||||
}
|
||||
}
|
||||
|
||||
/** How tall the card's one line is, over the blurred foot of its art. */
|
||||
private val CardStrip = 28.dp
|
||||
|
||||
@@ -535,10 +550,28 @@ internal fun ProgressBarThin(fraction: Float, paused: Boolean = false, verify: B
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun downloadLabel(state: DownloadState, stage: DownloadStage, percent: Int): String = when (state) {
|
||||
internal fun downloadLabel(d: com.droiddeck.launcher.stores.download.DownloadEntry): String = when (d.state) {
|
||||
DownloadState.PAUSED -> stringResource(R.string.stores_dl_paused)
|
||||
DownloadState.QUEUED -> stringResource(R.string.stores_dl_queued)
|
||||
else -> stringResource(R.string.stores_dl_stage_percent, stageLabel(stage), percent)
|
||||
else -> if (d.stageFraction >= 0f) stringResource(R.string.stores_dl_active_percent, activeStageLabel(d), d.percent) else activeStageLabel(d)
|
||||
}
|
||||
|
||||
/** The active stage as the row and the page name it: Checking (files already there), Downloading, Verifying, Installing. */
|
||||
@Composable
|
||||
internal fun activeStageLabel(d: com.droiddeck.launcher.stores.download.DownloadEntry): String = when (d.stage) {
|
||||
DownloadStage.MANIFEST -> if (d.stageItemsTotal > 0) stringResource(R.string.stores_stage_checking) else stringResource(R.string.stores_stage_manifest)
|
||||
DownloadStage.DOWNLOAD -> stringResource(R.string.stores_stage_downloading)
|
||||
DownloadStage.VERIFY -> stringResource(R.string.stores_stage_verifying)
|
||||
DownloadStage.INSTALL -> stringResource(R.string.stores_stage_installing)
|
||||
DownloadStage.DONE -> stringResource(R.string.stores_stage_done)
|
||||
}
|
||||
|
||||
/** Two taps: the first arms it ([confirm] shows), the second acts; it disarms itself after a few seconds. */
|
||||
@Composable
|
||||
internal fun ConfirmButton(label: String, confirm: String, compact: Boolean = false, onConfirm: () -> Unit) {
|
||||
var armed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(armed) { if (armed) { kotlinx.coroutines.delay(4000); armed = false } }
|
||||
SecondaryButton(if (armed) confirm else label, compact = compact) { if (!armed) armed = true else { armed = false; onConfirm() } }
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
||||
@@ -1991,7 +1991,7 @@
|
||||
<string name="stores_search_library">Search your library…</string>
|
||||
<string name="stores_search_all">Search everything on %1$s…</string>
|
||||
<string name="stores_installed_empty">Nothing installed</string>
|
||||
<string name="stores_library_loading">Fetching your library…</string>
|
||||
<string name="stores_library_loading">Loading</string>
|
||||
<string name="stores_nothing_matches">No games</string>
|
||||
<string name="stores_shelves_loading">Loading the store…</string>
|
||||
<string name="stores_amazon_no_catalog">No catalog</string>
|
||||
@@ -2062,7 +2062,10 @@
|
||||
<string name="stores_stage_verify">Verify</string>
|
||||
<string name="stores_stage_install">Install</string>
|
||||
<string name="stores_stage_done">Done</string>
|
||||
<string name="stores_stage_checking">Checking</string>
|
||||
<string name="stores_stage_downloading">Downloading</string>
|
||||
<string name="stores_dl_cancel_confirm">Delete download?</string>
|
||||
<string name="stores_dl_failed_kept">Failed · %1$s kept</string>
|
||||
<string name="stores_stage_verifying">Verifying</string>
|
||||
<string name="stores_stage_installing">Installing</string>
|
||||
<string name="stores_dl_active_percent">%1$s %2$d%%</string>
|
||||
|
||||
@@ -135,6 +135,17 @@ without it and Setup says "Stores engine not built").
|
||||
when installed, Resume, the download percentage, the install size, or Free / price / discount.
|
||||
A thin bar over the art while downloading. Install, Resume, Uninstall, Get and Buy live on the
|
||||
game page; A on a card opens it.
|
||||
- **Stages named for what they do; both sizes; Cancel deletes.** Epic's check of the files already
|
||||
there is part of Manifest ("Checking 120/515"), never a 100% Verify before anything is fetched.
|
||||
Install has its own write rate and ETA from the bytes the assembler writes, and files n/N. The
|
||||
row shows the download (compressed) as "1.6 GB / 2.5 GB" and the size on disk beside the
|
||||
location; Epic remembers the disk size for the card and page. Cancel asks twice ("Delete
|
||||
download?") and removes everything the install wrote - folder, sidecar, `.chunks`, scratch,
|
||||
`.gog_chunks` - except a finished install being repaired, which keeps itself; the row says
|
||||
Cancelled for three seconds, then goes, and the game reads Install. Pause keeps everything. A
|
||||
failed row reads "Failed · 2.1 GB kept" (the reason is in the log) with Resume and Clear (Clear
|
||||
deletes like Cancel); the game page offers Resume install and Clear for an unfinished install.
|
||||
Library sync is a thin bar and "25/55", no sentence.
|
||||
- **Per-stage progress** (device: Epic DOOMBLADE, 13 GB, ~3 min of assembly with a still bar). The
|
||||
entry carries the active stage's own count (amount + files) and which stages are passed; the
|
||||
Downloads row draws one segment per stage (Manifest, Download, Verify, Install), and its line
|
||||
|
||||
Reference in new issue
Block a user