add google photos support & fix preview

This commit is contained in:
iceBear67
2026-08-08 10:26:17 +00:00
parent b0a23544e3
commit 0108def92d
26 changed files with 2068 additions and 51 deletions
@@ -37,6 +37,26 @@ import java.util.concurrent.CompletableFuture;
@Slf4j
public final class ThumbnailCache implements AutoCloseable {
/**
* How many times one asset's thumbnail is fetched before the tile settles
* for its blur.
*
* <p>The first attempt fails for all the ordinary reasons -- the server was
* restarting, the connection dropped, the loader handed back a truncated
* body -- and giving up on it permanently leaves a tile that is blurred for
* the rest of the session with nothing to say why. Three attempts covers
* the transient cases; past that the failure is real and is drawn as one.
*/
private static final int MAX_ATTEMPTS = 3;
/**
* Frames to wait before trying again, multiplied by the attempt number.
* Long enough that a server that is down is not hammered by every tile on
* screen, short enough that the grid repairs itself while the player is
* still looking at it.
*/
private static final int RETRY_FRAMES = 40;
/** What to draw for one asset, and whether it is the real thing yet. */
public record Thumbnail(TextureHandle texture, boolean placeholder) {
}
@@ -89,10 +109,25 @@ public final class ThumbnailCache implements AutoCloseable {
return entry.thumbnail();
}
/** Whether this asset's thumbnail failed outright, so the grid can mark it. */
/**
* Starts an asset's fetch without drawing it, for a tile that is about to
* scroll into view.
*
* <p>Refuses once the cache is full, so lookahead can never evict a tile the
* player is actually looking at: the visible tiles are asked for first, and
* whatever room is left over is what the band gets.
*/
public void prefetch(RemoteAsset asset) {
if (!entries.containsKey(asset.id()) && entries.size() >= capacity) {
return;
}
of(asset);
}
/** Whether this asset's thumbnail failed for good, so the grid can mark it. */
public boolean failed(String assetId) {
Entry entry = entries.get(assetId);
return entry != null && entry.failed;
return entry != null && entry.exhausted();
}
/** Call at the end of a frame, once every visible tile has been asked for. */
@@ -132,7 +167,8 @@ public final class ThumbnailCache implements AutoCloseable {
private TextureHandle texture;
private boolean real;
private boolean failed;
private int attempts;
private long retryFrame;
private CompletableFuture<byte[]> pending;
private long touched;
@@ -145,12 +181,17 @@ public final class ThumbnailCache implements AutoCloseable {
return texture == null ? Optional.empty() : Optional.of(new Thumbnail(texture, !real));
}
/** Out of attempts: whatever is drawn now is what this tile gets. */
private boolean exhausted() {
return !real && attempts >= MAX_ATTEMPTS;
}
/**
* Advances this entry by whatever is available without blocking: start a
* request, or take delivery of one.
*/
private void poll() {
if (real || failed) {
if (real || exhausted() || frame < retryFrame) {
return;
}
if (pending == null) {
@@ -168,8 +209,10 @@ public final class ThumbnailCache implements AutoCloseable {
adopt(textures.decode(finished.join()));
real = true;
} catch (IOException | RuntimeException e) {
log.debug("Thumbnail {} is not drawable: {}", asset.id(), e.toString());
failed = true;
attempts++;
retryFrame = frame + (long) RETRY_FRAMES * attempts;
log.debug("Thumbnail {} is not drawable (attempt {} of {}): {}",
asset.id(), attempts, MAX_ATTEMPTS, e.toString());
}
}
@@ -42,6 +42,23 @@ public final class QueueScreen extends PhotoSyncScreen {
private static final int DETAIL_WIDTH = 140;
private static final int MIN_WIDTH_FOR_DETAIL = 340;
/**
* How many times a screenshot is read off disk before the pane gives up.
*
* <p>One attempt is not enough, and the reason is specific to what this list
* holds. A row can appear the instant a capture is queued, while the PNG
* behind it is still being flushed by the IO pool; the screenshots folder is
* on a network drive or a spinning disk often enough; and an antivirus that
* has the file open will refuse one read and allow the next. Failing those
* permanently leaves the newest screenshot -- the one at the top of the
* list, the one the player actually clicks -- showing "unavailable" for the
* rest of the session, with no way to ask again short of changing tabs.
*/
private static final int PREVIEW_ATTEMPTS = 3;
/** Milliseconds before the next attempt, multiplied by the attempt number. */
private static final long PREVIEW_RETRY_MILLIS = 600;
private final UploadQueue queue;
private final ScrollModel scroll;
private final Preview preview = new Preview();
@@ -223,7 +240,7 @@ public final class QueueScreen extends PhotoSyncScreen {
return;
}
UploadJob job = selection.get().job();
preview.follow(job.path());
preview.follow(job.path(), System.currentTimeMillis());
Rect inner = detailArea.inset(4);
int line = render.lineHeight() + 2;
@@ -366,20 +383,38 @@ public final class QueueScreen extends PhotoSyncScreen {
private Path source;
private CompletableFuture<byte[]> reading;
private TextureHandle texture;
private boolean failed;
private int attempts;
private long retryAtMillis;
/** Called every frame with the selected file; only acts when it changes. */
private void follow(Path path) {
private void follow(Path path, long now) {
if (!path.equals(source)) {
close();
source = path;
reading = CompletableFuture.supplyAsync(() -> readAll(path));
}
poll();
poll(now);
}
private void poll() {
if (reading == null || !reading.isDone()) {
/**
* Advances by whatever is available without blocking: start a read, or
* take delivery of one.
*
* <p>The read is started here rather than in {@link #follow} so that a
* retry needs no separate path -- an attempt that failed simply leaves
* nothing pending, and the next frame past the backoff starts another.
*/
private void poll(long now) {
if (texture != null || source == null) {
return;
}
if (reading == null) {
if (attempts < PREVIEW_ATTEMPTS && now >= retryAtMillis) {
Path path = source;
reading = CompletableFuture.supplyAsync(() -> readAll(path));
}
return;
}
if (!reading.isDone()) {
return;
}
CompletableFuture<byte[]> finished = reading;
@@ -387,8 +422,10 @@ public final class QueueScreen extends PhotoSyncScreen {
try {
texture = ui.bridge().textures().decode(finished.join());
} catch (IOException | RuntimeException e) {
log.debug("Cannot preview {}", source, e);
failed = true;
attempts++;
retryAtMillis = now + PREVIEW_RETRY_MILLIS * attempts;
log.debug("Cannot preview {} (attempt {} of {}): {}",
source, attempts, PREVIEW_ATTEMPTS, e.toString());
}
}
@@ -396,8 +433,9 @@ public final class QueueScreen extends PhotoSyncScreen {
return Optional.ofNullable(texture);
}
/** Out of attempts: this screenshot is not going to draw. */
private boolean failed() {
return failed;
return texture == null && attempts >= PREVIEW_ATTEMPTS;
}
/** Forgets the current image so the next {@link #follow} reloads. */
@@ -416,12 +454,22 @@ public final class QueueScreen extends PhotoSyncScreen {
texture.close();
texture = null;
}
failed = false;
attempts = 0;
retryAtMillis = 0;
}
private byte[] readAll(Path path) {
try {
return Files.readAllBytes(path);
byte[] bytes = Files.readAllBytes(path);
if (bytes.length == 0) {
// The name is claimed by creating the file empty and the
// pixels follow on the IO pool, so a screenshot taken a
// moment ago is briefly a real file with nothing in it.
// Treating that as a decode failure would spend an attempt
// saying "not an image" about a file that is about to be one.
throw new CompletionException(new IOException(path + " has not been written yet"));
}
return bytes;
} catch (IOException e) {
throw new CompletionException(e);
}
@@ -27,6 +27,7 @@ import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.function.BooleanSupplier;
import java.util.function.Consumer;
import java.util.function.UnaryOperator;
@@ -48,6 +49,26 @@ import java.util.function.UnaryOperator;
*/
public final class SettingsScreen extends PhotoSyncScreen {
/**
* A thread of its own for "test connection", rather than the common pool.
*
* <p>The common pool is sized from the core count and is shared with every
* other {@code supplyAsync} in the mod -- the queue screen's previews among
* them. A connection test used to be a request with a timeout on it, which
* that pool could absorb; signing in to Google Photos is a wait for a human
* to find a browser window and press Allow, which it cannot. On a two-core
* machine one such wait is the whole pool.
*
* <p>Daemon, so a sign-in nobody ever finished does not keep the game from
* closing. The button is disabled while a test runs, so this makes at most
* one thread at a time.
*/
private static final Executor TEST_EXECUTOR = task -> {
Thread thread = new Thread(task, "photosync-connection-test");
thread.setDaemon(true);
thread.start();
};
/** One line of the form. A null widget makes it a section heading. */
private static final class Row {
@@ -358,7 +379,7 @@ public final class SettingsScreen extends PhotoSyncScreen {
} catch (RuntimeException e) {
testStatus = chrome.translate("photosync.settings.test.failed", e.toString());
}
}).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
}, TEST_EXECUTOR).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
}
// -----------------------------------------------------------------------
@@ -167,6 +167,10 @@ public final class TimelineScreen extends PhotoSyncScreen {
scroll.advance(System.currentTimeMillis());
chrome.well(render, grid);
// The tile cache's frame spans the whole body rather than just the grid,
// because the opened photo borrows a tile texture and would otherwise be
// asking for one that this frame's eviction pass had already dropped.
tiles.beginFrame();
if (renderState(render)) {
renderGrid(render, mouseX, mouseY);
scroll.render(render, mouseX, mouseY);
@@ -174,6 +178,7 @@ public final class TimelineScreen extends PhotoSyncScreen {
if (opened != null) {
renderOpened(render, mouseX, mouseY);
}
tiles.endFrame();
}
/** Draws whatever stands in for the grid, and says whether the grid itself should be drawn. */
@@ -210,15 +215,15 @@ public final class TimelineScreen extends PhotoSyncScreen {
}
private void renderGrid(RenderBridge render, int mouseX, int mouseY) {
tiles.beginFrame();
int offset = scroll.offset();
// One viewport of lookahead, so a month is already being fetched by the
// time the player scrolls it into view.
int prefetchTop = offset - grid.height();
int prefetchBottom = offset + grid.height() * 2;
int first = firstVisible(prefetchTop);
render.pushClip(grid.x(), grid.y(), grid.width(), grid.height());
for (int i = firstVisible(prefetchTop); i < blocks.size(); i++) {
for (int i = first; i < blocks.size(); i++) {
Block block = blocks.get(i);
if (block.top() > prefetchBottom) {
break;
@@ -232,7 +237,45 @@ public final class TimelineScreen extends PhotoSyncScreen {
}
}
render.popClip();
tiles.endFrame();
// Second pass on purpose: every visible tile has now had its turn at the
// loader, so the band gets whatever request slots and cache room are
// left rather than competing for them.
for (int i = first; i < blocks.size(); i++) {
Block block = blocks.get(i);
if (block.top() > prefetchBottom) {
break;
}
prefetchBlock(block, prefetchTop, prefetchBottom);
}
}
/**
* Asks for the thumbnails of the tiles between {@code top} and {@code
* bottom} in content space, without drawing them.
*
* <p>Fetching a thumbnail only once its tile is inside the viewport means
* every tile is blurred for a whole round trip after it appears, which over
* a scroll is most of what the player sees. The months were already being
* loaded a viewport ahead; this gives their images the same head start.
*/
private void prefetchBlock(Block block, int top, int bottom) {
List<RemoteAsset> assets = block.section().assets();
if (assets.isEmpty()) {
return;
}
int stride = block.tileSize() + theme().tileGap();
int gridTop = block.gridTop(headerHeight);
int lastRow = (bottom - gridTop) / stride;
if (lastRow < 0) {
return;
}
int firstRow = Math.max(0, (top - gridTop) / stride);
int from = firstRow * block.columns();
int to = Math.min(assets.size(), (lastRow + 1) * block.columns());
for (int index = from; index < to; index++) {
tiles.prefetch(assets.get(index));
}
}
private void renderBlock(RenderBridge render, Block block, int offset, int mouseX, int mouseY) {
@@ -278,7 +321,10 @@ public final class TimelineScreen extends PhotoSyncScreen {
render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
Optional<ThumbnailCache.Thumbnail> thumbnail = tiles.of(asset);
thumbnail.ifPresent(value -> drawCropped(render, value.texture(), tile));
if (thumbnail.isEmpty() && tiles.failed(asset.id())) {
// Marked even when there is a ThumbHash to draw. A tile that keeps its
// blur because the image never arrived looks exactly like one the mod
// simply chose not to sharpen, and the player has no way to tell.
if (tiles.failed(asset.id())) {
chrome.centered(render, "!", tile, theme().danger());
}
if (asset.isVideo() && ui.core().config().current().browser().showVideoBadge()) {
@@ -302,33 +348,49 @@ public final class TimelineScreen extends PhotoSyncScreen {
/**
* The opened photo, at whatever resolution has arrived so far.
*
* <p>What arrives first is the ThumbHash: a blurred 32x32 that is right for a
* tile and nowhere near enough for a full-screen view. It is still worth
* drawing, because it says which photo is opening, but it is dimmed and
* labelled while it stands in -- an unannotated blur is indistinguishable
* from a mod that fetched the wrong size.
* <p>The stand-in while the full image loads is the grid's own thumbnail:
* it is already on the GPU, it is the picture the player just clicked, and
* it is sharp enough to be worth looking at. Only when the grid has nothing
* real either does this fall back to the ThumbHash -- a blurred 32x32 that
* is right for a tile and nowhere near enough for a full-screen view, so it
* is dimmed and labelled while it stands in. An unannotated blur is
* indistinguishable from a mod that fetched the wrong size.
*/
private void renderOpened(RenderBridge render, int mouseX, int mouseY) {
detail.beginFrame();
Rect area = body();
render.fill(area.x(), area.y(), area.width(), area.height(), theme().overlay());
Rect frame = area.inset(6);
Optional<ThumbnailCache.Thumbnail> image = detail.of(opened);
Optional<ThumbnailCache.Thumbnail> full = detail.of(opened);
// Keeps asking the grid cache too, so the tile survives this frame's
// eviction pass even when the player has scrolled it out of the grid.
Optional<ThumbnailCache.Thumbnail> tile = tiles.of(opened);
if (detail.failed(opened.id())) {
if (full.isPresent() && !full.get().placeholder()) {
drawContained(render, full.get().texture(), frame);
} else if (detail.failed(opened.id())) {
chrome.notice(render, frame, chrome.translate("photosync.browse.open_failed"),
chrome.translate("photosync.browse.open_failed.hint"));
} else if (image.isEmpty()) {
chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
} else {
drawContained(render, image.get().texture(), frame);
if (image.get().placeholder()) {
render.fill(frame.x(), frame.y(), frame.width(), frame.height(),
theme().fade(theme().overlay(), 0.6f));
chrome.centered(render, chrome.translate("photosync.browse.opening"),
new Rect(frame.x(), frame.centerY() - render.lineHeight(), frame.width(), render.lineHeight()),
theme().text());
chrome.busyBar(render, new Rect(frame.centerX() - 60, frame.centerY() + 6, 120, 3),
Optional<ThumbnailCache.Thumbnail> standIn =
tile.filter(image -> !image.placeholder()).or(() -> full);
standIn.ifPresent(image -> {
drawContained(render, image.texture(), frame);
if (image.placeholder()) {
render.fill(frame.x(), frame.y(), frame.width(), frame.height(),
theme().fade(theme().overlay(), 0.6f));
chrome.centered(render, chrome.translate("photosync.browse.opening"),
new Rect(frame.x(), frame.centerY() - render.lineHeight(),
frame.width(), render.lineHeight()),
theme().text());
}
});
if (standIn.isEmpty()) {
chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
} else {
// Under the picture rather than over it, because the picture is
// now worth seeing: the bar says a sharper one is still coming.
chrome.busyBar(render, new Rect(frame.centerX() - 60, frame.bottom() - 6, 120, 3),
System.currentTimeMillis(), theme().accent());
}
}