add google photos support & fix preview
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user