init
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
// The assembled mod, minus Minecraft. A platform module builds one
|
||||
// PhotoSyncClient and forwards the game's events to it; everything else in
|
||||
// :shared is reached through that object.
|
||||
dependencies {
|
||||
api project(':shared:core')
|
||||
api project(':shared:mc-api')
|
||||
api project(':shared:ui')
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package dev.photosync.client;
|
||||
|
||||
import dev.photosync.core.capture.CaptureOrigin;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.config.AutoCaptureSettings;
|
||||
import dev.photosync.mcapi.GameContext;
|
||||
import dev.photosync.mcapi.capture.ScreenshotService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The timer behind the optional "take a screenshot every N seconds" setting.
|
||||
*
|
||||
* <p>Measured against the wall clock rather than counted in ticks. Ticks stop
|
||||
* when the game is paused, which would make a five-minute interval mean
|
||||
* something different depending on how long the player spent in their inventory,
|
||||
* and the feature is only comprehensible if the number on the slider is the
|
||||
* number of seconds that elapse.
|
||||
*
|
||||
* <p>Off by default, and it stays off until the player says otherwise: silently
|
||||
* filling someone's screenshots folder is not a feature.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class AutoCapture {
|
||||
|
||||
private final GameContext game;
|
||||
private final ScreenshotService screenshots;
|
||||
private final Supplier<AutoCaptureSettings> settings;
|
||||
private final Consumer<CapturedScreenshot> sink;
|
||||
|
||||
/** Zero means "not armed": the next tick sets the first deadline. */
|
||||
private long nextCaptureMillis;
|
||||
private boolean capturing;
|
||||
|
||||
public AutoCapture(GameContext game, ScreenshotService screenshots,
|
||||
Supplier<AutoCaptureSettings> settings, Consumer<CapturedScreenshot> sink) {
|
||||
this.game = game;
|
||||
this.screenshots = screenshots;
|
||||
this.settings = settings;
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
/** Called once per client tick, from the render thread. */
|
||||
public void tick() {
|
||||
AutoCaptureSettings current = settings.get();
|
||||
if (!current.enabled()) {
|
||||
// Disarmed, so switching the feature on never fires immediately --
|
||||
// it always waits a full interval first.
|
||||
nextCaptureMillis = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long interval = current.intervalSeconds() * 1000L;
|
||||
if (nextCaptureMillis == 0) {
|
||||
nextCaptureMillis = now + interval;
|
||||
return;
|
||||
}
|
||||
if (now < nextCaptureMillis || capturing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.onlyInWorld() && !game.inWorld()) {
|
||||
// Sitting in the menus is a long-lived state; hold the deadline
|
||||
// forward so joining a world does not trigger an immediate burst.
|
||||
nextCaptureMillis = now + interval;
|
||||
return;
|
||||
}
|
||||
if (current.skipWhenScreenOpen() && game.screenOpen()) {
|
||||
// An open screen is momentary by comparison, so the deadline stays
|
||||
// where it is and the shot happens as soon as it closes.
|
||||
return;
|
||||
}
|
||||
|
||||
nextCaptureMillis = now + interval;
|
||||
take(current.fileNameSuffix());
|
||||
}
|
||||
|
||||
private void take(String suffix) {
|
||||
capturing = true;
|
||||
screenshots.capture(suffix).whenComplete((file, failure) -> {
|
||||
capturing = false;
|
||||
if (failure != null) {
|
||||
log.warn("Automatic capture failed", failure);
|
||||
return;
|
||||
}
|
||||
publish(file);
|
||||
});
|
||||
}
|
||||
|
||||
private void publish(Path file) {
|
||||
try {
|
||||
sink.accept(CapturedScreenshot.of(file, CaptureOrigin.AUTOMATIC));
|
||||
} catch (IOException e) {
|
||||
log.warn("Wrote {} but could not read it back", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package dev.photosync.client;
|
||||
|
||||
import dev.photosync.core.PhotoSync;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.config.NotificationKind;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
import dev.photosync.core.upload.UploadEvent;
|
||||
import dev.photosync.mcapi.ClientBridge;
|
||||
import dev.photosync.mcapi.capture.ScreenshotBus;
|
||||
import dev.photosync.mcapi.lifecycle.QuitGuard;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Theme;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The mod, as one object.
|
||||
*
|
||||
* <p>A platform module builds this with its {@link ClientBridge} and then only
|
||||
* ever calls the five methods below: {@link #start()}, {@link #tick()},
|
||||
* {@link #renderHud}, {@link #openScreen()} and {@link #close()}. Everything a
|
||||
* new Minecraft version can break is on the other side of that bridge.
|
||||
*
|
||||
* <p>What lives here is the wiring nobody else can own, because it crosses the
|
||||
* modules: a screenshot becomes an upload, an upload becomes a message in the
|
||||
* corner, and a quit becomes a question.
|
||||
*/
|
||||
@Slf4j
|
||||
@Accessors(fluent = true)
|
||||
public final class PhotoSyncClient implements AutoCloseable {
|
||||
|
||||
/**
|
||||
* How long the game is allowed to hang on shutdown after the player has said
|
||||
* they are done waiting. The queue is durable, so this is a courtesy for
|
||||
* uploads that are seconds from finishing, not a guarantee.
|
||||
*/
|
||||
private static final Duration SHUTDOWN_GRACE = Duration.ofSeconds(3);
|
||||
|
||||
@Getter
|
||||
private final PhotoSync core;
|
||||
@Getter
|
||||
private final ClientBridge bridge;
|
||||
@Getter
|
||||
private final PhotoSyncUi ui;
|
||||
|
||||
private final AutoCapture autoCapture;
|
||||
|
||||
public PhotoSyncClient(ClientBridge bridge) {
|
||||
this(bridge, new PhotoSync(bridge.game().configDirectory()), Theme.dark());
|
||||
}
|
||||
|
||||
/** Takes its collaborators explicitly so a test can drive it without a game. */
|
||||
public PhotoSyncClient(ClientBridge bridge, PhotoSync core, Theme theme) {
|
||||
this.bridge = bridge;
|
||||
this.core = core;
|
||||
this.ui = new PhotoSyncUi(core, bridge, theme);
|
||||
this.autoCapture = new AutoCapture(
|
||||
bridge.game(),
|
||||
bridge.screenshots(),
|
||||
() -> core.config().current().autoCapture(),
|
||||
this::captured);
|
||||
}
|
||||
|
||||
/** Connects everything and starts draining whatever last session left behind. */
|
||||
public PhotoSyncClient start() {
|
||||
core.start();
|
||||
// Manual screenshots arrive here from the platform's Screenshot mixin;
|
||||
// automatic ones come from AutoCapture, which already knows their origin.
|
||||
ScreenshotBus.get().subscribe(this::captured);
|
||||
core.queue().onEvent(this::uploadChanged);
|
||||
QuitGuard.get().handler(this::mayQuit);
|
||||
log.info("PhotoSync ready on Minecraft {}", bridge.game().minecraftVersion());
|
||||
return this;
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
autoCapture.tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the corner messages. Called every frame the in-world HUD is drawn,
|
||||
* so not on the title screen and not on top of an open screen.
|
||||
*/
|
||||
public void renderHud(RenderBridge render) {
|
||||
ui.notifications().render(render, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/** The key binding's action. */
|
||||
public void openScreen() {
|
||||
ui.open();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshots in, notifications out
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Called from an IO thread for manual shots and a worker for automatic ones. */
|
||||
private void captured(CapturedScreenshot shot) {
|
||||
PhotoSyncConfig config = core.config().current();
|
||||
ui.notifications().show(NotificationKind.CAPTURED,
|
||||
chrome().translate("photosync.notify.captured", shot.fileName()));
|
||||
|
||||
if (!config.upload().uploadOnCapture()) {
|
||||
return;
|
||||
}
|
||||
if (!config.isReady()) {
|
||||
// Queuing against a server we have no credentials for would fill the
|
||||
// queue screen with failures the player cannot act on from there.
|
||||
log.debug("Not queuing {}: no provider configured", shot.fileName());
|
||||
return;
|
||||
}
|
||||
core.queue().enqueue(shot, config.album());
|
||||
}
|
||||
|
||||
private void uploadChanged(UploadEvent event) {
|
||||
if (event instanceof UploadEvent.Completed completed) {
|
||||
ui.notifications().show(NotificationKind.UPLOADED,
|
||||
chrome().translate("photosync.notify.uploaded", completed.job().fileName()));
|
||||
} else if (event instanceof UploadEvent.Failed failed && !failed.willRetry()) {
|
||||
// Only the final failure is worth a message. The retries are visible
|
||||
// on the queue screen for anyone who wants to watch them.
|
||||
ui.notifications().show(NotificationKind.FAILED,
|
||||
chrome().translate("photosync.notify.failed", failed.job().fileName()));
|
||||
}
|
||||
}
|
||||
|
||||
private Chrome chrome() {
|
||||
return ui.chrome();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Quitting
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Answers {@code QuitGuard}. Returning false hands the interaction to the
|
||||
* quit dialog, which comes back through {@link PhotoSyncUi#quitNow()}.
|
||||
*/
|
||||
private boolean mayQuit() {
|
||||
PhotoSyncConfig config = core.config().current();
|
||||
if (!config.upload().waitOnQuit() || !core.isBusy()) {
|
||||
return true;
|
||||
}
|
||||
bridge.game().submit(ui::confirmQuit);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// A short drain first: an upload that is nearly done finishes now rather
|
||||
// than being re-attempted from the start next launch.
|
||||
if (core.isBusy() && !core.drain(SHUTDOWN_GRACE)) {
|
||||
log.info("Leaving {} upload(s) queued for the next session", core.queue().activeCount());
|
||||
}
|
||||
core.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Where the mod is assembled: the object graph, and the client-side behaviour
|
||||
* that is not drawing.
|
||||
*
|
||||
* <p>{@code :shared:core} knows about uploads, {@code :shared:ui} knows about
|
||||
* pixels, and {@code :shared:mc-api} declares what the game must provide. None
|
||||
* of them know about each other's lifecycles. This module is the one place that
|
||||
* does -- it decides that a screenshot becomes an upload, that an upload becomes
|
||||
* a corner message, and that quitting mid-upload becomes a dialog.
|
||||
*
|
||||
* <p>A platform module therefore constructs exactly one class from here and
|
||||
* forwards five events to it. That is the entire surface a new Minecraft version
|
||||
* has to reconnect.
|
||||
*/
|
||||
package dev.photosync.client;
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"key.categories.photosync": "PhotoSync",
|
||||
"key.category.photosync.main": "PhotoSync",
|
||||
"key.photosync.open": "Open PhotoSync",
|
||||
|
||||
"photosync.tab.queue": "Uploads",
|
||||
"photosync.tab.browse": "Browse",
|
||||
"photosync.tab.settings": "Settings",
|
||||
|
||||
"photosync.state.pending": "Queued",
|
||||
"photosync.state.uploading": "Uploading",
|
||||
"photosync.state.retrying": "Retrying",
|
||||
"photosync.state.completed": "Done",
|
||||
"photosync.state.failed": "Failed",
|
||||
"photosync.state.cancelled": "Cancelled",
|
||||
|
||||
"photosync.queue.retry_all": "Retry failed",
|
||||
"photosync.queue.clear_finished": "Clear finished",
|
||||
"photosync.queue.retry": "Retry",
|
||||
"photosync.queue.cancel": "Cancel",
|
||||
"photosync.queue.reveal": "Show in folder",
|
||||
"photosync.queue.forget": "Remove",
|
||||
"photosync.queue.empty": "No uploads yet",
|
||||
"photosync.queue.empty.hint": "Screenshots you take will appear here.",
|
||||
"photosync.queue.no_selection": "Select a screenshot to see it here.",
|
||||
"photosync.queue.preview_failed": "Preview unavailable",
|
||||
"photosync.queue.preview_loading": "Loading preview...",
|
||||
"photosync.queue.idle": "Nothing to upload",
|
||||
"photosync.queue.status": "%s active, %s failed",
|
||||
|
||||
"photosync.browse.refresh": "Refresh",
|
||||
"photosync.browse.showing_library": "Showing everything",
|
||||
"photosync.browse.showing_album": "Showing album",
|
||||
"photosync.browse.not_configured": "Not connected",
|
||||
"photosync.browse.not_configured.hint": "Add your server and API key in Settings.",
|
||||
"photosync.browse.loading": "Loading timeline...",
|
||||
"photosync.browse.failed": "Could not load the timeline",
|
||||
"photosync.browse.empty": "Nothing here yet",
|
||||
"photosync.browse.page_failed": "Could not load these photos",
|
||||
"photosync.browse.opening": "Loading...",
|
||||
"photosync.browse.close_hint": "Esc to close",
|
||||
"photosync.browse.status": "%s photos in %s days",
|
||||
|
||||
"photosync.album.library": "Whole library",
|
||||
"photosync.album.back": "Back",
|
||||
"photosync.album.refresh": "Refresh",
|
||||
"photosync.album.create": "Create",
|
||||
"photosync.album.new_hint": "New album name",
|
||||
"photosync.album.loading": "Loading albums...",
|
||||
"photosync.album.failed": "Could not load albums",
|
||||
"photosync.album.not_configured": "Add your server and API key first.",
|
||||
"photosync.album.status": "Uploading to: %s",
|
||||
|
||||
"photosync.settings.section.connection": "Connection",
|
||||
"photosync.settings.section.upload": "Uploads",
|
||||
"photosync.settings.section.auto_capture": "Automatic screenshots",
|
||||
"photosync.settings.section.notifications": "Notifications",
|
||||
"photosync.settings.section.browser": "Browser",
|
||||
|
||||
"photosync.settings.provider": "Service",
|
||||
"photosync.settings.connection_state": "Connection",
|
||||
"photosync.settings.test": "Test",
|
||||
"photosync.settings.test.running": "Testing...",
|
||||
"photosync.settings.test.incomplete": "Fill in both fields first.",
|
||||
"photosync.settings.test.ok": "Connected as %s (server %s)",
|
||||
"photosync.settings.test.failed": "Failed: %s",
|
||||
"photosync.settings.album": "Album",
|
||||
|
||||
"photosync.settings.upload_on_capture": "Upload screenshots automatically",
|
||||
"photosync.settings.upload_on_capture.detail": "Every screenshot you take is queued for upload.",
|
||||
"photosync.settings.concurrency": "Uploads at once",
|
||||
"photosync.settings.attempts": "Attempts before giving up",
|
||||
"photosync.settings.backoff": "Wait between attempts",
|
||||
"photosync.settings.wait_on_quit": "Ask before quitting mid-upload",
|
||||
"photosync.settings.wait_on_quit.detail": "Shows progress and lets uploads finish. Unfinished ones resume next time either way.",
|
||||
"photosync.settings.delete_local": "Delete the local file after upload",
|
||||
"photosync.settings.delete_local.detail": "Only once the server has confirmed it.",
|
||||
|
||||
"photosync.settings.auto_capture": "Take screenshots on a timer",
|
||||
"photosync.settings.auto_capture.detail": "Off by default.",
|
||||
"photosync.settings.interval": "Every",
|
||||
"photosync.settings.suffix": "Name suffix",
|
||||
"photosync.settings.only_in_world": "Only while in a world",
|
||||
"photosync.settings.skip_when_screen_open": "Skip while a screen is open",
|
||||
|
||||
"photosync.settings.notify": "Show messages in the corner",
|
||||
"photosync.settings.notify.detail": "One line, bottom left, for a moment.",
|
||||
"photosync.settings.notify_capture": "When a screenshot is taken",
|
||||
"photosync.settings.notify_uploaded": "When an upload finishes",
|
||||
"photosync.settings.notify_failed": "When an upload fails",
|
||||
"photosync.settings.linger": "Message duration",
|
||||
|
||||
"photosync.settings.tile_size": "Thumbnail size",
|
||||
"photosync.settings.cache": "Thumbnails kept in memory",
|
||||
"photosync.settings.video_badge": "Mark videos in the grid",
|
||||
|
||||
"photosync.settings.save": "Save",
|
||||
"photosync.settings.revert": "Revert",
|
||||
"photosync.settings.dirty": "Unsaved changes -- saved when you close this screen.",
|
||||
"photosync.settings.clean": "All changes saved.",
|
||||
|
||||
"photosync.quit.title": "Uploads still running",
|
||||
"photosync.quit.remaining": "%s left to upload",
|
||||
"photosync.quit.finishing": "Finishing up...",
|
||||
"photosync.quit.hint": "Anything unfinished resumes next time you play.",
|
||||
"photosync.quit.keep_playing": "Keep playing",
|
||||
"photosync.quit.anyway": "Quit anyway",
|
||||
|
||||
"photosync.notify.captured": "Screenshot saved: %s",
|
||||
"photosync.notify.uploaded": "Uploaded %s",
|
||||
"photosync.notify.failed": "Upload failed: %s",
|
||||
|
||||
"photosync.provider.immich.name": "Immich",
|
||||
"photosync.provider.immich.endpoint": "Server URL",
|
||||
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
|
||||
"photosync.provider.immich.secret": "API key",
|
||||
"photosync.provider.immich.secret.hint": "Account settings -> API Keys"
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Provider abstraction, Immich client, upload pipeline, configuration.
|
||||
// Nothing in this module may import a Minecraft or Fabric type.
|
||||
@@ -0,0 +1,113 @@
|
||||
package dev.photosync.core;
|
||||
|
||||
import dev.photosync.core.config.ConfigService;
|
||||
import dev.photosync.core.config.ConfigStore;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
import dev.photosync.core.provider.ProviderCatalog;
|
||||
import dev.photosync.core.provider.ProviderFactory;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.immich.ImmichProviderFactory;
|
||||
import dev.photosync.core.thumbnail.ThumbnailLoader;
|
||||
import dev.photosync.core.timeline.TimelineBrowser;
|
||||
import dev.photosync.core.upload.UploadCoordinator;
|
||||
import dev.photosync.core.upload.UploadQueue;
|
||||
import lombok.Getter;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Everything the mod does that is not Minecraft, assembled.
|
||||
*
|
||||
* <p>This is the seam the platform code sees: a Fabric entrypoint builds one of
|
||||
* these with a config directory and then only ever talks to the services hanging
|
||||
* off it. Nothing below this package knows what a {@code Screen} is, and nothing
|
||||
* here reaches back up.
|
||||
*
|
||||
* <p>The wiring is done in a constructor rather than by a container because
|
||||
* there are seven objects and their order is fixed. The one piece of behaviour
|
||||
* that lives here is the subscription that rebuilds the provider whenever the
|
||||
* player edits their credentials.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class PhotoSync implements AutoCloseable {
|
||||
|
||||
private static final String CONFIG_FILE = "photosync.json";
|
||||
private static final String QUEUE_FILE = "uploads.json";
|
||||
|
||||
@Getter
|
||||
private final ProviderCatalog catalog;
|
||||
@Getter
|
||||
private final ConfigService config;
|
||||
@Getter
|
||||
private final ProviderSession session;
|
||||
@Getter
|
||||
private final UploadQueue queue;
|
||||
@Getter
|
||||
private final UploadCoordinator uploads;
|
||||
@Getter
|
||||
private final TimelineBrowser browser;
|
||||
@Getter
|
||||
private final ThumbnailLoader thumbnails;
|
||||
|
||||
/** The standard set of backends. */
|
||||
public PhotoSync(Path directory) {
|
||||
this(directory, List.of(new ImmichProviderFactory()));
|
||||
}
|
||||
|
||||
/** Takes the backend list explicitly so tests can run against a fake one. */
|
||||
public PhotoSync(Path directory, List<ProviderFactory> providers) {
|
||||
this.catalog = new ProviderCatalog(providers);
|
||||
this.config = new ConfigService(new ConfigStore(directory.resolve(CONFIG_FILE), catalog.preferred()));
|
||||
this.session = new ProviderSession(catalog);
|
||||
// Reads and repairs the on-disk queue, so anything interrupted by the
|
||||
// last quit is already pending again by the time uploads start.
|
||||
this.queue = new UploadQueue(directory.resolve(QUEUE_FILE));
|
||||
this.uploads = new UploadCoordinator(queue, session, () -> config.current().upload());
|
||||
this.browser = new TimelineBrowser(session);
|
||||
this.thumbnails = new ThumbnailLoader(session);
|
||||
}
|
||||
|
||||
/** Connects to the configured backend and starts draining the queue. */
|
||||
public PhotoSync start() {
|
||||
config.onChange(this::applyConnection);
|
||||
applyConnection(config.current());
|
||||
uploads.start();
|
||||
int resumed = queue.activeCount();
|
||||
if (resumed > 0) {
|
||||
log.info("Resuming {} upload(s) left over from the last session", resumed);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** True while anything is still queued, uploading or waiting to retry. */
|
||||
public boolean isBusy() {
|
||||
return queue.hasActiveWork();
|
||||
}
|
||||
|
||||
/**
|
||||
* Drains the queue, giving up after {@code timeout}.
|
||||
*
|
||||
* <p>Answers whether it finished, which is what the quit dialog reports back
|
||||
* to the player. Whatever is left is already on disk and will be picked up
|
||||
* the next time the game starts.
|
||||
*/
|
||||
public boolean drain(Duration timeout) {
|
||||
return uploads.shutdown(timeout);
|
||||
}
|
||||
|
||||
private void applyConnection(PhotoSyncConfig current) {
|
||||
session.configure(current.provider(), current.connection());
|
||||
uploads.wake();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
thumbnails.close();
|
||||
browser.close();
|
||||
uploads.close();
|
||||
session.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package dev.photosync.core.capture;
|
||||
|
||||
/** Whether the player pressed the key, or the auto-capture timer fired. */
|
||||
public enum CaptureOrigin {
|
||||
MANUAL,
|
||||
AUTOMATIC
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.photosync.core.capture;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A screenshot that has just landed in the screenshots folder.
|
||||
*
|
||||
* <p>Produced by the platform layer -- which is the only part that knows how
|
||||
* Minecraft writes the file -- and consumed by the upload queue.
|
||||
*/
|
||||
public record CapturedScreenshot(Path file, String fileName, Instant capturedAt, CaptureOrigin origin, long sizeBytes) {
|
||||
|
||||
public static CapturedScreenshot of(Path file, CaptureOrigin origin) throws IOException {
|
||||
return new CapturedScreenshot(
|
||||
file.toAbsolutePath(),
|
||||
file.getFileName().toString(),
|
||||
Files.getLastModifiedTime(file).toInstant(),
|
||||
origin,
|
||||
Files.size(file));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* Unattended screenshots on a timer.
|
||||
*
|
||||
* <p>Off by default, and deliberately so: a mod that silently starts writing
|
||||
* files and uploading them the moment it is installed would be a bad neighbour.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record AutoCaptureSettings(
|
||||
boolean enabled,
|
||||
int intervalSeconds,
|
||||
String fileNameSuffix,
|
||||
boolean onlyInWorld,
|
||||
boolean skipWhenScreenOpen) {
|
||||
|
||||
public static final int MIN_INTERVAL_SECONDS = 5;
|
||||
public static final int MAX_INTERVAL_SECONDS = 3600;
|
||||
|
||||
public static AutoCaptureSettings defaults() {
|
||||
return AutoCaptureSettings.builder()
|
||||
.enabled(false)
|
||||
.intervalSeconds(300)
|
||||
.fileNameSuffix("_auto")
|
||||
.onlyInWorld(true)
|
||||
.skipWhenScreenOpen(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
AutoCaptureSettings normalized() {
|
||||
return toBuilder()
|
||||
.intervalSeconds(Math.min(MAX_INTERVAL_SECONDS, Math.max(MIN_INTERVAL_SECONDS, intervalSeconds)))
|
||||
.fileNameSuffix(sanitizeSuffix(fileNameSuffix))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The suffix lands in a file name, so anything that would break a path has
|
||||
* to go. An empty result is allowed -- it just means "no suffix".
|
||||
*/
|
||||
private static String sanitizeSuffix(String raw) {
|
||||
if (raw == null) {
|
||||
return "";
|
||||
}
|
||||
StringBuilder clean = new StringBuilder(raw.length());
|
||||
raw.chars()
|
||||
.filter(ch -> ch > 0x1F && "\\/:*?\"<>|".indexOf(ch) < 0)
|
||||
.forEach(ch -> clean.append((char) ch));
|
||||
return clean.toString().strip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** Look and memory budget of the timeline browser. */
|
||||
@Builder(toBuilder = true)
|
||||
public record BrowserSettings(int tileSize, int thumbnailCacheEntries, boolean showVideoBadge) {
|
||||
|
||||
public static BrowserSettings defaults() {
|
||||
return BrowserSettings.builder()
|
||||
.tileSize(96)
|
||||
// Each cached tile is an uploaded GPU texture plus its decoded
|
||||
// bytes, so this is the single biggest memory knob in the mod.
|
||||
.thumbnailCacheEntries(256)
|
||||
.showVideoBadge(true)
|
||||
.build();
|
||||
}
|
||||
|
||||
BrowserSettings normalized() {
|
||||
return toBuilder()
|
||||
.tileSize(Math.min(192, Math.max(48, tileSize)))
|
||||
.thumbnailCacheEntries(Math.min(2048, Math.max(32, thumbnailCacheEntries)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Holds the live configuration and tells the rest of the mod when it changes.
|
||||
*
|
||||
* <p>The upload coordinator, the auto-capture timer and the browser all need to
|
||||
* react to a settings edit without the settings screen knowing they exist, so
|
||||
* they subscribe here instead.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ConfigService {
|
||||
|
||||
private final ConfigStore store;
|
||||
private final AtomicReference<PhotoSyncConfig> current;
|
||||
private final List<Consumer<PhotoSyncConfig>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public ConfigService(ConfigStore store) {
|
||||
this.store = store;
|
||||
this.current = new AtomicReference<>(store.load());
|
||||
}
|
||||
|
||||
public PhotoSyncConfig current() {
|
||||
return current.get();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to later changes. Listeners run on whichever thread made the
|
||||
* change -- in practice the client thread, from the settings screen.
|
||||
*/
|
||||
public void onChange(Consumer<PhotoSyncConfig> listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
public void update(UnaryOperator<PhotoSyncConfig> change) {
|
||||
PhotoSyncConfig updated = current.updateAndGet(previous -> change.apply(previous).normalized(previous.provider()));
|
||||
persist(updated);
|
||||
for (Consumer<PhotoSyncConfig> listener : listeners) {
|
||||
try {
|
||||
listener.accept(updated);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("A config listener failed; the change itself was still applied", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void persist(PhotoSyncConfig config) {
|
||||
try {
|
||||
store.save(config);
|
||||
} catch (IOException e) {
|
||||
// Losing the write is bad but not fatal: the in-memory config is
|
||||
// already updated, so the session behaves as the player asked.
|
||||
log.error("Could not write {}", store.path(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import dev.photosync.core.persistence.JsonFile;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Reads and writes {@code photosync.json}.
|
||||
*
|
||||
* <p>Loading merges the stored file <em>onto</em> the serialized defaults rather
|
||||
* than deserializing it directly. A setting added in a later release is therefore
|
||||
* present with its default in an old config file, and a setting removed in a
|
||||
* later release is ignored -- no schema version, no migration step. This is not
|
||||
* a nicety: Gson gives every absent record component {@code null} or {@code 0},
|
||||
* so without the merge a newly added {@code lingerMillis} would arrive as zero
|
||||
* and notifications would vanish for everyone upgrading.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ConfigStore {
|
||||
|
||||
private final JsonFile file;
|
||||
private final ProviderId fallbackProvider;
|
||||
|
||||
public ConfigStore(Path path, ProviderId fallbackProvider) {
|
||||
this.file = new JsonFile(path);
|
||||
this.fallbackProvider = fallbackProvider;
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return file.path();
|
||||
}
|
||||
|
||||
/** Never throws: a broken config falls back to defaults rather than blocking start-up. */
|
||||
public PhotoSyncConfig load() {
|
||||
PhotoSyncConfig defaults = PhotoSyncConfig.defaults(fallbackProvider);
|
||||
Optional<JsonElement> stored = file.readTree();
|
||||
if (stored.isEmpty() || !stored.get().isJsonObject()) {
|
||||
return defaults;
|
||||
}
|
||||
try {
|
||||
JsonObject merged = file.gson().toJsonTree(defaults).getAsJsonObject();
|
||||
overlay(merged, stored.get().getAsJsonObject());
|
||||
return file.gson().fromJson(merged, PhotoSyncConfig.class).normalized(fallbackProvider);
|
||||
} catch (RuntimeException e) {
|
||||
log.warn("{} could not be understood; using defaults", file.path(), e);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
public void save(PhotoSyncConfig config) throws IOException {
|
||||
file.write(file.gson().toJsonTree(config));
|
||||
}
|
||||
|
||||
/** Recursively copies {@code stored} over {@code base}, object by object. */
|
||||
private static void overlay(JsonObject base, JsonObject stored) {
|
||||
for (Map.Entry<String, JsonElement> entry : stored.entrySet()) {
|
||||
JsonElement current = base.get(entry.getKey());
|
||||
JsonElement incoming = entry.getValue();
|
||||
if (current != null && current.isJsonObject() && incoming.isJsonObject()) {
|
||||
overlay(current.getAsJsonObject(), incoming.getAsJsonObject());
|
||||
} else {
|
||||
base.add(entry.getKey(), incoming);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
/** Which of the three things worth a corner message just happened. */
|
||||
public enum NotificationKind {
|
||||
CAPTURED,
|
||||
UPLOADED,
|
||||
FAILED
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* The one-line messages that appear in the bottom-left corner.
|
||||
*
|
||||
* <p>Each event is switchable on its own, because the useful setting for a
|
||||
* player running auto-capture every thirty seconds is different from the one for
|
||||
* a player who screenshots twice an hour.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record NotificationSettings(
|
||||
boolean enabled,
|
||||
boolean onCapture,
|
||||
boolean onUploaded,
|
||||
boolean onFailed,
|
||||
int lingerMillis) {
|
||||
|
||||
public static NotificationSettings defaults() {
|
||||
return NotificationSettings.builder()
|
||||
.enabled(true)
|
||||
.onCapture(true)
|
||||
.onUploaded(true)
|
||||
.onFailed(true)
|
||||
.lingerMillis(3000)
|
||||
.build();
|
||||
}
|
||||
|
||||
NotificationSettings normalized() {
|
||||
return toBuilder()
|
||||
.lingerMillis(Math.min(15_000, Math.max(500, lingerMillis)))
|
||||
.build();
|
||||
}
|
||||
|
||||
public boolean shows(NotificationKind kind) {
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return switch (kind) {
|
||||
case CAPTURED -> onCapture;
|
||||
case UPLOADED -> onUploaded;
|
||||
case FAILED -> onFailed;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* The whole of PhotoSync's settings, as one immutable value.
|
||||
*
|
||||
* <p>Screens never mutate this. They build a new one -- {@code
|
||||
* config.toBuilder().albumId(id).build()} -- and hand it to {@link
|
||||
* ConfigService}, which persists it and tells everyone who cares. That makes
|
||||
* "cancel" free and "what changed?" answerable.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record PhotoSyncConfig(
|
||||
ProviderId provider,
|
||||
ProviderConnection connection,
|
||||
String albumId,
|
||||
UploadSettings upload,
|
||||
AutoCaptureSettings autoCapture,
|
||||
NotificationSettings notifications,
|
||||
BrowserSettings browser) {
|
||||
|
||||
public static PhotoSyncConfig defaults(ProviderId provider) {
|
||||
return PhotoSyncConfig.builder()
|
||||
.provider(provider)
|
||||
.connection(ProviderConnection.empty())
|
||||
.albumId("")
|
||||
.upload(UploadSettings.defaults())
|
||||
.autoCapture(AutoCaptureSettings.defaults())
|
||||
.notifications(NotificationSettings.defaults())
|
||||
.browser(BrowserSettings.defaults())
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs a config that came off disk: fills in anything a hand edit removed
|
||||
* and clamps anything a hand edit made absurd. Called on every load, so the
|
||||
* rest of the mod may assume its values are sane.
|
||||
*/
|
||||
public PhotoSyncConfig normalized(ProviderId fallbackProvider) {
|
||||
return PhotoSyncConfig.builder()
|
||||
.provider(provider == null ? fallbackProvider : provider)
|
||||
.connection(connection == null ? ProviderConnection.empty() : connection)
|
||||
.albumId(albumId == null ? "" : albumId.trim())
|
||||
.upload((upload == null ? UploadSettings.defaults() : upload).normalized())
|
||||
.autoCapture((autoCapture == null ? AutoCaptureSettings.defaults() : autoCapture).normalized())
|
||||
.notifications((notifications == null ? NotificationSettings.defaults() : notifications).normalized())
|
||||
.browser((browser == null ? BrowserSettings.defaults() : browser).normalized())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** Where uploads go and what the browser shows. Empty album id means the whole library. */
|
||||
public AlbumRef album() {
|
||||
return AlbumRef.of(albumId);
|
||||
}
|
||||
|
||||
/** True once there is enough here to actually talk to a server. */
|
||||
public boolean isReady() {
|
||||
return connection.isConfigured();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.photosync.core.config;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/** How aggressively the queue drains, and what happens when the player quits. */
|
||||
@Builder(toBuilder = true)
|
||||
public record UploadSettings(
|
||||
boolean uploadOnCapture,
|
||||
int concurrency,
|
||||
int maxAttempts,
|
||||
int retryBackoffSeconds,
|
||||
boolean waitOnQuit,
|
||||
boolean deleteLocalAfterUpload) {
|
||||
|
||||
public static final int MAX_CONCURRENCY = 4;
|
||||
|
||||
public static UploadSettings defaults() {
|
||||
return UploadSettings.builder()
|
||||
.uploadOnCapture(true)
|
||||
.concurrency(2)
|
||||
.maxAttempts(5)
|
||||
// Multiplied by 2^(attempt-1), so 5s grows to 80s by the fifth try.
|
||||
.retryBackoffSeconds(5)
|
||||
.waitOnQuit(true)
|
||||
.deleteLocalAfterUpload(false)
|
||||
.build();
|
||||
}
|
||||
|
||||
UploadSettings normalized() {
|
||||
return toBuilder()
|
||||
.concurrency(Math.min(MAX_CONCURRENCY, Math.max(1, concurrency)))
|
||||
.maxAttempts(Math.min(20, Math.max(1, maxAttempts)))
|
||||
.retryBackoffSeconds(Math.min(300, Math.max(1, retryBackoffSeconds)))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Everything PhotoSync does that is not about Minecraft.
|
||||
*
|
||||
* <p>Nothing under {@code dev.photosync.core} may import a Minecraft, Fabric or
|
||||
* LWJGL type. That rule is what lets this module be compiled once, to Java 17
|
||||
* bytecode, and folded unchanged into all nine platform jars. It is also what
|
||||
* makes the upload pipeline testable without a game running.
|
||||
*
|
||||
* <p>The dependency arrow only ever points inwards: platform code depends on
|
||||
* {@code :shared:ui}, which depends on {@code :shared:mc-api} and this module.
|
||||
*/
|
||||
package dev.photosync.core;
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.photosync.core.persistence;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.TypeAdapter;
|
||||
import com.google.gson.stream.JsonReader;
|
||||
import com.google.gson.stream.JsonToken;
|
||||
import com.google.gson.stream.JsonWriter;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.time.format.DateTimeParseException;
|
||||
|
||||
/**
|
||||
* The handful of value types PhotoSync persists that Gson would otherwise write
|
||||
* as nested objects.
|
||||
*
|
||||
* <p>Everything else -- records, enums, primitives -- Gson already handles, and
|
||||
* on purpose: the config and queue models are built out of types the library
|
||||
* understands so that this class stays this short. Note the Gson version we
|
||||
* compile against (2.10, the oldest Minecraft ships in the supported range) is
|
||||
* also the first that can construct records, which is what makes that possible.
|
||||
*/
|
||||
final class Adapters {
|
||||
|
||||
private Adapters() {
|
||||
}
|
||||
|
||||
static Gson newGson() {
|
||||
return new GsonBuilder()
|
||||
.registerTypeAdapter(ProviderId.class, new ProviderIdAdapter().nullSafe())
|
||||
.registerTypeAdapter(Instant.class, new InstantAdapter().nullSafe())
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.create();
|
||||
}
|
||||
|
||||
/** Writes {@code "immich"} rather than {@code {"key":"immich"}}. */
|
||||
private static final class ProviderIdAdapter extends TypeAdapter<ProviderId> {
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter out, ProviderId value) throws IOException {
|
||||
out.value(value.key());
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderId read(JsonReader in) throws IOException {
|
||||
String key = in.nextString();
|
||||
return key.isBlank() ? null : new ProviderId(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** ISO-8601, so the queue file stays readable when someone goes looking. */
|
||||
private static final class InstantAdapter extends TypeAdapter<Instant> {
|
||||
|
||||
@Override
|
||||
public void write(JsonWriter out, Instant value) throws IOException {
|
||||
out.value(value.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Instant read(JsonReader in) throws IOException {
|
||||
if (in.peek() == JsonToken.NUMBER) {
|
||||
return Instant.ofEpochMilli(in.nextLong());
|
||||
}
|
||||
try {
|
||||
return Instant.parse(in.nextString());
|
||||
} catch (DateTimeParseException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dev.photosync.core.persistence;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonParser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.nio.file.attribute.PosixFilePermission;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* One JSON document on disk, written so that it cannot be found half-written.
|
||||
*
|
||||
* <p>PhotoSync keeps two of these -- the settings and the upload queue -- and
|
||||
* both have the same requirement: the game can be killed at any moment, and what
|
||||
* survives has to be either the old file or the new one. Writing in place would
|
||||
* make a hard shutdown during a save look identical to "the mod lost my API key"
|
||||
* or "the mod lost my queue", so every write goes to a scratch file and is moved
|
||||
* over the target.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class JsonFile {
|
||||
|
||||
private final Path path;
|
||||
private final Gson gson;
|
||||
|
||||
public JsonFile(Path path) {
|
||||
this.path = path;
|
||||
this.gson = Adapters.newGson();
|
||||
}
|
||||
|
||||
public Path path() {
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Configured with PhotoSync's persistence conventions -- see {@link Adapters}. */
|
||||
public Gson gson() {
|
||||
return gson;
|
||||
}
|
||||
|
||||
public boolean exists() {
|
||||
return Files.isRegularFile(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the file, or returns empty when it is missing, unreadable or not
|
||||
* valid JSON. Callers fall back to a default rather than failing start-up:
|
||||
* losing settings is annoying, refusing to launch is worse.
|
||||
*/
|
||||
public Optional<JsonElement> readTree() {
|
||||
if (!exists()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
String text = Files.readString(path, StandardCharsets.UTF_8);
|
||||
JsonElement parsed = JsonParser.parseString(text);
|
||||
return parsed.isJsonNull() ? Optional.empty() : Optional.of(parsed);
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.warn("Could not read {}; falling back to defaults", path, e);
|
||||
quarantine();
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
public void write(JsonElement tree) throws IOException {
|
||||
Path directory = path.toAbsolutePath().getParent();
|
||||
Files.createDirectories(directory);
|
||||
|
||||
Path scratch = Files.createTempFile(directory, path.getFileName() + ".", ".tmp");
|
||||
try {
|
||||
Files.writeString(scratch, gson.toJson(tree), StandardCharsets.UTF_8);
|
||||
restrictToOwner(scratch);
|
||||
try {
|
||||
Files.move(scratch, path, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException e) {
|
||||
// Network shares and some Windows setups refuse atomic moves. A
|
||||
// plain replace is still better than writing the target in place.
|
||||
Files.move(scratch, path, StandardCopyOption.REPLACE_EXISTING);
|
||||
}
|
||||
} finally {
|
||||
Files.deleteIfExists(scratch);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps a corrupt file around under a {@code .broken} name instead of
|
||||
* silently overwriting it on the next save, so a player who lost their
|
||||
* settings still has something to send us.
|
||||
*/
|
||||
private void quarantine() {
|
||||
Path broken = path.resolveSibling(path.getFileName() + ".broken");
|
||||
try {
|
||||
Files.move(path, broken, StandardCopyOption.REPLACE_EXISTING);
|
||||
log.warn("Moved the unreadable file aside as {}", broken);
|
||||
} catch (IOException e) {
|
||||
log.debug("Could not move {} aside", path, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings file holds an API key. Where the filesystem can express it,
|
||||
* keep the file to its owner; where it cannot, carry on.
|
||||
*/
|
||||
private static void restrictToOwner(Path file) {
|
||||
try {
|
||||
if (file.getFileSystem().supportedFileAttributeViews().contains("posix")) {
|
||||
Files.setPosixFilePermissions(file,
|
||||
Set.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_WRITE));
|
||||
}
|
||||
} catch (IOException | UnsupportedOperationException e) {
|
||||
log.debug("Could not restrict permissions on {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** One album as shown in the album picker. */
|
||||
public record Album(String id, String name, int assetCount, Optional<String> coverAssetId) {
|
||||
|
||||
public Album {
|
||||
coverAssetId = coverAssetId == null ? Optional.empty() : coverAssetId;
|
||||
}
|
||||
|
||||
public AlbumRef ref() {
|
||||
return AlbumRef.of(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Which slice of the backend we are talking about: one album, or the whole
|
||||
* library.
|
||||
*
|
||||
* <p>Both browsing and uploading take one of these, and "no album configured"
|
||||
* is a first-class case rather than a null id sprinkled through the code.
|
||||
*/
|
||||
public record AlbumRef(Optional<String> id) {
|
||||
|
||||
public AlbumRef {
|
||||
id = id == null ? Optional.empty() : id.filter(value -> !value.isBlank());
|
||||
}
|
||||
|
||||
public static AlbumRef library() {
|
||||
return new AlbumRef(Optional.empty());
|
||||
}
|
||||
|
||||
public static AlbumRef of(String albumId) {
|
||||
return new AlbumRef(Optional.ofNullable(albumId));
|
||||
}
|
||||
|
||||
public boolean isLibrary() {
|
||||
return id.isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* PhotoSync never plays video. A video asset is shown as its still preview with
|
||||
* a marker drawn over it, which is why this is a two-value enum and not a media
|
||||
* type hierarchy.
|
||||
*/
|
||||
public enum AssetKind {
|
||||
IMAGE,
|
||||
VIDEO
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** The assets of one {@link TimelineBucket}, newest first. */
|
||||
public record BucketPage(TimelineBucket bucket, List<RemoteAsset> assets) {
|
||||
|
||||
public BucketPage {
|
||||
assets = assets == null ? List.of() : List.copyOf(assets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The seam between PhotoSync and whatever is storing the photos.
|
||||
*
|
||||
* <p>Every method is blocking and is only ever called off the render thread, by
|
||||
* the upload workers or the timeline loader. Implementations must be safe to
|
||||
* call from several threads at once.
|
||||
*
|
||||
* <p>The interface is deliberately narrow and read-mostly. PhotoSync uploads,
|
||||
* and it browses; it does not delete, favourite or move remote assets, so no
|
||||
* such capability is exposed here. Keeping it that way is what makes a second
|
||||
* backend a weekend's work instead of a rewrite.
|
||||
*/
|
||||
public interface PhotoProvider extends AutoCloseable {
|
||||
|
||||
ProviderDescriptor descriptor();
|
||||
|
||||
/**
|
||||
* Verifies that the endpoint answers and the credentials are accepted.
|
||||
*
|
||||
* @throws ProviderException with {@link ProviderException.Kind#AUTHENTICATION}
|
||||
* if the secret is wrong, or {@link ProviderException.Kind#NETWORK}
|
||||
* if the endpoint cannot be reached
|
||||
*/
|
||||
ProviderIdentity identify() throws ProviderException;
|
||||
|
||||
/** Albums the account can upload into, for the settings picker. */
|
||||
List<Album> albums() throws ProviderException;
|
||||
|
||||
Album createAlbum(String name) throws ProviderException;
|
||||
|
||||
/**
|
||||
* Uploads one file and, when {@link UploadRequest#album()} names an album,
|
||||
* puts it there.
|
||||
*
|
||||
* <p>Must be idempotent with respect to file content: re-uploading bytes the
|
||||
* backend already has has to succeed with
|
||||
* {@link UploadReceipt.Outcome#DUPLICATE} rather than fail or duplicate.
|
||||
* The upload queue leans on this when it retries a job whose response was
|
||||
* lost -- for instance because the game was killed mid-request.
|
||||
*/
|
||||
UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException;
|
||||
|
||||
/**
|
||||
* Every bucket of the timeline with its asset count, newest first.
|
||||
*
|
||||
* <p>One cheap call that gives the browser enough to size its scrollbar for
|
||||
* the entire album without fetching any asset.
|
||||
*/
|
||||
List<TimelineBucket> timeline(AlbumRef album) throws ProviderException;
|
||||
|
||||
/** The assets of a single bucket returned by {@link #timeline(AlbumRef)}. */
|
||||
BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException;
|
||||
|
||||
/**
|
||||
* Encoded image bytes for one asset.
|
||||
*
|
||||
* <p>Must be PNG or JPEG. The game decodes these with stb_image, which reads
|
||||
* neither WebP nor AVIF, so a backend that stores those has to transcode or
|
||||
* ask for a different rendition -- the mod cannot recover from it later.
|
||||
*/
|
||||
byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException;
|
||||
|
||||
/** Releases connections. Never throws, so callers can use try-with-resources cleanly. */
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The set of backends this build knows about, in the order the settings screen
|
||||
* offers them.
|
||||
*
|
||||
* <p>An instance rather than a static registry: it is created once by
|
||||
* {@code PhotoSync} and handed to whoever needs it, which keeps the wiring
|
||||
* visible and the whole thing constructible in a test.
|
||||
*/
|
||||
public final class ProviderCatalog {
|
||||
|
||||
private final Map<ProviderId, ProviderFactory> factories;
|
||||
private final ProviderId preferred;
|
||||
|
||||
public ProviderCatalog(List<ProviderFactory> factories) {
|
||||
if (factories.isEmpty()) {
|
||||
throw new IllegalArgumentException("A catalog needs at least one provider");
|
||||
}
|
||||
Map<ProviderId, ProviderFactory> byId = new LinkedHashMap<>();
|
||||
for (ProviderFactory factory : factories) {
|
||||
ProviderFactory clash = byId.put(factory.descriptor().id(), factory);
|
||||
if (clash != null) {
|
||||
throw new IllegalArgumentException("Duplicate provider id: " + factory.descriptor().id());
|
||||
}
|
||||
}
|
||||
this.factories = Map.copyOf(byId);
|
||||
this.preferred = factories.get(0).descriptor().id();
|
||||
}
|
||||
|
||||
/** Used as the default in a fresh config, and as the fallback for an unknown id. */
|
||||
public ProviderId preferred() {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
public List<ProviderDescriptor> descriptors() {
|
||||
return factories.values().stream().map(ProviderFactory::descriptor).toList();
|
||||
}
|
||||
|
||||
public Optional<ProviderDescriptor> describe(ProviderId id) {
|
||||
return Optional.ofNullable(factories.get(id)).map(ProviderFactory::descriptor);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects using the named provider, falling back to {@link #preferred()}
|
||||
* when the config names one this build does not have -- which happens when a
|
||||
* player downgrades the mod, and is not worth crashing over.
|
||||
*/
|
||||
public PhotoProvider connect(ProviderId id, ProviderConnection connection) {
|
||||
ProviderFactory factory = factories.getOrDefault(id, factories.get(preferred));
|
||||
return factory.connect(connection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* The two things every token-authenticated photo backend needs: where it lives
|
||||
* and how we prove who we are.
|
||||
*
|
||||
* <p>Deliberately not a provider-specific credential bag. Immich calls these a
|
||||
* server URL and an API key; a future backend may call them something else, and
|
||||
* the labels come from {@link ProviderDescriptor} rather than from here.
|
||||
*/
|
||||
public record ProviderConnection(String endpoint, String secret) {
|
||||
|
||||
public ProviderConnection {
|
||||
endpoint = endpoint == null ? "" : endpoint.trim();
|
||||
secret = secret == null ? "" : secret.trim();
|
||||
}
|
||||
|
||||
public static ProviderConnection empty() {
|
||||
return new ProviderConnection("", "");
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
return !endpoint.isEmpty() && !secret.isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redacted on purpose. This record ends up in exception messages and debug
|
||||
* logs, and an API key in a pasted log is a real leak.
|
||||
*/
|
||||
@Override
|
||||
public String toString() {
|
||||
return "ProviderConnection[endpoint=" + endpoint
|
||||
+ ", secret=" + (secret.isEmpty() ? "<unset>" : "<redacted>") + "]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* What the settings screen needs in order to render a provider's connection
|
||||
* form without knowing which provider it is looking at.
|
||||
*
|
||||
* <p>The point of this type is that adding a second backend means writing a
|
||||
* {@link ProviderFactory} and a language file -- not editing the GUI. The UI
|
||||
* resolves labels by appending fixed suffixes to {@link #translationPrefix()}:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code .name} -- provider name in the picker</li>
|
||||
* <li>{@code .endpoint} / {@code .endpoint.hint} -- first credential field</li>
|
||||
* <li>{@code .secret} / {@code .secret.hint} -- second credential field</li>
|
||||
* </ul>
|
||||
*/
|
||||
public record ProviderDescriptor(ProviderId id, String translationPrefix, boolean supportsAlbums) {
|
||||
|
||||
public ProviderDescriptor {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(translationPrefix, "translationPrefix");
|
||||
}
|
||||
|
||||
public String nameKey() {
|
||||
return translationPrefix + ".name";
|
||||
}
|
||||
|
||||
public String endpointKey() {
|
||||
return translationPrefix + ".endpoint";
|
||||
}
|
||||
|
||||
public String endpointHintKey() {
|
||||
return translationPrefix + ".endpoint.hint";
|
||||
}
|
||||
|
||||
public String secretKey() {
|
||||
return translationPrefix + ".secret";
|
||||
}
|
||||
|
||||
public String secretHintKey() {
|
||||
return translationPrefix + ".secret.hint";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
/**
|
||||
* Anything a backend can fail with, classified well enough for the upload queue
|
||||
* to decide between "retry later" and "stop and tell the player".
|
||||
*/
|
||||
@Getter
|
||||
public class ProviderException extends Exception {
|
||||
|
||||
public enum Kind {
|
||||
/** Connection refused, DNS failure, timeout. Worth retrying. */
|
||||
NETWORK(true),
|
||||
/** Bad or revoked credentials. Retrying will not help. */
|
||||
AUTHENTICATION(false),
|
||||
/** The album or asset is gone. Retrying will not help. */
|
||||
NOT_FOUND(false),
|
||||
/** Server asked us to slow down. Worth retrying, after a longer wait. */
|
||||
RATE_LIMITED(true),
|
||||
/** 5xx. The server may recover. */
|
||||
SERVER(true),
|
||||
/** The response did not look like what the API promised. */
|
||||
PROTOCOL(false),
|
||||
/** The file we were asked to upload is unreadable or gone. */
|
||||
SOURCE_UNREADABLE(false);
|
||||
|
||||
private final boolean retryable;
|
||||
|
||||
Kind(boolean retryable) {
|
||||
this.retryable = retryable;
|
||||
}
|
||||
|
||||
public boolean isRetryable() {
|
||||
return retryable;
|
||||
}
|
||||
}
|
||||
|
||||
private final Kind kind;
|
||||
|
||||
public ProviderException(Kind kind, String message) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public ProviderException(Kind kind, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.kind = kind;
|
||||
}
|
||||
|
||||
public boolean isRetryable() {
|
||||
return kind.isRetryable();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Creates providers of one kind. Registered in a {@link ProviderCatalog} at
|
||||
* start-up; that registration is the only place a new backend has to be named.
|
||||
*/
|
||||
public interface ProviderFactory {
|
||||
|
||||
ProviderDescriptor descriptor();
|
||||
|
||||
/**
|
||||
* Builds a provider bound to these credentials. Cheap and non-blocking: no
|
||||
* network call happens until {@link PhotoProvider#identify()} or a real
|
||||
* request, because this runs while the settings screen is being built.
|
||||
*/
|
||||
PhotoProvider connect(ProviderConnection connection);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Stable key for one backend implementation, for example {@code immich}.
|
||||
*
|
||||
* <p>This value is written to the config file, so it must not change once a
|
||||
* version has shipped.
|
||||
*/
|
||||
public record ProviderId(String key) implements Comparable<ProviderId> {
|
||||
|
||||
public ProviderId {
|
||||
Objects.requireNonNull(key, "key");
|
||||
key = key.trim().toLowerCase(Locale.ROOT);
|
||||
if (key.isEmpty()) {
|
||||
throw new IllegalArgumentException("A provider id must not be blank");
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compareTo(ProviderId other) {
|
||||
return key.compareTo(other.key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return key;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Proof that the configured credentials work, and something human-readable to
|
||||
* show next to the "Test connection" button.
|
||||
*/
|
||||
public record ProviderIdentity(String serverVersion, String accountName) {
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The one live {@link PhotoProvider}, rebuilt whenever the credentials change.
|
||||
*
|
||||
* <p>Uploads and browsing share it, so they also share its connection pool
|
||||
* rather than each opening their own. It knows nothing about the config file --
|
||||
* it is <em>told</em> to reconfigure -- which keeps the provider package free of
|
||||
* a dependency on the config package.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ProviderSession implements AutoCloseable {
|
||||
|
||||
private final ProviderCatalog catalog;
|
||||
private final Object lock = new Object();
|
||||
|
||||
private ProviderId currentId;
|
||||
private ProviderConnection currentConnection = ProviderConnection.empty();
|
||||
private PhotoProvider provider;
|
||||
|
||||
public ProviderSession(ProviderCatalog catalog) {
|
||||
this.catalog = catalog;
|
||||
this.currentId = catalog.preferred();
|
||||
}
|
||||
|
||||
/** Cheap and idempotent: unchanged credentials leave the provider alone. */
|
||||
public void configure(ProviderId id, ProviderConnection connection) {
|
||||
PhotoProvider discarded = null;
|
||||
synchronized (lock) {
|
||||
if (Objects.equals(currentId, id) && Objects.equals(currentConnection, connection)) {
|
||||
return;
|
||||
}
|
||||
discarded = provider;
|
||||
currentId = id;
|
||||
currentConnection = connection;
|
||||
provider = connection.isConfigured() ? catalog.connect(id, connection) : null;
|
||||
}
|
||||
if (discarded != null) {
|
||||
closeQuietly(discarded);
|
||||
}
|
||||
}
|
||||
|
||||
/** Empty until the player has entered a server and a key. */
|
||||
public Optional<PhotoProvider> provider() {
|
||||
synchronized (lock) {
|
||||
return Optional.ofNullable(provider);
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isConfigured() {
|
||||
synchronized (lock) {
|
||||
return provider != null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A throwaway provider for the settings screen's "test connection" button,
|
||||
* so the player can check credentials before committing them.
|
||||
*/
|
||||
public PhotoProvider probe(ProviderId id, ProviderConnection connection) {
|
||||
return catalog.connect(id, connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
PhotoProvider discarded;
|
||||
synchronized (lock) {
|
||||
discarded = provider;
|
||||
provider = null;
|
||||
}
|
||||
if (discarded != null) {
|
||||
closeQuietly(discarded);
|
||||
}
|
||||
}
|
||||
|
||||
private static void closeQuietly(PhotoProvider target) {
|
||||
try {
|
||||
target.close();
|
||||
} catch (RuntimeException e) {
|
||||
log.debug("Closing a provider threw", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One item in the remote timeline, reduced to what the grid actually draws.
|
||||
*
|
||||
* <p>{@code localCapturedAt} is the wall-clock time where the photo was taken,
|
||||
* not where the player is sitting now. Day grouping uses it, so a screenshot
|
||||
* taken at 00:30 in Tokyo stays on the Tokyo day even when browsed from Berlin
|
||||
* -- which is what every photo app does and what users expect.
|
||||
*
|
||||
* <p>{@code aspectRatio} arrives with the timeline page, before any image data,
|
||||
* so the grid can lay out and reserve space without a single thumbnail request.
|
||||
*/
|
||||
public record RemoteAsset(
|
||||
String id,
|
||||
AssetKind kind,
|
||||
Instant capturedAt,
|
||||
LocalDateTime localCapturedAt,
|
||||
double aspectRatio,
|
||||
Optional<String> thumbHash,
|
||||
Duration duration) {
|
||||
|
||||
public RemoteAsset {
|
||||
thumbHash = thumbHash == null ? Optional.empty() : thumbHash.filter(hash -> !hash.isBlank());
|
||||
duration = duration == null ? Duration.ZERO : duration;
|
||||
// A zero or negative ratio would divide by zero in the layout pass.
|
||||
if (!(aspectRatio > 0) || Double.isInfinite(aspectRatio)) {
|
||||
aspectRatio = 1.0;
|
||||
}
|
||||
}
|
||||
|
||||
public LocalDate localDay() {
|
||||
return localCapturedAt.toLocalDate();
|
||||
}
|
||||
|
||||
public boolean isVideo() {
|
||||
return kind == AssetKind.VIDEO;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Named after what we use the image for rather than after a pixel count, since
|
||||
* every backend has its own idea of what "small" means.
|
||||
*/
|
||||
public enum ThumbnailSize {
|
||||
/** The tile in the timeline grid. */
|
||||
GRID,
|
||||
/** The single-photo view opened by clicking a tile. */
|
||||
DETAIL
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* A month of the remote timeline, and how many assets are in it.
|
||||
*
|
||||
* <p>This is the whole trick behind the virtual list: the backend hands us every
|
||||
* bucket's <em>count</em> in one cheap call, so the scrollbar and the total
|
||||
* height are exact before a single asset has been fetched. Pages are then loaded
|
||||
* only for the months the viewport actually reaches.
|
||||
*
|
||||
* <p>{@code key} is the backend's own opaque token for the bucket and is passed
|
||||
* straight back when requesting its page; {@code month} is the parsed form the
|
||||
* UI sorts and labels with.
|
||||
*/
|
||||
public record TimelineBucket(String key, LocalDate month, int assetCount) implements Comparable<TimelineBucket> {
|
||||
|
||||
/** Newest first, matching how the timeline is displayed. */
|
||||
@Override
|
||||
public int compareTo(TimelineBucket other) {
|
||||
return other.month.compareTo(month);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Thrown out of a {@link TransferProgress} callback to abandon a transfer that
|
||||
* is already on the wire.
|
||||
*
|
||||
* <p>This is how "remove from queue" works on an upload that has started: the
|
||||
* queue flags the job, the next progress callback throws, and the body stream
|
||||
* unwinds. Providers must let it escape rather than wrapping it in a
|
||||
* {@link ProviderException} -- a cancelled upload is not a failure and must not
|
||||
* be retried.
|
||||
*
|
||||
* <p>Unchecked, because it travels through {@link TransferProgress}, which has
|
||||
* no business declaring it.
|
||||
*/
|
||||
public final class TransferCancelledException extends RuntimeException {
|
||||
|
||||
public TransferCancelledException(String jobId) {
|
||||
super("Transfer " + jobId + " was cancelled", null, false, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/**
|
||||
* Byte-level progress callback for an in-flight upload.
|
||||
*
|
||||
* <p>Called from the upload worker thread, possibly very often. Implementations
|
||||
* must not block and must not touch the render thread directly.
|
||||
*
|
||||
* <p>Throwing {@link TransferCancelledException} from here aborts the transfer.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface TransferProgress {
|
||||
|
||||
void onProgress(long bytesTransferred, long totalBytes);
|
||||
|
||||
static TransferProgress ignored() {
|
||||
return (transferred, total) -> {
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
/** What the backend did with an {@link UploadRequest}. */
|
||||
public record UploadReceipt(String assetId, Outcome outcome) {
|
||||
|
||||
public enum Outcome {
|
||||
/** A new asset was stored. */
|
||||
CREATED,
|
||||
/**
|
||||
* The backend already had a byte-identical asset and kept the original.
|
||||
* This is a success, not an error: it is exactly what we want to happen
|
||||
* when a retry follows an upload whose response we never saw.
|
||||
*/
|
||||
DUPLICATE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package dev.photosync.core.provider;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One file to push to the backend.
|
||||
*
|
||||
* <p>Carries no checksum and no device identifier: those are backend-specific
|
||||
* and are derived inside the provider that needs them.
|
||||
*/
|
||||
public record UploadRequest(
|
||||
Path file,
|
||||
String fileName,
|
||||
Instant capturedAt,
|
||||
Instant modifiedAt,
|
||||
AlbumRef album) {
|
||||
|
||||
public UploadRequest {
|
||||
album = album == null ? AlbumRef.library() : album;
|
||||
modifiedAt = modifiedAt == null ? capturedAt : modifiedAt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.TransferCancelledException;
|
||||
import dev.photosync.core.provider.TransferProgress;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Type;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.StringJoiner;
|
||||
|
||||
/**
|
||||
* Everything HTTP about talking to Immich: URL shape, authentication, timeouts,
|
||||
* and turning a status code into a {@link ProviderException} the upload queue
|
||||
* can act on.
|
||||
*
|
||||
* <p>Keeping this apart from {@link ImmichProvider} means the provider reads as
|
||||
* a list of API calls and their mapping to PhotoSync's model, with no transport
|
||||
* noise in between.
|
||||
*/
|
||||
final class ImmichApi {
|
||||
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(30);
|
||||
/** Uploads get their own budget: a screenshot over a slow uplink is not a hung server. */
|
||||
private static final Duration UPLOAD_TIMEOUT = Duration.ofMinutes(10);
|
||||
|
||||
private final HttpClient http;
|
||||
private final URI root;
|
||||
private final String apiKey;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
ImmichApi(ProviderConnection connection) {
|
||||
this.root = apiRoot(connection.endpoint());
|
||||
this.apiKey = connection.secret();
|
||||
this.http = HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
// HTTP/1.1 on purpose. Immich is nearly always behind a
|
||||
// self-hosted reverse proxy, and multipart uploads over an
|
||||
// upgraded HTTP/2 connection are the first thing to break when
|
||||
// that proxy is misconfigured.
|
||||
.version(HttpClient.Version.HTTP_1_1)
|
||||
.followRedirects(HttpClient.Redirect.NORMAL)
|
||||
.build();
|
||||
}
|
||||
|
||||
URI root() {
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns whatever the player typed into the API root.
|
||||
*
|
||||
* <p>People paste {@code immich.example.com}, {@code https://immich.example.com/},
|
||||
* and {@code https://example.com/immich/api} in roughly equal measure, and
|
||||
* being told "connection failed" because of a missing scheme is a miserable
|
||||
* first five minutes with a mod.
|
||||
*/
|
||||
static URI apiRoot(String endpoint) {
|
||||
String value = endpoint.trim();
|
||||
if (!value.matches("(?i)^[a-z][a-z0-9+.-]*://.*")) {
|
||||
value = "https://" + value;
|
||||
}
|
||||
while (value.endsWith("/")) {
|
||||
value = value.substring(0, value.length() - 1);
|
||||
}
|
||||
if (!value.endsWith("/api")) {
|
||||
value = value + "/api";
|
||||
}
|
||||
return URI.create(value);
|
||||
}
|
||||
|
||||
<T> T get(String path, Map<String, String> query, Type type) throws ProviderException {
|
||||
return decode(send(request(path, query, REQUEST_TIMEOUT).GET().build()), type);
|
||||
}
|
||||
|
||||
byte[] getBytes(String path, Map<String, String> query) throws ProviderException {
|
||||
return send(request(path, query, REQUEST_TIMEOUT).GET().build());
|
||||
}
|
||||
|
||||
<T> T post(String path, Object body, Type type) throws ProviderException {
|
||||
HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
return decode(send(request), type);
|
||||
}
|
||||
|
||||
<T> T put(String path, Object body, Type type) throws ProviderException {
|
||||
HttpRequest request = request(path, Map.of(), REQUEST_TIMEOUT)
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(gson.toJson(body), StandardCharsets.UTF_8))
|
||||
.build();
|
||||
return decode(send(request), type);
|
||||
}
|
||||
|
||||
<T> T upload(String path, MultipartBody body, String checksumBase64, TransferProgress progress, Type type)
|
||||
throws ProviderException {
|
||||
HttpRequest.Builder builder = request(path, Map.of(), UPLOAD_TIMEOUT)
|
||||
.header("Content-Type", body.contentType())
|
||||
// Lets the server recognise bytes it already has and answer
|
||||
// "duplicate" instead of storing a second copy -- which is what
|
||||
// makes retrying an interrupted upload safe.
|
||||
.header("x-immich-checksum", checksumBase64)
|
||||
.POST(body.publisher(progress));
|
||||
return decode(send(builder.build()), type);
|
||||
}
|
||||
|
||||
private HttpRequest.Builder request(String path, Map<String, String> query, Duration timeout) {
|
||||
return HttpRequest.newBuilder(uri(path, query))
|
||||
.header("x-api-key", apiKey)
|
||||
.header("Accept", "application/json")
|
||||
.timeout(timeout);
|
||||
}
|
||||
|
||||
private URI uri(String path, Map<String, String> query) {
|
||||
StringBuilder url = new StringBuilder(root.toString()).append(path);
|
||||
if (!query.isEmpty()) {
|
||||
StringJoiner joiner = new StringJoiner("&");
|
||||
query.forEach((key, value) -> joiner.add(encode(key) + "=" + encode(value)));
|
||||
url.append('?').append(joiner);
|
||||
}
|
||||
return URI.create(url.toString());
|
||||
}
|
||||
|
||||
private static String encode(String value) {
|
||||
return URLEncoder.encode(value, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private byte[] send(HttpRequest request) throws ProviderException {
|
||||
try {
|
||||
HttpResponse<byte[]> response = http.send(request, HttpResponse.BodyHandlers.ofByteArray());
|
||||
int status = response.statusCode();
|
||||
if (status >= 200 && status < 300) {
|
||||
return response.body();
|
||||
}
|
||||
throw failure(status, response.body());
|
||||
} catch (IOException e) {
|
||||
// A cancelled transfer unwinds through the request body stream, and
|
||||
// the client hands it back wrapped. It is not a network failure and
|
||||
// must not be retried, so dig it out before classifying anything.
|
||||
TransferCancelledException cancelled = findCancellation(e);
|
||||
if (cancelled != null) {
|
||||
throw cancelled;
|
||||
}
|
||||
throw new ProviderException(ProviderException.Kind.NETWORK,
|
||||
"Could not reach " + root.getHost() + ": " + e.getMessage(), e);
|
||||
} catch (UncheckedIOException e) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE,
|
||||
"Could not read the file to upload: " + e.getMessage(), e);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ProviderException(ProviderException.Kind.NETWORK, "Interrupted while waiting for Immich", e);
|
||||
}
|
||||
}
|
||||
|
||||
private ProviderException failure(int status, byte[] body) {
|
||||
String detail = describe(status, body);
|
||||
ProviderException.Kind kind = switch (status) {
|
||||
case 401, 403 -> ProviderException.Kind.AUTHENTICATION;
|
||||
case 404 -> ProviderException.Kind.NOT_FOUND;
|
||||
case 408, 429 -> ProviderException.Kind.RATE_LIMITED;
|
||||
default -> status >= 500 ? ProviderException.Kind.SERVER : ProviderException.Kind.PROTOCOL;
|
||||
};
|
||||
return new ProviderException(kind, detail);
|
||||
}
|
||||
|
||||
/** Immich answers errors as JSON; fall back to the raw text when it does not. */
|
||||
private String describe(int status, byte[] body) {
|
||||
String text = new String(body, StandardCharsets.UTF_8).trim();
|
||||
Optional<String> message = Optional.empty();
|
||||
if (text.startsWith("{")) {
|
||||
try {
|
||||
ImmichDtos.ApiError error = gson.fromJson(text, ImmichDtos.ApiError.class);
|
||||
message = Optional.ofNullable(error).map(ImmichDtos.ApiError::message);
|
||||
} catch (JsonSyntaxException ignored) {
|
||||
// Fall through to the raw body below.
|
||||
}
|
||||
}
|
||||
String summary = message.filter(value -> !value.isBlank())
|
||||
.orElseGet(() -> text.isEmpty() ? "no details" : abbreviate(text));
|
||||
return "Immich returned HTTP " + status + " (" + summary + ")";
|
||||
}
|
||||
|
||||
private <T> T decode(byte[] body, Type type) throws ProviderException {
|
||||
try {
|
||||
T value = gson.fromJson(new String(body, StandardCharsets.UTF_8), type);
|
||||
if (value == null) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich returned an empty response");
|
||||
}
|
||||
return value;
|
||||
} catch (JsonSyntaxException e) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL,
|
||||
"Immich returned something that is not the JSON we expected", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static TransferCancelledException findCancellation(Throwable thrown) {
|
||||
for (Throwable cause = thrown; cause != null; cause = cause.getCause()) {
|
||||
if (cause instanceof TransferCancelledException cancelled) {
|
||||
return cancelled;
|
||||
}
|
||||
if (cause.getCause() == cause) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String abbreviate(String text) {
|
||||
String flat = text.replaceAll("\\s+", " ");
|
||||
return flat.length() <= 160 ? flat : flat.substring(0, 157) + "...";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The slices of the Immich API that PhotoSync actually reads, mirrored as
|
||||
* records for Gson.
|
||||
*
|
||||
* <p>Written against Immich's published OpenAPI document, version 3.1.0. Fields
|
||||
* the mod does not use are simply absent -- Gson ignores unknown JSON members,
|
||||
* so the smaller this file is, the fewer ways a server upgrade can break us.
|
||||
*
|
||||
* <p>Two absences are deliberate rather than incidental:
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code duration} is not read. It has changed representation between
|
||||
* Immich releases (string, then seconds) and the only thing PhotoSync
|
||||
* would do with it is decorate a badge it already draws from
|
||||
* {@code isImage}.</li>
|
||||
* <li>Boxed types throughout, because every one of these arrays is declared
|
||||
* nullable somewhere in the schema, and an older server that omits one
|
||||
* should degrade rather than throw.</li>
|
||||
* </ul>
|
||||
*/
|
||||
final class ImmichDtos {
|
||||
|
||||
private ImmichDtos() {
|
||||
}
|
||||
|
||||
record ServerAbout(String version) {
|
||||
}
|
||||
|
||||
record UserProfile(String name, String email) {
|
||||
}
|
||||
|
||||
record AlbumSummary(String id, String albumName, Integer assetCount, String albumThumbnailAssetId) {
|
||||
}
|
||||
|
||||
record CreateAlbum(String albumName) {
|
||||
}
|
||||
|
||||
/** {@code status} is {@code created} or {@code duplicate}. */
|
||||
record AssetMediaResponse(String id, String status) {
|
||||
}
|
||||
|
||||
record BulkIds(List<String> ids) {
|
||||
}
|
||||
|
||||
record BulkIdResponse(String id, Boolean success, String error) {
|
||||
}
|
||||
|
||||
/** {@code timeBucket} is an opaque token; we hand it straight back to the server. */
|
||||
record TimeBucket(String timeBucket, Integer count) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Immich returns a bucket columnwise: one array per attribute, all the same
|
||||
* length, indexed in parallel. It is an odd shape for a REST API and a very
|
||||
* good one for this use case -- a thousand assets arrive without a thousand
|
||||
* repetitions of every key name.
|
||||
*/
|
||||
record BucketAssets(
|
||||
List<String> id,
|
||||
List<String> fileCreatedAt,
|
||||
List<Double> localOffsetHours,
|
||||
List<Double> ratio,
|
||||
List<String> thumbhash,
|
||||
List<Boolean> isImage) {
|
||||
|
||||
int size() {
|
||||
return id == null ? 0 : id.size();
|
||||
}
|
||||
}
|
||||
|
||||
/** The body Immich returns on an error, when it returns one at all. */
|
||||
record ApiError(String message, String error, Integer statusCode) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.photosync.core.provider.Album;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.AssetKind;
|
||||
import dev.photosync.core.provider.BucketPage;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderDescriptor;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderIdentity;
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.ThumbnailSize;
|
||||
import dev.photosync.core.provider.TimelineBucket;
|
||||
import dev.photosync.core.provider.TransferProgress;
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
import dev.photosync.core.provider.UploadRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.lang.reflect.Type;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Immich, expressed in PhotoSync's terms.
|
||||
*
|
||||
* <p>Two Immich design choices carry the whole browsing experience and are worth
|
||||
* naming. Its timeline is exposed as monthly buckets with counts, so the grid
|
||||
* knows its exact total height before fetching a single asset -- that is what
|
||||
* makes a real virtual list possible rather than an endless scroll. And each
|
||||
* bucket page carries a per-asset aspect ratio and thumbhash, so a tile can be
|
||||
* laid out and filled with a recognisable blur before its image request is even
|
||||
* queued.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ImmichProvider implements PhotoProvider {
|
||||
|
||||
private static final Type ALBUM_LIST = new TypeToken<List<ImmichDtos.AlbumSummary>>() {
|
||||
}.getType();
|
||||
private static final Type BUCKET_LIST = new TypeToken<List<ImmichDtos.TimeBucket>>() {
|
||||
}.getType();
|
||||
private static final Type BULK_RESULT = new TypeToken<List<ImmichDtos.BulkIdResponse>>() {
|
||||
}.getType();
|
||||
|
||||
private final ProviderDescriptor descriptor;
|
||||
private final ImmichApi api;
|
||||
|
||||
/**
|
||||
* Whether this server's small thumbnails are usable, learned from the first
|
||||
* one we fetch. See {@link #thumbnail}.
|
||||
*/
|
||||
private volatile boolean smallThumbnails = true;
|
||||
|
||||
ImmichProvider(ProviderDescriptor descriptor, ProviderConnection connection) {
|
||||
this.descriptor = descriptor;
|
||||
this.api = new ImmichApi(connection);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public ProviderIdentity identify() throws ProviderException {
|
||||
// The profile call first: a wrong API key should say "not authorised",
|
||||
// not "could not read the version".
|
||||
ImmichDtos.UserProfile me = api.get("/users/me", Map.of(), ImmichDtos.UserProfile.class);
|
||||
ImmichDtos.ServerAbout about = api.get("/server/about", Map.of(), ImmichDtos.ServerAbout.class);
|
||||
String account = Optional.ofNullable(me.name())
|
||||
.filter(name -> !name.isBlank())
|
||||
.orElseGet(() -> Optional.ofNullable(me.email()).orElse("unknown"));
|
||||
return new ProviderIdentity(Optional.ofNullable(about.version()).orElse("unknown"), account);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Album> albums() throws ProviderException {
|
||||
List<ImmichDtos.AlbumSummary> response = api.get("/albums", Map.of(), ALBUM_LIST);
|
||||
return response.stream()
|
||||
.filter(summary -> summary.id() != null)
|
||||
.map(summary -> new Album(
|
||||
summary.id(),
|
||||
Optional.ofNullable(summary.albumName()).orElse(summary.id()),
|
||||
Optional.ofNullable(summary.assetCount()).orElse(0),
|
||||
Optional.ofNullable(summary.albumThumbnailAssetId())))
|
||||
.sorted(Comparator.comparing(Album::name, String.CASE_INSENSITIVE_ORDER))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Album createAlbum(String name) throws ProviderException {
|
||||
ImmichDtos.AlbumSummary created =
|
||||
api.post("/albums", new ImmichDtos.CreateAlbum(name), ImmichDtos.AlbumSummary.class);
|
||||
if (created.id() == null) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich created an album without an id");
|
||||
}
|
||||
return new Album(created.id(), name, 0, Optional.empty());
|
||||
}
|
||||
|
||||
@Override
|
||||
public UploadReceipt upload(UploadRequest request, TransferProgress progress) throws ProviderException {
|
||||
Path file = request.file();
|
||||
if (!Files.isReadable(file)) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file);
|
||||
}
|
||||
|
||||
MultipartBody body;
|
||||
try {
|
||||
body = MultipartBody.with(file, "assetData", request.fileName(), mediaTypeOf(request.fileName()))
|
||||
.field("fileCreatedAt", request.capturedAt().toString())
|
||||
.field("fileModifiedAt", request.modifiedAt().toString())
|
||||
.field("filename", request.fileName())
|
||||
.build();
|
||||
} catch (IOException e) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
|
||||
}
|
||||
|
||||
ImmichDtos.AssetMediaResponse response =
|
||||
api.upload("/assets", body, sha1Base64(file), progress, ImmichDtos.AssetMediaResponse.class);
|
||||
if (response.id() == null) {
|
||||
throw new ProviderException(ProviderException.Kind.PROTOCOL, "Immich accepted the upload without an id");
|
||||
}
|
||||
|
||||
UploadReceipt.Outcome outcome = "duplicate".equalsIgnoreCase(response.status())
|
||||
? UploadReceipt.Outcome.DUPLICATE
|
||||
: UploadReceipt.Outcome.CREATED;
|
||||
|
||||
Optional<String> album = request.album().id();
|
||||
if (album.isPresent()) {
|
||||
addToAlbum(album.get(), response.id());
|
||||
}
|
||||
return new UploadReceipt(response.id(), outcome);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<TimelineBucket> timeline(AlbumRef album) throws ProviderException {
|
||||
List<ImmichDtos.TimeBucket> response = api.get("/timeline/buckets", scope(album), BUCKET_LIST);
|
||||
return response.stream()
|
||||
.filter(bucket -> bucket.timeBucket() != null)
|
||||
.map(bucket -> monthOf(bucket.timeBucket())
|
||||
.map(month -> new TimelineBucket(
|
||||
bucket.timeBucket(), month, Math.max(0, Optional.ofNullable(bucket.count()).orElse(0)))))
|
||||
.flatMap(Optional::stream)
|
||||
.filter(bucket -> bucket.assetCount() > 0)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public BucketPage page(AlbumRef album, TimelineBucket bucket) throws ProviderException {
|
||||
Map<String, String> query = scope(album);
|
||||
query.put("timeBucket", bucket.key());
|
||||
ImmichDtos.BucketAssets columns = api.get("/timeline/bucket", query, ImmichDtos.BucketAssets.class);
|
||||
|
||||
int count = columns.size();
|
||||
List<RemoteAsset> assets = new ArrayList<>(count);
|
||||
for (int index = 0; index < count; index++) {
|
||||
String id = columns.id().get(index);
|
||||
if (id == null) {
|
||||
continue;
|
||||
}
|
||||
Optional<Instant> takenAt = instantOf(column(columns.fileCreatedAt(), index));
|
||||
if (takenAt.isEmpty()) {
|
||||
// Without a timestamp the asset has no day to live under.
|
||||
continue;
|
||||
}
|
||||
double offsetHours = column(columns.localOffsetHours(), index, 0.0);
|
||||
boolean image = column(columns.isImage(), index, Boolean.TRUE);
|
||||
assets.add(new RemoteAsset(
|
||||
id,
|
||||
image ? AssetKind.IMAGE : AssetKind.VIDEO,
|
||||
takenAt.get(),
|
||||
localTimeOf(takenAt.get(), offsetHours),
|
||||
column(columns.ratio(), index, 1.0),
|
||||
Optional.ofNullable(column(columns.thumbhash(), index)),
|
||||
Duration.ZERO));
|
||||
}
|
||||
assets.sort(Comparator.comparing(RemoteAsset::localCapturedAt).reversed());
|
||||
return new BucketPage(bucket, assets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] thumbnail(String assetId, ThumbnailSize size) throws ProviderException {
|
||||
// Videos have no still of their own to serve, but Immich renders one for
|
||||
// them at the same endpoint -- which is exactly what a video tile needs.
|
||||
String path = "/assets/" + assetId + "/thumbnail";
|
||||
if (size == ThumbnailSize.DETAIL || !smallThumbnails) {
|
||||
return api.getBytes(path, Map.of("size", "preview"));
|
||||
}
|
||||
byte[] small = api.getBytes(path, Map.of("size", "thumbnail"));
|
||||
if (!isWebP(small)) {
|
||||
return small;
|
||||
}
|
||||
// Immich's default thumbnail format is WebP, which the game's image
|
||||
// decoder cannot read; its previews default to JPEG, which it can. One
|
||||
// wasted request per session buys correct tiles on those servers, and
|
||||
// servers already configured for JPEG never take this branch.
|
||||
log.info("This Immich server serves WebP thumbnails; falling back to previews for the grid");
|
||||
smallThumbnails = false;
|
||||
return api.getBytes(path, Map.of("size", "preview"));
|
||||
}
|
||||
|
||||
/** RIFF container with a WEBP fourcc, per the WebP specification. */
|
||||
private static boolean isWebP(byte[] bytes) {
|
||||
return bytes.length >= 12
|
||||
&& bytes[0] == 'R' && bytes[1] == 'I' && bytes[2] == 'F' && bytes[3] == 'F'
|
||||
&& bytes[8] == 'W' && bytes[9] == 'E' && bytes[10] == 'B' && bytes[11] == 'P';
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// Nothing to release: java.net.http.HttpClient has no close() before
|
||||
// Java 21, and we compile this module to 17. It uses daemon threads and
|
||||
// is collected with the provider.
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void addToAlbum(String albumId, String assetId) throws ProviderException {
|
||||
List<ImmichDtos.BulkIdResponse> results =
|
||||
api.put("/albums/" + albumId + "/assets", new ImmichDtos.BulkIds(List.of(assetId)), BULK_RESULT);
|
||||
for (ImmichDtos.BulkIdResponse result : results) {
|
||||
boolean added = Boolean.TRUE.equals(result.success());
|
||||
if (!added && !"duplicate".equalsIgnoreCase(result.error())) {
|
||||
// The asset itself is safely stored, so this is a warning rather
|
||||
// than a failure -- but the player asked for an album, so say so.
|
||||
log.warn("Uploaded {} but Immich would not add it to album {}: {}",
|
||||
assetId, albumId, Optional.ofNullable(result.error()).orElse("no reason given"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Album filter plus the flags that keep archived and trashed assets out of the library view. */
|
||||
private static Map<String, String> scope(AlbumRef album) {
|
||||
Map<String, String> query = new LinkedHashMap<>();
|
||||
album.id().ifPresent(id -> query.put("albumId", id));
|
||||
if (album.isLibrary()) {
|
||||
query.put("visibility", "timeline");
|
||||
}
|
||||
query.put("order", "desc");
|
||||
return query;
|
||||
}
|
||||
|
||||
/**
|
||||
* Immich has returned the bucket token as both {@code 2024-01-01} and
|
||||
* {@code 2024-01-01T00:00:00.000Z} across releases. Only the date part is
|
||||
* ever meaningful, and the token itself goes back to the server untouched.
|
||||
*/
|
||||
private static Optional<LocalDate> monthOf(String bucketToken) {
|
||||
if (bucketToken.length() < 10) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(LocalDate.parse(bucketToken.substring(0, 10)));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("Ignoring bucket with an unparseable token: {}", bucketToken);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static Optional<Instant> instantOf(String iso) {
|
||||
if (iso == null) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(Instant.parse(iso));
|
||||
} catch (DateTimeParseException e) {
|
||||
try {
|
||||
return Optional.of(LocalDateTime.parse(iso).toInstant(ZoneOffset.UTC));
|
||||
} catch (DateTimeParseException ignored) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The wall-clock time where the photo was taken. Immich stores the capture
|
||||
* instant in UTC alongside the offset that was in force, and some zones are
|
||||
* on a fractional hour, so the offset is converted through seconds.
|
||||
*/
|
||||
private static LocalDateTime localTimeOf(Instant instant, double offsetHours) {
|
||||
double clamped = Math.max(-18.0, Math.min(18.0, offsetHours));
|
||||
return instant.atOffset(ZoneOffset.ofTotalSeconds((int) Math.round(clamped * 3600))).toLocalDateTime();
|
||||
}
|
||||
|
||||
private static <T> T column(List<T> values, int index) {
|
||||
return values == null || index >= values.size() ? null : values.get(index);
|
||||
}
|
||||
|
||||
private static <T> T column(List<T> values, int index, T fallback) {
|
||||
T value = column(values, index);
|
||||
return value == null ? fallback : value;
|
||||
}
|
||||
|
||||
private static String mediaTypeOf(String fileName) {
|
||||
String lower = fileName.toLowerCase(Locale.ROOT);
|
||||
if (lower.endsWith(".png")) {
|
||||
return "image/png";
|
||||
}
|
||||
if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) {
|
||||
return "image/jpeg";
|
||||
}
|
||||
if (lower.endsWith(".webp")) {
|
||||
return "image/webp";
|
||||
}
|
||||
return "application/octet-stream";
|
||||
}
|
||||
|
||||
/**
|
||||
* SHA-1 of the file, base64 encoded, for the {@code x-immich-checksum}
|
||||
* header. Reading the screenshot a second time costs a millisecond and buys
|
||||
* the server the ability to recognise a retry of an upload it already has.
|
||||
*/
|
||||
private static String sha1Base64(Path file) throws ProviderException {
|
||||
try (InputStream stream = Files.newInputStream(file)) {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-1");
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
int read;
|
||||
while ((read = stream.read(buffer)) > 0) {
|
||||
digest.update(buffer, 0, read);
|
||||
}
|
||||
return Base64.getEncoder().encodeToString(digest.digest());
|
||||
} catch (IOException e) {
|
||||
throw new ProviderException(ProviderException.Kind.SOURCE_UNREADABLE, "Cannot read " + file, e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("Every JVM is required to provide SHA-1", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderDescriptor;
|
||||
import dev.photosync.core.provider.ProviderFactory;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
|
||||
/**
|
||||
* Registers Immich with the {@code ProviderCatalog}.
|
||||
*
|
||||
* <p>The one place in the mod where the string "immich" appears outside a
|
||||
* language file. A second backend is a second class like this one.
|
||||
*/
|
||||
public final class ImmichProviderFactory implements ProviderFactory {
|
||||
|
||||
private final ProviderDescriptor descriptor =
|
||||
new ProviderDescriptor(new ProviderId("immich"), "photosync.provider.immich", true);
|
||||
|
||||
@Override
|
||||
public ProviderDescriptor descriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PhotoProvider connect(ProviderConnection connection) {
|
||||
return new ImmichProvider(descriptor, connection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package dev.photosync.core.provider.immich;
|
||||
|
||||
import dev.photosync.core.provider.TransferProgress;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.FilterInputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.SequenceInputStream;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* A {@code multipart/form-data} body that streams one file from disk.
|
||||
*
|
||||
* <p>The obvious implementation -- read the screenshot into a byte array and
|
||||
* hand it to {@code BodyPublishers.ofByteArray} -- would double the mod's peak
|
||||
* memory for every concurrent upload, on a heap the game is already competing
|
||||
* for. So the body is assembled as a preamble, the file, and an epilogue, and
|
||||
* the file part is never held in memory.
|
||||
*
|
||||
* <p>The length is computed up front so the request can carry a real
|
||||
* {@code Content-Length}. Streaming with an unknown length would force chunked
|
||||
* transfer encoding, which self-hosted reverse proxies in front of Immich are
|
||||
* not reliably configured for.
|
||||
*/
|
||||
final class MultipartBody {
|
||||
|
||||
/** Report progress at most this often, so a fast local server is not drowned in callbacks. */
|
||||
private static final int PROGRESS_STRIDE_BYTES = 64 * 1024;
|
||||
|
||||
private final String boundary;
|
||||
private final byte[] preamble;
|
||||
private final byte[] epilogue;
|
||||
private final Path file;
|
||||
private final long fileSize;
|
||||
|
||||
private MultipartBody(String boundary, byte[] preamble, byte[] epilogue, Path file, long fileSize) {
|
||||
this.boundary = boundary;
|
||||
this.preamble = preamble;
|
||||
this.epilogue = epilogue;
|
||||
this.file = file;
|
||||
this.fileSize = fileSize;
|
||||
}
|
||||
|
||||
static Builder with(Path file, String fieldName, String fileName, String contentType) throws IOException {
|
||||
return new Builder(file, fieldName, fileName, contentType);
|
||||
}
|
||||
|
||||
String contentType() {
|
||||
return "multipart/form-data; boundary=" + boundary;
|
||||
}
|
||||
|
||||
long contentLength() {
|
||||
return preamble.length + fileSize + epilogue.length;
|
||||
}
|
||||
|
||||
HttpRequest.BodyPublisher publisher(TransferProgress progress) {
|
||||
long total = contentLength();
|
||||
return HttpRequest.BodyPublishers.fromPublisher(
|
||||
HttpRequest.BodyPublishers.ofInputStream(() -> open(progress, total)),
|
||||
total);
|
||||
}
|
||||
|
||||
private InputStream open(TransferProgress progress, long total) {
|
||||
try {
|
||||
InputStream body = new SequenceInputStream(Collections.enumeration(List.<InputStream>of(
|
||||
new ByteArrayInputStream(preamble),
|
||||
Files.newInputStream(file),
|
||||
new ByteArrayInputStream(epilogue))));
|
||||
return new CountingStream(body, progress, total);
|
||||
} catch (IOException e) {
|
||||
// The supplier cannot throw a checked exception; the HttpClient
|
||||
// unwraps this back into an IOException on send().
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts bytes as the HTTP client pulls them, and tells the queue.
|
||||
*
|
||||
* <p>The callback may throw -- that is how a cancelled upload unwinds -- so
|
||||
* it is invoked where the exception can propagate out of {@code read}.
|
||||
*/
|
||||
private static final class CountingStream extends FilterInputStream {
|
||||
|
||||
private final TransferProgress progress;
|
||||
private final long total;
|
||||
private long sent;
|
||||
private long lastReported;
|
||||
|
||||
CountingStream(InputStream delegate, TransferProgress progress, long total) {
|
||||
super(delegate);
|
||||
this.progress = progress;
|
||||
this.total = total;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
int value = super.read();
|
||||
if (value >= 0) {
|
||||
advance(1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(byte[] buffer, int offset, int length) throws IOException {
|
||||
int count = super.read(buffer, offset, length);
|
||||
if (count > 0) {
|
||||
advance(count);
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private void advance(int count) {
|
||||
sent += count;
|
||||
if (sent - lastReported >= PROGRESS_STRIDE_BYTES || sent == total) {
|
||||
lastReported = sent;
|
||||
progress.onProgress(sent, total);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static final class Builder {
|
||||
|
||||
private final String boundary = "PhotoSync" + UUID.randomUUID().toString().replace("-", "");
|
||||
private final List<String> fields = new ArrayList<>();
|
||||
private final Path file;
|
||||
private final String fieldName;
|
||||
private final String fileName;
|
||||
private final String contentType;
|
||||
private final long fileSize;
|
||||
|
||||
private Builder(Path file, String fieldName, String fileName, String contentType) throws IOException {
|
||||
this.file = file;
|
||||
this.fieldName = fieldName;
|
||||
this.fileName = fileName;
|
||||
this.contentType = contentType;
|
||||
this.fileSize = Files.size(file);
|
||||
}
|
||||
|
||||
Builder field(String name, String value) {
|
||||
fields.add("--" + boundary + "\r\n"
|
||||
+ "Content-Disposition: form-data; name=\"" + name + "\"\r\n\r\n"
|
||||
+ value + "\r\n");
|
||||
return this;
|
||||
}
|
||||
|
||||
MultipartBody build() {
|
||||
StringBuilder head = new StringBuilder();
|
||||
fields.forEach(head::append);
|
||||
head.append("--").append(boundary).append("\r\n")
|
||||
.append("Content-Disposition: form-data; name=\"").append(fieldName)
|
||||
.append("\"; filename=\"").append(escape(fileName)).append("\"\r\n")
|
||||
.append("Content-Type: ").append(contentType).append("\r\n\r\n");
|
||||
|
||||
return new MultipartBody(
|
||||
boundary,
|
||||
head.toString().getBytes(StandardCharsets.UTF_8),
|
||||
("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8),
|
||||
file,
|
||||
fileSize);
|
||||
}
|
||||
|
||||
/** Quotes and backslashes in a file name would end the header early. */
|
||||
private static String escape(String raw) {
|
||||
return raw.replace("\\", "\\\\").replace("\"", "\\\"").replace("\r", "").replace("\n", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Decoder for the ThumbHash placeholders Immich attaches to every asset.
|
||||
*
|
||||
* <p>A ThumbHash is around 25 bytes and already travels with the timeline
|
||||
* response, so the blurred stand-in for a tile costs no extra request -- the
|
||||
* grid can be fully painted the instant a month's metadata lands, and the real
|
||||
* thumbnails fade in behind it. That is what makes fast scrolling feel like the
|
||||
* web gallery this browser is imitating rather than a wall of grey boxes.
|
||||
*
|
||||
* <p>Only decoding lives here; the mod never produces hashes. The format is
|
||||
* Evan Wallace's, and the maths below deliberately mirrors the reference
|
||||
* implementation step for step so it can be diffed against it.
|
||||
*/
|
||||
public final class ThumbHash {
|
||||
|
||||
/** The reference decoder's output is normalised to fit in a 32x32 box. */
|
||||
private static final int MAX_EDGE = 32;
|
||||
|
||||
private ThumbHash() {
|
||||
}
|
||||
|
||||
/** Decodes the base64 form carried in the timeline response. */
|
||||
public static Optional<ThumbImage> decode(String base64) {
|
||||
if (base64 == null || base64.isBlank()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return decode(Base64.getDecoder().decode(base64.trim()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes the raw hash.
|
||||
*
|
||||
* <p>Returns empty rather than throwing for anything malformed: a bad
|
||||
* placeholder should cost the player a grey tile, never a crashed screen.
|
||||
*/
|
||||
public static Optional<ThumbImage> decode(byte[] hash) {
|
||||
if (hash == null || hash.length < 5) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
return Optional.of(decodeChecked(hash));
|
||||
} catch (ArrayIndexOutOfBoundsException e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static ThumbImage decodeChecked(byte[] hash) {
|
||||
int header24 = byteAt(hash, 0) | (byteAt(hash, 1) << 8) | (byteAt(hash, 2) << 16);
|
||||
int header16 = byteAt(hash, 3) | (byteAt(hash, 4) << 8);
|
||||
|
||||
float lDc = (header24 & 63) / 63f;
|
||||
float pDc = ((header24 >> 6) & 63) / 31.5f - 1f;
|
||||
float qDc = ((header24 >> 12) & 63) / 31.5f - 1f;
|
||||
float lScale = ((header24 >> 18) & 31) / 31f;
|
||||
boolean hasAlpha = (header24 >>> 23) != 0;
|
||||
float pScale = ((header16 >> 3) & 63) / 63f;
|
||||
float qScale = ((header16 >> 9) & 63) / 63f;
|
||||
boolean landscape = (header16 >>> 15) != 0;
|
||||
|
||||
int lx = Math.max(3, landscape ? (hasAlpha ? 5 : 7) : (header16 & 7));
|
||||
int ly = Math.max(3, landscape ? (header16 & 7) : (hasAlpha ? 5 : 7));
|
||||
|
||||
float aDc = hasAlpha ? (byteAt(hash, 5) & 15) / 15f : 1f;
|
||||
float aScale = hasAlpha ? (byteAt(hash, 5) >> 4) / 15f : 0f;
|
||||
|
||||
// Saturation is boosted by 1.25 on the chroma channels to undo the loss
|
||||
// from quantising them into four bits, exactly as the encoder expects.
|
||||
int[] cursor = {0};
|
||||
int acStart = hasAlpha ? 6 : 5;
|
||||
float[] lAc = readChannel(hash, acStart, cursor, lx, ly, lScale);
|
||||
float[] pAc = readChannel(hash, acStart, cursor, 3, 3, pScale * 1.25f);
|
||||
float[] qAc = readChannel(hash, acStart, cursor, 3, 3, qScale * 1.25f);
|
||||
float[] aAc = hasAlpha ? readChannel(hash, acStart, cursor, 5, 5, aScale) : new float[0];
|
||||
|
||||
// The aspect ratio comes from the unclamped counts, matching the encoder.
|
||||
float ratio = (float) (landscape ? (hasAlpha ? 5 : 7) : (header16 & 7))
|
||||
/ (landscape ? (header16 & 7) : (hasAlpha ? 5 : 7));
|
||||
int width = Math.max(1, Math.round(ratio > 1 ? MAX_EDGE : MAX_EDGE * ratio));
|
||||
int height = Math.max(1, Math.round(ratio > 1 ? MAX_EDGE / ratio : MAX_EDGE));
|
||||
|
||||
// The reference recomputes these cosines per pixel; hoisting them out
|
||||
// turns the inner loop into pure multiply-add.
|
||||
int fxCount = Math.max(lx, hasAlpha ? 5 : 3);
|
||||
int fyCount = Math.max(ly, hasAlpha ? 5 : 3);
|
||||
float[][] fxTable = cosineTable(width, fxCount);
|
||||
float[][] fyTable = cosineTable(height, fyCount);
|
||||
|
||||
int[] argb = new int[width * height];
|
||||
for (int y = 0; y < height; y++) {
|
||||
float[] fy = fyTable[y];
|
||||
for (int x = 0; x < width; x++) {
|
||||
float[] fx = fxTable[x];
|
||||
float l = lDc;
|
||||
float p = pDc;
|
||||
float q = qDc;
|
||||
float a = aDc;
|
||||
|
||||
for (int cy = 0, j = 0; cy < ly; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx * ly < lx * (ly - cy); cx++, j++) {
|
||||
l += lAc[j] * fx[cx] * fy[cy] * 2f;
|
||||
}
|
||||
}
|
||||
for (int cy = 0, j = 0; cy < 3; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx < 3 - cy; cx++, j++) {
|
||||
float f = fx[cx] * fy[cy] * 2f;
|
||||
p += pAc[j] * f;
|
||||
q += qAc[j] * f;
|
||||
}
|
||||
}
|
||||
if (hasAlpha) {
|
||||
for (int cy = 0, j = 0; cy < 5; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx < 5 - cy; cx++, j++) {
|
||||
a += aAc[j] * fx[cx] * fy[cy] * 2f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
float blue = l - 2f / 3f * p;
|
||||
float red = (3f * l - blue + q) / 2f;
|
||||
float green = red - q;
|
||||
argb[y * width + x] = (channel(a) << 24) | (channel(red) << 16)
|
||||
| (channel(green) << 8) | channel(blue);
|
||||
}
|
||||
}
|
||||
return new ThumbImage(width, height, argb);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads one channel's AC coefficients, four bits each, from a nibble stream
|
||||
* shared by all channels -- hence the caller-owned cursor.
|
||||
*/
|
||||
private static float[] readChannel(byte[] hash, int start, int[] cursor, int nx, int ny, float scale) {
|
||||
float[] ac = new float[nx * ny];
|
||||
int count = 0;
|
||||
for (int cy = 0; cy < ny; cy++) {
|
||||
for (int cx = cy > 0 ? 0 : 1; cx * ny < nx * (ny - cy); cx++) {
|
||||
int index = cursor[0]++;
|
||||
int nibble = (byteAt(hash, start + (index >> 1)) >> ((index & 1) << 2)) & 15;
|
||||
ac[count++] = (nibble / 7.5f - 1f) * scale;
|
||||
}
|
||||
}
|
||||
return Arrays.copyOf(ac, count);
|
||||
}
|
||||
|
||||
private static float[][] cosineTable(int size, int terms) {
|
||||
float[][] table = new float[size][terms];
|
||||
for (int i = 0; i < size; i++) {
|
||||
for (int c = 0; c < terms; c++) {
|
||||
table[i][c] = (float) Math.cos(Math.PI / size * (i + 0.5) * c);
|
||||
}
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private static int channel(float value) {
|
||||
return Math.max(0, Math.min(255, Math.round(value * 255f)));
|
||||
}
|
||||
|
||||
private static int byteAt(byte[] hash, int index) {
|
||||
return hash[index] & 0xFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
/**
|
||||
* A tiny decoded bitmap -- at most 32x32 -- in 0xAARRGGBB order.
|
||||
*
|
||||
* <p>Deliberately not a Minecraft {@code NativeImage}: this module has no
|
||||
* graphics dependencies, and the version adapters are the ones that know how to
|
||||
* turn a pixel array into a texture on their Minecraft version.
|
||||
*/
|
||||
public record ThumbImage(int width, int height, int[] argb) {
|
||||
|
||||
public ThumbImage {
|
||||
argb = argb.clone();
|
||||
}
|
||||
|
||||
public int pixel(int x, int y) {
|
||||
return argb[y * width + x];
|
||||
}
|
||||
|
||||
/** A defensive copy, because callers hand this straight to native buffers. */
|
||||
@Override
|
||||
public int[] argb() {
|
||||
return argb.clone();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.ThumbnailSize;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Fetches thumbnail bytes off the render thread, one request per asset no matter
|
||||
* how many tiles ask for it.
|
||||
*
|
||||
* <p>Decoding those bytes into a texture is deliberately <em>not</em> done here:
|
||||
* that has to happen on the render thread against a version-specific image type,
|
||||
* so the caller polls the returned future and uploads the result itself.
|
||||
*
|
||||
* <p>The loader refuses work once too many requests are outstanding. During a
|
||||
* fast scroll most tiles are on screen for a few frames and their thumbnails
|
||||
* would arrive long after the player has moved past them; dropping those
|
||||
* requests and letting the grid ask again when it settles keeps the queue short
|
||||
* and the visible tiles first in line.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ThumbnailLoader implements AutoCloseable {
|
||||
|
||||
/** Roughly two screens' worth of tiles. Past this, the queue is stale by the time it drains. */
|
||||
private static final int MAX_PENDING = 96;
|
||||
private static final int WORKERS = 3;
|
||||
|
||||
private final ProviderSession session;
|
||||
private final ExecutorService workers;
|
||||
private final Map<String, CompletableFuture<byte[]>> inFlight = new ConcurrentHashMap<>();
|
||||
|
||||
public ThumbnailLoader(ProviderSession session) {
|
||||
this.session = session;
|
||||
this.workers = Executors.newFixedThreadPool(WORKERS, daemonFactory());
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for one thumbnail.
|
||||
*
|
||||
* <p>Returns empty when the loader is saturated, which is a "not now" rather
|
||||
* than a failure -- the caller is expected to try again on a later frame.
|
||||
* Two callers asking for the same asset share one request and one future.
|
||||
*/
|
||||
public Optional<CompletableFuture<byte[]>> request(String assetId, ThumbnailSize size) {
|
||||
String key = assetId + '@' + size;
|
||||
CompletableFuture<byte[]> existing = inFlight.get(key);
|
||||
if (existing != null) {
|
||||
return Optional.of(existing);
|
||||
}
|
||||
if (inFlight.size() >= MAX_PENDING) {
|
||||
return Optional.empty();
|
||||
}
|
||||
CompletableFuture<byte[]> future = new CompletableFuture<>();
|
||||
CompletableFuture<byte[]> raced = inFlight.putIfAbsent(key, future);
|
||||
if (raced != null) {
|
||||
return Optional.of(raced);
|
||||
}
|
||||
try {
|
||||
workers.execute(() -> {
|
||||
try {
|
||||
future.complete(fetch(assetId, size));
|
||||
} catch (ProviderException e) {
|
||||
log.debug("Thumbnail {} failed: {}", assetId, e.getMessage());
|
||||
future.completeExceptionally(e);
|
||||
} catch (RuntimeException e) {
|
||||
future.completeExceptionally(e);
|
||||
} finally {
|
||||
inFlight.remove(key);
|
||||
}
|
||||
});
|
||||
} catch (RuntimeException e) {
|
||||
// Shutting down: the screen is closing and nobody will read this.
|
||||
inFlight.remove(key);
|
||||
future.completeExceptionally(e);
|
||||
}
|
||||
return Optional.of(future);
|
||||
}
|
||||
|
||||
public int pending() {
|
||||
return inFlight.size();
|
||||
}
|
||||
|
||||
private byte[] fetch(String assetId, ThumbnailSize size) throws ProviderException {
|
||||
PhotoProvider provider = session.provider().orElseThrow(() -> new ProviderException(
|
||||
ProviderException.Kind.AUTHENTICATION, "No provider is configured"));
|
||||
return provider.thumbnail(assetId, size);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
workers.shutdownNow();
|
||||
inFlight.values().forEach(future -> future.cancel(false));
|
||||
inFlight.clear();
|
||||
try {
|
||||
workers.awaitTermination(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonFactory() {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread = new Thread(runnable, "photosync-thumbnail-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
package dev.photosync.core.timeline;
|
||||
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.BucketPage;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.TimelineBucket;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* The lazily-filled model behind the album browser.
|
||||
*
|
||||
* <p>It fetches the month counts once, which is enough to lay the whole album
|
||||
* out at full height, and then fetches a month's assets only when the renderer
|
||||
* says that month is about to be on screen. Everything here runs off the render
|
||||
* thread; the renderer only ever reads {@link #sections()} and {@link #revision()},
|
||||
* both of which are non-blocking.
|
||||
*
|
||||
* <p>The renderer drives loading by calling {@link #request(TimelineBucket)} for
|
||||
* the months it is about to draw. Repeat calls are free -- already-loaded,
|
||||
* in-flight and previously-failed months are all ignored -- so it can call it
|
||||
* every frame without keeping any bookkeeping of its own.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class TimelineBrowser implements AutoCloseable {
|
||||
|
||||
private final ProviderSession session;
|
||||
private final ExecutorService loader;
|
||||
private final Object lock = new Object();
|
||||
private final Set<String> inFlight = ConcurrentHashMap.newKeySet();
|
||||
private final AtomicInteger revision = new AtomicInteger();
|
||||
|
||||
private final Map<String, List<TimelineSection.Day>> pages = new HashMap<>();
|
||||
private final Map<String, String> pageErrors = new HashMap<>();
|
||||
|
||||
private AlbumRef album = AlbumRef.library();
|
||||
private List<TimelineBucket> buckets = List.of();
|
||||
/** Bumped on every {@link #open} so results for the previous album are discarded. */
|
||||
private long generation;
|
||||
|
||||
private volatile List<TimelineSection> sections = List.of();
|
||||
private volatile TimelineState state = TimelineState.NOT_CONFIGURED;
|
||||
private volatile String error;
|
||||
|
||||
public TimelineBrowser(ProviderSession session) {
|
||||
this.session = session;
|
||||
this.loader = Executors.newFixedThreadPool(2, daemonFactory());
|
||||
}
|
||||
|
||||
/** Points the browser at an album (or the whole library) and starts loading. */
|
||||
public void open(AlbumRef target) {
|
||||
long token;
|
||||
synchronized (lock) {
|
||||
album = target;
|
||||
buckets = List.of();
|
||||
pages.clear();
|
||||
pageErrors.clear();
|
||||
sections = List.of();
|
||||
error = null;
|
||||
token = ++generation;
|
||||
}
|
||||
inFlight.clear();
|
||||
state = TimelineState.LOADING;
|
||||
revision.incrementAndGet();
|
||||
loader.execute(() -> loadBuckets(token));
|
||||
}
|
||||
|
||||
/** Re-fetches everything for the album already open -- the refresh button. */
|
||||
public void reload() {
|
||||
AlbumRef target;
|
||||
synchronized (lock) {
|
||||
target = album;
|
||||
}
|
||||
open(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* The current layout: one entry per calendar day for loaded months, one per
|
||||
* month for the rest. Cheap enough to call every frame -- it is a cached
|
||||
* immutable list, rebuilt only when something actually arrives.
|
||||
*/
|
||||
public List<TimelineSection> sections() {
|
||||
return sections;
|
||||
}
|
||||
|
||||
/** Changes whenever {@link #sections()} does, so the renderer knows to re-measure. */
|
||||
public int revision() {
|
||||
return revision.get();
|
||||
}
|
||||
|
||||
public TimelineState state() {
|
||||
return state;
|
||||
}
|
||||
|
||||
public Optional<String> error() {
|
||||
return Optional.ofNullable(error);
|
||||
}
|
||||
|
||||
/** Total assets across the album, known before anything is fetched. */
|
||||
public int assetCount() {
|
||||
synchronized (lock) {
|
||||
return buckets.stream().mapToInt(TimelineBucket::assetCount).sum();
|
||||
}
|
||||
}
|
||||
|
||||
/** Asks for a month's assets. Loaded, loading and failed months are no-ops. */
|
||||
public void request(TimelineBucket bucket) {
|
||||
long token;
|
||||
synchronized (lock) {
|
||||
if (pages.containsKey(bucket.key()) || pageErrors.containsKey(bucket.key())) {
|
||||
return;
|
||||
}
|
||||
token = generation;
|
||||
}
|
||||
if (!inFlight.add(bucket.key())) {
|
||||
return;
|
||||
}
|
||||
loader.execute(() -> loadPage(token, bucket));
|
||||
}
|
||||
|
||||
/** Why a month's tiles are still blank, if it failed rather than merely not being reached yet. */
|
||||
public Optional<String> pageError(TimelineBucket bucket) {
|
||||
synchronized (lock) {
|
||||
return Optional.ofNullable(pageErrors.get(bucket.key()));
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isLoading(TimelineBucket bucket) {
|
||||
return inFlight.contains(bucket.key());
|
||||
}
|
||||
|
||||
/** Clears a month's recorded failure so the next {@link #request} tries again. */
|
||||
public void retryPage(TimelineBucket bucket) {
|
||||
synchronized (lock) {
|
||||
pageErrors.remove(bucket.key());
|
||||
}
|
||||
request(bucket);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
loader.shutdownNow();
|
||||
try {
|
||||
loader.awaitTermination(2, TimeUnit.SECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadBuckets(long token) {
|
||||
Optional<PhotoProvider> provider = session.provider();
|
||||
if (provider.isEmpty()) {
|
||||
state = TimelineState.NOT_CONFIGURED;
|
||||
revision.incrementAndGet();
|
||||
return;
|
||||
}
|
||||
AlbumRef target;
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
target = album;
|
||||
}
|
||||
try {
|
||||
List<TimelineBucket> fetched = new ArrayList<>(provider.get().timeline(target));
|
||||
fetched.sort(Comparator.naturalOrder());
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
buckets = List.copyOf(fetched);
|
||||
rebuild();
|
||||
}
|
||||
state = fetched.isEmpty() ? TimelineState.EMPTY : TimelineState.READY;
|
||||
} catch (ProviderException e) {
|
||||
log.warn("Could not load the timeline: {}", e.getMessage());
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
error = e.getMessage();
|
||||
}
|
||||
state = TimelineState.FAILED;
|
||||
} finally {
|
||||
revision.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private void loadPage(long token, TimelineBucket bucket) {
|
||||
try {
|
||||
Optional<PhotoProvider> provider = session.provider();
|
||||
if (provider.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
AlbumRef target;
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
target = album;
|
||||
}
|
||||
BucketPage page = provider.get().page(target, bucket);
|
||||
List<TimelineSection.Day> days = groupByDay(page.assets());
|
||||
synchronized (lock) {
|
||||
if (token != generation) {
|
||||
return;
|
||||
}
|
||||
pages.put(bucket.key(), days);
|
||||
rebuild();
|
||||
}
|
||||
} catch (ProviderException e) {
|
||||
log.warn("Could not load {}: {}", bucket.key(), e.getMessage());
|
||||
synchronized (lock) {
|
||||
if (token == generation) {
|
||||
pageErrors.put(bucket.key(), e.getMessage());
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
inFlight.remove(bucket.key());
|
||||
revision.incrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a month into days, newest first.
|
||||
*
|
||||
* <p>Grouping is by the asset's <em>local</em> date -- the one the camera saw
|
||||
* -- so a photo taken at 23:00 stays on the day it was taken no matter which
|
||||
* timezone the player is browsing from.
|
||||
*/
|
||||
private static List<TimelineSection.Day> groupByDay(List<RemoteAsset> assets) {
|
||||
Map<LocalDate, List<RemoteAsset>> byDay = new LinkedHashMap<>();
|
||||
for (RemoteAsset asset : assets) {
|
||||
byDay.computeIfAbsent(asset.localDay(), day -> new ArrayList<>()).add(asset);
|
||||
}
|
||||
List<TimelineSection.Day> days = new ArrayList<>(byDay.size());
|
||||
byDay.forEach((day, members) -> days.add(new TimelineSection.Day(day, members)));
|
||||
days.sort(Comparator.comparing(TimelineSection.Day::date).reversed());
|
||||
return List.copyOf(days);
|
||||
}
|
||||
|
||||
/** Must hold {@link #lock}. */
|
||||
private void rebuild() {
|
||||
List<TimelineSection> built = new ArrayList<>();
|
||||
for (TimelineBucket bucket : buckets) {
|
||||
List<TimelineSection.Day> loaded = pages.get(bucket.key());
|
||||
if (loaded == null) {
|
||||
built.add(new TimelineSection.PendingMonth(bucket));
|
||||
} else {
|
||||
built.addAll(loaded);
|
||||
}
|
||||
}
|
||||
sections = List.copyOf(built);
|
||||
}
|
||||
|
||||
private static ThreadFactory daemonFactory() {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread = new Thread(runnable, "photosync-timeline-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dev.photosync.core.timeline;
|
||||
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.TimelineBucket;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One labelled run of tiles in the browser.
|
||||
*
|
||||
* <p>The backend groups by month; the player asked to browse by day. Those two
|
||||
* facts are reconciled here rather than in the renderer: a month that has not
|
||||
* been fetched yet contributes a single {@link PendingMonth} whose asset count
|
||||
* is known exactly, and the moment its page arrives it is replaced by one
|
||||
* {@link Day} per calendar day inside it.
|
||||
*
|
||||
* <p>Because the count is exact either way, the list has its true height before
|
||||
* anything is loaded -- so the scrollbar is honest from the first frame and the
|
||||
* browser only ever fetches the months the viewport actually reaches.
|
||||
*/
|
||||
public sealed interface TimelineSection {
|
||||
|
||||
/** The day, or the first of the month for a section that is still pending. */
|
||||
LocalDate date();
|
||||
|
||||
int assetCount();
|
||||
|
||||
/** Assets known so far. Empty while pending, which is what makes a tile a placeholder. */
|
||||
List<RemoteAsset> assets();
|
||||
|
||||
record PendingMonth(TimelineBucket bucket) implements TimelineSection {
|
||||
|
||||
@Override
|
||||
public LocalDate date() {
|
||||
return bucket.month();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int assetCount() {
|
||||
return bucket.assetCount();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RemoteAsset> assets() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
record Day(LocalDate date, List<RemoteAsset> assets) implements TimelineSection {
|
||||
|
||||
public Day {
|
||||
assets = List.copyOf(assets);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int assetCount() {
|
||||
return assets.size();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.photosync.core.timeline;
|
||||
|
||||
/** What the browser should be showing instead of, or alongside, the grid. */
|
||||
public enum TimelineState {
|
||||
/** No credentials yet. The settings screen is the thing to offer. */
|
||||
NOT_CONFIGURED,
|
||||
/** Fetching the bucket list. Nothing can be laid out yet. */
|
||||
LOADING,
|
||||
READY,
|
||||
/** The bucket list failed; {@code TimelineBrowser.error()} says why. */
|
||||
FAILED,
|
||||
/** The album really is empty. */
|
||||
EMPTY
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
/**
|
||||
* What the GUI draws for one row: the job, plus the byte counters that are not
|
||||
* worth persisting and would be stale a frame later anyway.
|
||||
*/
|
||||
public record QueuedUpload(UploadJob job, long bytesSent, long bytesTotal) {
|
||||
|
||||
public static QueuedUpload idle(UploadJob job) {
|
||||
return new QueuedUpload(job, 0L, job.sizeBytes());
|
||||
}
|
||||
|
||||
/** 0..1, and 0 rather than NaN when the total is not known yet. */
|
||||
public double fraction() {
|
||||
if (bytesTotal <= 0L) {
|
||||
return 0.0;
|
||||
}
|
||||
return Math.min(1.0, (double) bytesSent / (double) bytesTotal);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import dev.photosync.core.config.UploadSettings;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderSession;
|
||||
import dev.photosync.core.provider.TransferCancelledException;
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* Drains the {@link UploadQueue}.
|
||||
*
|
||||
* <p>A single pump thread wakes a few times a second, works out how many slots
|
||||
* the current settings allow, and hands that many due jobs to a small worker
|
||||
* pool. Nothing has to be reconfigured when the player changes the concurrency:
|
||||
* the next tick simply reads the new number. The same is true of credentials --
|
||||
* while none are set the pump finds no provider and the queue just waits, which
|
||||
* is exactly the behaviour a first-run player wants.
|
||||
*
|
||||
* <p>Threads are daemons. Correctness across a hard shutdown comes from the
|
||||
* queue being durable, not from holding the JVM open; blocking exit on a stuck
|
||||
* socket would be a much worse bug than re-uploading one file.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class UploadCoordinator implements AutoCloseable {
|
||||
|
||||
private static final Duration PUMP_INTERVAL = Duration.ofMillis(250);
|
||||
private static final Duration MAX_BACKOFF = Duration.ofMinutes(10);
|
||||
|
||||
private final UploadQueue queue;
|
||||
private final ProviderSession session;
|
||||
private final Supplier<UploadSettings> settings;
|
||||
|
||||
private final ScheduledExecutorService pump;
|
||||
private final ExecutorService workers;
|
||||
private final AtomicInteger inFlight = new AtomicInteger();
|
||||
|
||||
private volatile boolean running;
|
||||
|
||||
public UploadCoordinator(UploadQueue queue, ProviderSession session, Supplier<UploadSettings> settings) {
|
||||
this.queue = queue;
|
||||
this.session = session;
|
||||
this.settings = settings;
|
||||
this.pump = Executors.newSingleThreadScheduledExecutor(named("photosync-pump"));
|
||||
this.workers = Executors.newFixedThreadPool(UploadSettings.MAX_CONCURRENCY, named("photosync-upload"));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
running = true;
|
||||
pump.scheduleWithFixedDelay(this::tick, 0, PUMP_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
/** Nudges the pump so a just-taken screenshot does not sit for a quarter of a second. */
|
||||
public void wake() {
|
||||
if (running) {
|
||||
pump.execute(this::tick);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops taking new work and waits for the transfers already in flight.
|
||||
*
|
||||
* @return true if everything finished within the timeout
|
||||
*/
|
||||
public boolean shutdown(Duration timeout) {
|
||||
running = false;
|
||||
pump.shutdown();
|
||||
workers.shutdown();
|
||||
try {
|
||||
return workers.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!shutdown(Duration.ofSeconds(5))) {
|
||||
// Whatever is still running will be re-tried next launch: the queue
|
||||
// file already says these jobs were interrupted.
|
||||
workers.shutdownNow();
|
||||
pump.shutdownNow();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void tick() {
|
||||
if (!running) {
|
||||
return;
|
||||
}
|
||||
Optional<PhotoProvider> provider = session.provider();
|
||||
if (provider.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
UploadSettings current = settings.get();
|
||||
Instant now = Instant.now();
|
||||
while (running && inFlight.get() < current.concurrency()) {
|
||||
Optional<UploadJob> claimed = queue.claim(now);
|
||||
if (claimed.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
inFlight.incrementAndGet();
|
||||
UploadJob job = claimed.get();
|
||||
try {
|
||||
workers.execute(() -> transfer(job, provider.get(), current));
|
||||
} catch (RuntimeException e) {
|
||||
// Pool already shutting down; put the job back for next launch.
|
||||
inFlight.decrementAndGet();
|
||||
queue.reschedule(job.id(), "Shutting down", now);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void transfer(UploadJob job, PhotoProvider provider, UploadSettings current) {
|
||||
try {
|
||||
UploadReceipt receipt = provider.upload(job.toRequest(), (sent, total) -> {
|
||||
if (queue.isCancelRequested(job.id())) {
|
||||
throw new TransferCancelledException(job.id());
|
||||
}
|
||||
queue.reportProgress(job.id(), sent, total);
|
||||
});
|
||||
queue.succeed(job.id(), receipt);
|
||||
if (current.deleteLocalAfterUpload()) {
|
||||
removeLocalCopy(job);
|
||||
}
|
||||
} catch (TransferCancelledException e) {
|
||||
log.debug("Upload of {} cancelled by the player", job.fileName());
|
||||
} catch (ProviderException e) {
|
||||
handleFailure(job, current, e);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("Unexpected error uploading {}", job.fileName(), e);
|
||||
queue.fail(job.id(), e.toString());
|
||||
} finally {
|
||||
inFlight.decrementAndGet();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleFailure(UploadJob job, UploadSettings current, ProviderException failure) {
|
||||
String message = failure.getMessage() == null ? failure.kind().name() : failure.getMessage();
|
||||
boolean tryAgain = failure.isRetryable() && job.attempts() < current.maxAttempts();
|
||||
if (tryAgain) {
|
||||
Duration wait = backoff(job.attempts(), current);
|
||||
log.info("Upload of {} failed ({}); retrying in {}s", job.fileName(), message, wait.toSeconds());
|
||||
queue.reschedule(job.id(), message, Instant.now().plus(wait));
|
||||
} else {
|
||||
log.warn("Giving up on {}: {}", job.fileName(), message);
|
||||
queue.fail(job.id(), message);
|
||||
}
|
||||
}
|
||||
|
||||
/** Exponential, so a server that is down for a while stops being hammered. */
|
||||
private static Duration backoff(int attempts, UploadSettings current) {
|
||||
int exponent = Math.min(attempts - 1, 16);
|
||||
long seconds = (long) current.retryBackoffSeconds() << exponent;
|
||||
return seconds >= MAX_BACKOFF.toSeconds() ? MAX_BACKOFF : Duration.ofSeconds(seconds);
|
||||
}
|
||||
|
||||
private static void removeLocalCopy(UploadJob job) {
|
||||
try {
|
||||
Files.deleteIfExists(job.path());
|
||||
} catch (IOException e) {
|
||||
log.warn("Uploaded {} but could not delete the local copy", job.fileName(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private static ThreadFactory named(String prefix) {
|
||||
AtomicInteger counter = new AtomicInteger();
|
||||
return runnable -> {
|
||||
Thread thread = new Thread(runnable, prefix + "-" + counter.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
|
||||
/**
|
||||
* The transitions worth telling someone about.
|
||||
*
|
||||
* <p>Byte-level progress is deliberately not an event. It changes hundreds of
|
||||
* times per upload, and the only thing that wants it -- the queue screen -- is
|
||||
* redrawing every frame anyway and can just read {@link UploadQueue#snapshot()}.
|
||||
* Emitting it would mean thousands of cross-thread hops for no gain.
|
||||
*/
|
||||
public sealed interface UploadEvent {
|
||||
|
||||
UploadJob job();
|
||||
|
||||
record Enqueued(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
|
||||
record Started(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
|
||||
record Completed(UploadJob job, UploadReceipt receipt) implements UploadEvent {
|
||||
}
|
||||
|
||||
/** A single attempt failed. {@code willRetry} says whether another one is coming. */
|
||||
record Failed(UploadJob job, String message, boolean willRetry) implements UploadEvent {
|
||||
}
|
||||
|
||||
record Removed(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
|
||||
/** The last active job left the queue. The quit dialog waits for this. */
|
||||
record Drained(UploadJob job) implements UploadEvent {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import dev.photosync.core.capture.CaptureOrigin;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.UploadRequest;
|
||||
import lombok.Builder;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* One screenshot's journey to the backend, as an immutable value.
|
||||
*
|
||||
* <p>Every transition produces a new job rather than mutating one, so the queue
|
||||
* can hand a consistent snapshot to the GUI while workers are running, without
|
||||
* copying or locking on the render thread.
|
||||
*
|
||||
* <p>The file is held as a string rather than a {@link Path} because this record
|
||||
* is serialized straight to the queue file, and Gson has no idea what to do with
|
||||
* a filesystem-specific {@code Path} implementation.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record UploadJob(
|
||||
String id,
|
||||
String file,
|
||||
String fileName,
|
||||
long sizeBytes,
|
||||
Instant capturedAt,
|
||||
CaptureOrigin origin,
|
||||
String albumId,
|
||||
UploadState state,
|
||||
int attempts,
|
||||
Instant notBefore,
|
||||
String lastError,
|
||||
String assetId) {
|
||||
|
||||
public Path path() {
|
||||
return Path.of(file);
|
||||
}
|
||||
|
||||
public AlbumRef album() {
|
||||
return AlbumRef.of(albumId);
|
||||
}
|
||||
|
||||
public Optional<String> failureMessage() {
|
||||
return Optional.ofNullable(lastError).filter(message -> !message.isBlank());
|
||||
}
|
||||
|
||||
public UploadRequest toRequest() {
|
||||
return new UploadRequest(path(), fileName, capturedAt, capturedAt, album());
|
||||
}
|
||||
|
||||
/** True once the backoff has elapsed and a worker may pick this up. */
|
||||
public boolean isClaimable(Instant now) {
|
||||
return switch (state) {
|
||||
case PENDING -> true;
|
||||
case RETRYING -> notBefore == null || !notBefore.isAfter(now);
|
||||
default -> false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
import com.google.gson.reflect.TypeToken;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.persistence.JsonFile;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.core.provider.UploadReceipt;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The durable list of screenshots on their way to the backend.
|
||||
*
|
||||
* <p>This is the answer to "what happens if the game dies mid-upload". Every
|
||||
* state change is written through to {@code photosync-queue.json} before it is
|
||||
* announced, so the worst a hard kill can cost is one in-flight request. On the
|
||||
* next launch anything left in {@link UploadState#UPLOADING} is demoted back to
|
||||
* {@link UploadState#PENDING} and simply tried again -- which is safe because
|
||||
* {@code PhotoProvider.upload} is required to answer
|
||||
* {@link UploadReceipt.Outcome#DUPLICATE} rather than store a second copy.
|
||||
*
|
||||
* <p>All mutation happens under one lock and all events are published outside
|
||||
* it, so a listener can call back into the queue without deadlocking.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class UploadQueue {
|
||||
|
||||
private final JsonFile file;
|
||||
private final int historyLimit;
|
||||
|
||||
private final Object lock = new Object();
|
||||
private final Map<String, UploadJob> jobs = new LinkedHashMap<>();
|
||||
|
||||
/** jobId to {sent, total}. Not persisted: meaningless across a restart. */
|
||||
private final Map<String, long[]> transfers = new ConcurrentHashMap<>();
|
||||
private final Set<String> cancelRequests = ConcurrentHashMap.newKeySet();
|
||||
private final List<Consumer<UploadEvent>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
public UploadQueue(Path path) {
|
||||
this(path, 100);
|
||||
}
|
||||
|
||||
public UploadQueue(Path path, int historyLimit) {
|
||||
this.file = new JsonFile(path);
|
||||
this.historyLimit = historyLimit;
|
||||
restore();
|
||||
}
|
||||
|
||||
public void onEvent(Consumer<UploadEvent> listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reading
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Oldest first, which is also the order workers pick jobs up in. */
|
||||
public List<QueuedUpload> snapshot() {
|
||||
List<UploadJob> current;
|
||||
synchronized (lock) {
|
||||
current = List.copyOf(jobs.values());
|
||||
}
|
||||
List<QueuedUpload> view = new ArrayList<>(current.size());
|
||||
for (UploadJob job : current) {
|
||||
long[] transfer = transfers.get(job.id());
|
||||
view.add(transfer == null
|
||||
? QueuedUpload.idle(job)
|
||||
: new QueuedUpload(job, transfer[0], transfer[1]));
|
||||
}
|
||||
return view;
|
||||
}
|
||||
|
||||
public Optional<UploadJob> find(String id) {
|
||||
synchronized (lock) {
|
||||
return Optional.ofNullable(jobs.get(id));
|
||||
}
|
||||
}
|
||||
|
||||
/** How much work the quit dialog is waiting for. */
|
||||
public int activeCount() {
|
||||
synchronized (lock) {
|
||||
return (int) jobs.values().stream().filter(job -> job.state().isActive()).count();
|
||||
}
|
||||
}
|
||||
|
||||
public int failedCount() {
|
||||
synchronized (lock) {
|
||||
return (int) jobs.values().stream().filter(job -> job.state() == UploadState.FAILED).count();
|
||||
}
|
||||
}
|
||||
|
||||
public boolean hasActiveWork() {
|
||||
synchronized (lock) {
|
||||
return jobs.values().stream().anyMatch(job -> job.state().isActive());
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCancelRequested(String jobId) {
|
||||
return cancelRequests.contains(jobId);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Writing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Adds a screenshot, or returns the job already covering that file.
|
||||
*
|
||||
* <p>The de-duplication matters on the path where a capture is enqueued and
|
||||
* the player immediately quits and relaunches: the restored job and a fresh
|
||||
* rescan would otherwise both try to upload the same file.
|
||||
*/
|
||||
public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album) {
|
||||
String absolute = shot.file().toAbsolutePath().toString();
|
||||
UploadJob created;
|
||||
synchronized (lock) {
|
||||
Optional<UploadJob> existing = jobs.values().stream()
|
||||
.filter(job -> job.file().equals(absolute))
|
||||
.filter(job -> job.state().isActive() || job.state() == UploadState.FAILED)
|
||||
.findFirst();
|
||||
if (existing.isPresent()) {
|
||||
return existing.get();
|
||||
}
|
||||
created = UploadJob.builder()
|
||||
.id(UUID.randomUUID().toString())
|
||||
.file(absolute)
|
||||
.fileName(shot.fileName())
|
||||
.sizeBytes(shot.sizeBytes())
|
||||
.capturedAt(shot.capturedAt())
|
||||
.origin(shot.origin())
|
||||
.albumId(album.id().orElse(""))
|
||||
.state(UploadState.PENDING)
|
||||
.attempts(0)
|
||||
.build();
|
||||
jobs.put(created.id(), created);
|
||||
persist();
|
||||
}
|
||||
publish(new UploadEvent.Enqueued(created));
|
||||
return created;
|
||||
}
|
||||
|
||||
/** Takes the oldest job that is due, marking it in flight. */
|
||||
public Optional<UploadJob> claim(Instant now) {
|
||||
UploadJob claimed = null;
|
||||
synchronized (lock) {
|
||||
for (UploadJob job : jobs.values()) {
|
||||
if (job.isClaimable(now)) {
|
||||
claimed = job.toBuilder()
|
||||
.state(UploadState.UPLOADING)
|
||||
.attempts(job.attempts() + 1)
|
||||
.lastError(null)
|
||||
.build();
|
||||
jobs.put(claimed.id(), claimed);
|
||||
persist();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (claimed != null) {
|
||||
transfers.put(claimed.id(), new long[]{0L, claimed.sizeBytes()});
|
||||
publish(new UploadEvent.Started(claimed));
|
||||
}
|
||||
return Optional.ofNullable(claimed);
|
||||
}
|
||||
|
||||
public void reportProgress(String jobId, long sent, long total) {
|
||||
transfers.put(jobId, new long[]{sent, total});
|
||||
}
|
||||
|
||||
public void succeed(String jobId, UploadReceipt receipt) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.COMPLETED)
|
||||
.assetId(receipt.assetId())
|
||||
.lastError(null)
|
||||
.build(),
|
||||
job -> new UploadEvent.Completed(job, receipt));
|
||||
}
|
||||
|
||||
/** A retryable failure: the job goes back to sleep until {@code notBefore}. */
|
||||
public void reschedule(String jobId, String message, Instant notBefore) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.RETRYING)
|
||||
.notBefore(notBefore)
|
||||
.lastError(message)
|
||||
.build(),
|
||||
job -> new UploadEvent.Failed(job, message, true));
|
||||
}
|
||||
|
||||
/** A permanent failure, or the last attempt. Waits for the player now. */
|
||||
public void fail(String jobId, String message) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.FAILED)
|
||||
.notBefore(null)
|
||||
.lastError(message)
|
||||
.build(),
|
||||
job -> new UploadEvent.Failed(job, message, false));
|
||||
}
|
||||
|
||||
/** Player pressed retry: a clean slate rather than one more attempt. */
|
||||
public void retry(String jobId) {
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.PENDING)
|
||||
.attempts(0)
|
||||
.notBefore(null)
|
||||
.lastError(null)
|
||||
.build(),
|
||||
UploadEvent.Enqueued::new);
|
||||
}
|
||||
|
||||
public void retryAllFailed() {
|
||||
List<String> failed;
|
||||
synchronized (lock) {
|
||||
failed = jobs.values().stream()
|
||||
.filter(job -> job.state() == UploadState.FAILED)
|
||||
.map(UploadJob::id)
|
||||
.toList();
|
||||
}
|
||||
failed.forEach(this::retry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a job. An upload already in flight is asked to stop through
|
||||
* {@link #isCancelRequested(String)}, which its progress callback checks.
|
||||
*/
|
||||
public void cancel(String jobId) {
|
||||
cancelRequests.add(jobId);
|
||||
transition(jobId, job -> job.toBuilder()
|
||||
.state(UploadState.CANCELLED)
|
||||
.lastError(null)
|
||||
.build(),
|
||||
UploadEvent.Removed::new);
|
||||
}
|
||||
|
||||
/** Drops a finished or failed job from the list entirely. */
|
||||
public void forget(String jobId) {
|
||||
UploadJob removed;
|
||||
synchronized (lock) {
|
||||
UploadJob job = jobs.get(jobId);
|
||||
if (job == null || job.state() == UploadState.UPLOADING) {
|
||||
return;
|
||||
}
|
||||
removed = jobs.remove(jobId);
|
||||
persist();
|
||||
}
|
||||
transfers.remove(jobId);
|
||||
cancelRequests.remove(jobId);
|
||||
publish(new UploadEvent.Removed(removed));
|
||||
}
|
||||
|
||||
public void clearFinished() {
|
||||
List<UploadJob> removed = new ArrayList<>();
|
||||
synchronized (lock) {
|
||||
jobs.values().removeIf(job -> {
|
||||
if (job.state().isFinished()) {
|
||||
removed.add(job);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
if (!removed.isEmpty()) {
|
||||
persist();
|
||||
}
|
||||
}
|
||||
for (UploadJob job : removed) {
|
||||
transfers.remove(job.id());
|
||||
cancelRequests.remove(job.id());
|
||||
publish(new UploadEvent.Removed(job));
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Internals
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void transition(String jobId,
|
||||
java.util.function.UnaryOperator<UploadJob> change,
|
||||
java.util.function.Function<UploadJob, UploadEvent> event) {
|
||||
UploadJob updated;
|
||||
boolean drained;
|
||||
synchronized (lock) {
|
||||
UploadJob previous = jobs.get(jobId);
|
||||
if (previous == null) {
|
||||
return;
|
||||
}
|
||||
updated = change.apply(previous);
|
||||
jobs.put(jobId, updated);
|
||||
trimHistory();
|
||||
persist();
|
||||
drained = previous.state().isActive() && jobs.values().stream().noneMatch(job -> job.state().isActive());
|
||||
}
|
||||
if (!updated.state().isActive()) {
|
||||
transfers.remove(jobId);
|
||||
}
|
||||
publish(event.apply(updated));
|
||||
if (drained) {
|
||||
publish(new UploadEvent.Drained(updated));
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller holds the lock. Keeps the finished tail from growing without bound. */
|
||||
private void trimHistory() {
|
||||
List<String> finished = jobs.values().stream()
|
||||
.filter(job -> job.state().isFinished())
|
||||
.map(UploadJob::id)
|
||||
.toList();
|
||||
for (int i = 0; i < finished.size() - historyLimit; i++) {
|
||||
jobs.remove(finished.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
/** Caller holds the lock. */
|
||||
private void persist() {
|
||||
List<UploadJob> durable = jobs.values().stream()
|
||||
.filter(job -> job.state().isPersistent())
|
||||
.toList();
|
||||
try {
|
||||
file.write(file.gson().toJsonTree(durable));
|
||||
} catch (IOException e) {
|
||||
log.error("Could not write the upload queue to {}", file.path(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void restore() {
|
||||
TypeToken<List<UploadJob>> listType = new TypeToken<>() {
|
||||
};
|
||||
List<UploadJob> stored = file.readTree()
|
||||
.<List<UploadJob>>map(tree -> file.gson().fromJson(tree, listType.getType()))
|
||||
.orElseGet(List::of);
|
||||
|
||||
int skipped = 0;
|
||||
for (UploadJob job : stored) {
|
||||
if (job == null || job.id() == null || job.file() == null || job.state() == null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (!Files.isRegularFile(Path.of(job.file()))) {
|
||||
// The screenshot was deleted while we were away. Nothing to send.
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
// Anything that claimed to be uploading was interrupted by whatever
|
||||
// ended the last session; start it over.
|
||||
UploadJob resumed = job.state() == UploadState.UPLOADING
|
||||
? job.toBuilder().state(UploadState.PENDING).build()
|
||||
: job;
|
||||
jobs.put(resumed.id(), resumed);
|
||||
}
|
||||
if (!jobs.isEmpty() || skipped > 0) {
|
||||
log.info("Restored {} pending upload(s) from {} ({} dropped)", jobs.size(), file.path(), skipped);
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(UploadEvent event) {
|
||||
for (Consumer<UploadEvent> listener : listeners) {
|
||||
try {
|
||||
listener.accept(event);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("An upload listener failed on {}", event.getClass().getSimpleName(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package dev.photosync.core.upload;
|
||||
|
||||
/**
|
||||
* Where a job is in its life.
|
||||
*
|
||||
* <p>{@link #FAILED} is neither active nor finished on purpose: the job has run
|
||||
* out of automatic attempts and is now waiting for the player to press retry.
|
||||
* Treating it as finished would quietly lose screenshots; treating it as active
|
||||
* would keep the quit dialog open forever.
|
||||
*/
|
||||
public enum UploadState {
|
||||
|
||||
/** Waiting for a free worker. */
|
||||
PENDING,
|
||||
/** A worker is pushing bytes right now. */
|
||||
UPLOADING,
|
||||
/** A retryable error; will be picked up again after a backoff. */
|
||||
RETRYING,
|
||||
/** The backend has the file. */
|
||||
COMPLETED,
|
||||
/** Out of attempts, or a permanent error. Needs the player. */
|
||||
FAILED,
|
||||
/** The player removed it before it went up. */
|
||||
CANCELLED;
|
||||
|
||||
/** Counts as outstanding work: the quit dialog waits for these. */
|
||||
public boolean isActive() {
|
||||
return this == PENDING || this == UPLOADING || this == RETRYING;
|
||||
}
|
||||
|
||||
/** Nothing more will happen without the player asking. */
|
||||
public boolean isFinished() {
|
||||
return this == COMPLETED || this == CANCELLED;
|
||||
}
|
||||
|
||||
/** Worth writing to the queue file, because it still means something next launch. */
|
||||
public boolean isPersistent() {
|
||||
return isActive() || this == FAILED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package dev.photosync.core.thumbnail;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* The decoder is a transcription of a bit-packed format, so these tests aim at
|
||||
* the things a transcription gets wrong: which bits a field lives in, how the
|
||||
* shared nibble stream is walked, and the colour-space conversion at the end.
|
||||
*
|
||||
* <p>Hashes are built here rather than captured from a server, because setting
|
||||
* the three AC scales to zero collapses the whole DCT to its DC term -- giving a
|
||||
* flat image whose exact colour can be worked out by hand.
|
||||
*/
|
||||
class ThumbHashTest {
|
||||
|
||||
@Test
|
||||
@DisplayName("a hash with no AC energy decodes to its DC colour, everywhere")
|
||||
void flatImage() {
|
||||
// l_dc = 42/63, p_dc = 63 -> +1.0, q_dc = 10 -> -0.68254, l_scale = 0.
|
||||
// blue = 0.66667 - 2/3 * 1.0 = 0.0
|
||||
// red = (3*0.66667 - 0.0 + -0.68254)/2 = 0.65873 -> 168
|
||||
// green = red - q = 1.34127 -> 255 (clamped)
|
||||
byte[] hash = hash(42, 63, 10, 0, 7, 0, 0);
|
||||
|
||||
ThumbImage image = ThumbHash.decode(hash).orElseThrow();
|
||||
|
||||
assertEquals(32, image.width());
|
||||
assertEquals(32, image.height());
|
||||
for (int y = 0; y < image.height(); y++) {
|
||||
for (int x = 0; x < image.width(); x++) {
|
||||
int pixel = image.pixel(x, y);
|
||||
assertEquals(255, alpha(pixel), "opaque when the hash carries no alpha");
|
||||
assertNear(168, red(pixel), "red");
|
||||
assertNear(255, green(pixel), "green");
|
||||
assertNear(0, blue(pixel), "blue");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("a landscape hash is decoded at its own aspect ratio")
|
||||
void aspectRatio() {
|
||||
// Landscape with no alpha pins lx to 7 and reads ly from the low nibble:
|
||||
// 7/3 -> 32 x round(32 / 2.3333) = 32 x 14.
|
||||
ThumbImage image = ThumbHash.decode(landscape(3)).orElseThrow();
|
||||
|
||||
assertEquals(32, image.width());
|
||||
assertEquals(14, image.height());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("AC coefficients actually reach the pixels")
|
||||
void acCoefficientsVaryTheImage() {
|
||||
// Same DC as the flat case but with full luminance scale, so the AC
|
||||
// nibbles -- which are 0x5A filler -- must show up as variation.
|
||||
ThumbImage image = ThumbHash.decode(hash(42, 63, 10, 31, 7, 63, 63)).orElseThrow();
|
||||
|
||||
int first = image.pixel(0, 0);
|
||||
boolean varies = false;
|
||||
for (int y = 0; y < image.height() && !varies; y++) {
|
||||
for (int x = 0; x < image.width(); x++) {
|
||||
if (image.pixel(x, y) != first) {
|
||||
varies = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
assertTrue(varies, "a non-zero l_scale must produce a non-flat image");
|
||||
assertNotEquals(0, image.argb().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("malformed input yields no image rather than an exception")
|
||||
void rejectsGarbage() {
|
||||
assertEquals(Optional.empty(), ThumbHash.decode((String) null));
|
||||
assertEquals(Optional.empty(), ThumbHash.decode(""));
|
||||
assertEquals(Optional.empty(), ThumbHash.decode("not base64 at all!!"));
|
||||
assertEquals(Optional.empty(), ThumbHash.decode(new byte[] {1, 2, 3}));
|
||||
// Well-formed header, but the AC nibble stream is truncated.
|
||||
assertEquals(Optional.empty(), ThumbHash.decode(new byte[] {(byte) 0xEA, (byte) 0xAF, 0, 7, 0, 0x5A}));
|
||||
}
|
||||
|
||||
/** Builds a portrait, alpha-less hash from the raw field values. */
|
||||
private static byte[] hash(int lDc, int pDc, int qDc, int lScale, int lCount, int pScale, int qScale) {
|
||||
int header24 = (lDc & 63) | ((pDc & 63) << 6) | ((qDc & 63) << 12) | ((lScale & 31) << 18);
|
||||
int header16 = (lCount & 7) | ((pScale & 63) << 3) | ((qScale & 63) << 9);
|
||||
byte[] hash = new byte[24];
|
||||
hash[0] = (byte) header24;
|
||||
hash[1] = (byte) (header24 >> 8);
|
||||
hash[2] = (byte) (header24 >> 16);
|
||||
hash[3] = (byte) header16;
|
||||
hash[4] = (byte) (header16 >> 8);
|
||||
for (int i = 5; i < hash.length; i++) {
|
||||
hash[i] = 0x5A;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static byte[] landscape(int lyCount) {
|
||||
byte[] hash = hash(42, 63, 10, 0, lyCount, 0, 0);
|
||||
hash[4] |= (byte) 0x80;
|
||||
return hash;
|
||||
}
|
||||
|
||||
private static void assertNear(int expected, int actual, String channel) {
|
||||
assertTrue(Math.abs(expected - actual) <= 2,
|
||||
() -> channel + " expected around " + expected + " but was " + actual);
|
||||
}
|
||||
|
||||
private static int alpha(int argb) {
|
||||
return (argb >>> 24) & 0xFF;
|
||||
}
|
||||
|
||||
private static int red(int argb) {
|
||||
return (argb >>> 16) & 0xFF;
|
||||
}
|
||||
|
||||
private static int green(int argb) {
|
||||
return (argb >>> 8) & 0xFF;
|
||||
}
|
||||
|
||||
private static int blue(int argb) {
|
||||
return argb & 0xFF;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// The seam. Interfaces that platform modules implement and the UI consumes.
|
||||
// Nothing in this module may import a Minecraft or Fabric type.
|
||||
dependencies {
|
||||
api project(':shared:core')
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package dev.photosync.mcapi;
|
||||
|
||||
import dev.photosync.mcapi.capture.ScreenshotService;
|
||||
import dev.photosync.mcapi.render.TextureSink;
|
||||
import dev.photosync.mcapi.screen.ScreenHost;
|
||||
|
||||
/**
|
||||
* One object holding every version-specific service, built once by the platform
|
||||
* module at client startup.
|
||||
*
|
||||
* <p>It exists so that a screen takes a single constructor argument instead of
|
||||
* five, and so that the list of things a new Minecraft version has to provide is
|
||||
* readable in one place -- this interface is the porting checklist.
|
||||
*/
|
||||
public interface ClientBridge {
|
||||
|
||||
GameContext game();
|
||||
|
||||
Translator text();
|
||||
|
||||
Clipboard clipboard();
|
||||
|
||||
TextureSink textures();
|
||||
|
||||
ScreenHost screens();
|
||||
|
||||
ScreenshotService screenshots();
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.photosync.mcapi;
|
||||
|
||||
/**
|
||||
* The system clipboard.
|
||||
*
|
||||
* <p>Worth a seam of its own for one reason: an Immich API key is a long random
|
||||
* string that nobody types by hand, so paste has to work in the settings screen
|
||||
* or the mod is unusable.
|
||||
*/
|
||||
public interface Clipboard {
|
||||
|
||||
String read();
|
||||
|
||||
void write(String text);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package dev.photosync.mcapi;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
/** The ambient client state PhotoSync has to consult, and the render thread. */
|
||||
public interface GameContext {
|
||||
|
||||
/** True once a world is loaded and being rendered. */
|
||||
boolean inWorld();
|
||||
|
||||
/** True while any screen is up, vanilla's or ours. Automatic capture checks this. */
|
||||
boolean screenOpen();
|
||||
|
||||
/** {@code .minecraft/config}, where PhotoSync keeps its settings and upload queue. */
|
||||
Path configDirectory();
|
||||
|
||||
/**
|
||||
* Runs a task on the render thread, or immediately if already on it.
|
||||
*
|
||||
* <p>Anything that touches a texture, a screen or the framebuffer has to go
|
||||
* through here, because everything that produces such work in this mod --
|
||||
* uploads, thumbnail fetches, the capture timer -- runs on a worker.
|
||||
*/
|
||||
void submit(Runnable task);
|
||||
|
||||
/**
|
||||
* Opens a file or folder in the desktop's file manager.
|
||||
*
|
||||
* <p>The queue screen offers this for a screenshot the player is looking at,
|
||||
* because the alternative to a working "show in folder" is explaining where
|
||||
* {@code .minecraft} lives.
|
||||
*/
|
||||
void reveal(Path path);
|
||||
|
||||
/** The running Minecraft version, for logs and the settings screen's footer. */
|
||||
String minecraftVersion();
|
||||
|
||||
/**
|
||||
* Shuts the game down, having already opened {@code QuitGuard}'s gate.
|
||||
*
|
||||
* <p>This is how the quit dialog finishes what the player started once the
|
||||
* uploads are done or they have chosen not to wait.
|
||||
*/
|
||||
void quit();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package dev.photosync.mcapi;
|
||||
|
||||
/**
|
||||
* The GLFW key and modifier codes the UI reacts to.
|
||||
*
|
||||
* <p>They are named here rather than read from LWJGL because {@code :shared:ui}
|
||||
* deliberately has no LWJGL on its classpath, and because {@code key == 259} at
|
||||
* a call site is unreadable in a way {@code key == Keys.BACKSPACE} is not. The
|
||||
* numbers are fixed by GLFW's ABI and have not changed in the library's history.
|
||||
*/
|
||||
public final class Keys {
|
||||
|
||||
public static final int ESCAPE = 256;
|
||||
public static final int ENTER = 257;
|
||||
public static final int TAB = 258;
|
||||
public static final int BACKSPACE = 259;
|
||||
public static final int DELETE = 261;
|
||||
public static final int RIGHT = 262;
|
||||
public static final int LEFT = 263;
|
||||
public static final int DOWN = 264;
|
||||
public static final int UP = 265;
|
||||
public static final int PAGE_UP = 266;
|
||||
public static final int PAGE_DOWN = 267;
|
||||
public static final int HOME = 268;
|
||||
public static final int END = 269;
|
||||
public static final int KEYPAD_ENTER = 335;
|
||||
|
||||
public static final int A = 65;
|
||||
public static final int C = 67;
|
||||
public static final int V = 86;
|
||||
public static final int X = 88;
|
||||
|
||||
public static final int MOD_SHIFT = 0x1;
|
||||
public static final int MOD_CONTROL = 0x2;
|
||||
public static final int MOD_ALT = 0x4;
|
||||
public static final int MOD_SUPER = 0x8;
|
||||
|
||||
private Keys() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the copy/paste modifier is held: Control everywhere, and Command
|
||||
* on macOS, where GLFW reports it as Super.
|
||||
*/
|
||||
public static boolean shortcut(int modifiers) {
|
||||
return (modifiers & (MOD_CONTROL | MOD_SUPER)) != 0;
|
||||
}
|
||||
|
||||
public static boolean shift(int modifiers) {
|
||||
return (modifiers & MOD_SHIFT) != 0;
|
||||
}
|
||||
|
||||
public static boolean confirms(int key) {
|
||||
return key == ENTER || key == KEYPAD_ENTER;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package dev.photosync.mcapi;
|
||||
|
||||
/**
|
||||
* Looks up translated strings.
|
||||
*
|
||||
* <p>The UI works in plain {@code String}s rather than Minecraft's text
|
||||
* components: everything PhotoSync displays is a translated line with a colour
|
||||
* chosen by the caller, and nothing needs a hover event or a click event.
|
||||
*/
|
||||
public interface Translator {
|
||||
|
||||
/** The translation for {@code key}, or the key itself when it is missing. */
|
||||
String get(String key, Object... arguments);
|
||||
|
||||
boolean has(String key);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package dev.photosync.mcapi.capture;
|
||||
|
||||
import dev.photosync.core.capture.CaptureOrigin;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Where a screenshot the player took is announced.
|
||||
*
|
||||
* <p>This is the mod's one singleton, and it exists for a specific reason: the
|
||||
* thing that knows a screenshot was saved is a mixin, and a mixin is woven into
|
||||
* a Minecraft class that nobody constructs, so it has nowhere to be handed a
|
||||
* collaborator. A static rendezvous point is the only option, and confining it
|
||||
* to this one class is what stops that fact from spreading.
|
||||
*
|
||||
* <p>Every platform module's {@code Screenshot} mixin calls {@link #published}
|
||||
* with the same two arguments, which is why the mixins stay a handful of lines
|
||||
* each no matter how the surrounding Minecraft code is reshaped.
|
||||
*
|
||||
* <p>Automatic captures do not come through here -- they are published by
|
||||
* whoever called {@link ScreenshotService#capture}, which already knows their
|
||||
* origin.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ScreenshotBus {
|
||||
|
||||
private static final ScreenshotBus INSTANCE = new ScreenshotBus();
|
||||
|
||||
private final List<Consumer<CapturedScreenshot>> listeners = new CopyOnWriteArrayList<>();
|
||||
|
||||
private ScreenshotBus() {
|
||||
}
|
||||
|
||||
public static ScreenshotBus get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public void subscribe(Consumer<CapturedScreenshot> listener) {
|
||||
listeners.add(listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces a saved screenshot. Called from the IO thread that wrote it, so
|
||||
* subscribers must be thread-safe.
|
||||
*
|
||||
* <p>Swallows failures on purpose: a screenshot the mod could not stat is
|
||||
* still a screenshot the player successfully took, and throwing back into a
|
||||
* mixin would turn a sync problem into a vanilla one.
|
||||
*/
|
||||
public void published(Path file, CaptureOrigin origin) {
|
||||
CapturedScreenshot shot;
|
||||
try {
|
||||
shot = CapturedScreenshot.of(file, origin);
|
||||
} catch (IOException e) {
|
||||
log.warn("Ignoring a screenshot that could not be read back: {}", file, e);
|
||||
return;
|
||||
}
|
||||
for (Consumer<CapturedScreenshot> listener : listeners) {
|
||||
try {
|
||||
listener.accept(shot);
|
||||
} catch (RuntimeException e) {
|
||||
log.error("A screenshot listener failed for {}", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.photosync.mcapi.capture;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Takes a screenshot on demand -- the automatic capture timer's only way into
|
||||
* the game.
|
||||
*
|
||||
* <p>Deliberately not routed through vanilla's own screenshot call. Going
|
||||
* straight to the framebuffer lets the adapter choose the file name, which is
|
||||
* what makes the configurable suffix possible, and it keeps automatic captures
|
||||
* out of the {@link ScreenshotBus} path that the mixin owns -- so the origin of
|
||||
* a file is known by construction rather than guessed from its name.
|
||||
*/
|
||||
public interface ScreenshotService {
|
||||
|
||||
/** Where the game keeps screenshots. Created if it does not exist. */
|
||||
Path directory();
|
||||
|
||||
/**
|
||||
* Captures the current frame.
|
||||
*
|
||||
* <p>Safe to call from any thread; the grab itself is moved onto the render
|
||||
* thread. The future completes once the PNG is on disk, or fails if there is
|
||||
* no frame to capture or the write failed.
|
||||
*
|
||||
* @param fileNameSuffix inserted before the extension, e.g. {@code "_auto"}
|
||||
*/
|
||||
CompletableFuture<Path> capture(String fileNameSuffix);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.photosync.mcapi.lifecycle;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* The gate a mixin on the game's exit path asks before letting the process go
|
||||
* away.
|
||||
*
|
||||
* <p>A screenshot that was still uploading when the player pressed "Quit Game"
|
||||
* is the one case where doing nothing loses data the player can see they took.
|
||||
* The queue is durable, so nothing is truly lost, but "it will finish next time
|
||||
* you play" is a worse answer than "give it four more seconds", and the player
|
||||
* cannot make that choice unless something stops the shutdown long enough to ask.
|
||||
*
|
||||
* <p>Static for the same reason as {@code ScreenshotBus}: the caller is woven
|
||||
* into a Minecraft class and has nothing to be injected with. Both are confined
|
||||
* to {@code mc-api} so the rest of the mod stays constructor-wired.
|
||||
*
|
||||
* <p>The protocol is deliberately one-shot. Once the player has answered -- by
|
||||
* waiting or by insisting -- {@link #allowOnce()} opens the gate for exactly the
|
||||
* next attempt, so a second quit later in the session is questioned again.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class QuitGuard {
|
||||
|
||||
private static final QuitGuard INSTANCE = new QuitGuard();
|
||||
|
||||
/** Answers whether the game may shut down, and takes over the interaction if not. */
|
||||
@FunctionalInterface
|
||||
public interface Handler {
|
||||
boolean mayQuit();
|
||||
}
|
||||
|
||||
private final AtomicBoolean approved = new AtomicBoolean();
|
||||
private volatile Handler handler = () -> true;
|
||||
|
||||
private QuitGuard() {
|
||||
}
|
||||
|
||||
public static QuitGuard get() {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/** Installed once, at client startup. */
|
||||
public void handler(Handler handler) {
|
||||
this.handler = handler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the exit path. {@code false} means PhotoSync has taken over
|
||||
* and the mixin should cancel the shutdown; the mod will come back through
|
||||
* here once the player has decided.
|
||||
*/
|
||||
public boolean mayQuit() {
|
||||
if (approved.getAndSet(false)) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
return handler.mayQuit();
|
||||
} catch (RuntimeException e) {
|
||||
// Never trap the player in a game that will not close.
|
||||
log.error("The quit handler failed; letting the game shut down", e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets exactly the next {@link #mayQuit()} through. */
|
||||
public void allowOnce() {
|
||||
approved.set(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* The seam between PhotoSync and Minecraft.
|
||||
*
|
||||
* <p>Every interface here is implemented once per compatibility bucket, in a
|
||||
* {@code :platform:*} module, and consumed by {@code :shared:ui} and the mod's
|
||||
* wiring. Nothing in this module may import a Minecraft, Fabric or LWJGL type --
|
||||
* that is what keeps the amount of code that has to be revisited for a new
|
||||
* Minecraft version down to the adapters rather than the whole mod.
|
||||
*
|
||||
* <p>Two rules keep the seam from growing. A method belongs here only if its
|
||||
* Minecraft implementation genuinely differs between versions -- anything that
|
||||
* can be computed from what is already here belongs in {@code :shared:ui}
|
||||
* instead. And nothing here exposes a Minecraft concept by another name: the
|
||||
* mod draws its own widgets from a handful of primitives rather than describing
|
||||
* vanilla ones, because {@code Button}'s constructor has changed more often in
|
||||
* this version range than {@code fill} has.
|
||||
*
|
||||
* <p>See {@code docs/PORTING.md} for the routine when a new version lands.
|
||||
*/
|
||||
package dev.photosync.mcapi;
|
||||
@@ -0,0 +1,64 @@
|
||||
package dev.photosync.mcapi.render;
|
||||
|
||||
/**
|
||||
* The drawing primitives every PhotoSync screen is built from.
|
||||
*
|
||||
* <p>Eleven methods, chosen because each one maps to something Minecraft has had
|
||||
* continuously since 1.20 even as the class holding it was renamed, moved behind
|
||||
* a render pipeline, or had its parameters reordered. Everything else the UI
|
||||
* draws -- buttons, scrollbars, text fields, tooltips, the timeline grid -- is
|
||||
* composed from these in {@code :shared:ui}, so a new Minecraft version costs
|
||||
* one adapter rather than one widget set.
|
||||
*
|
||||
* <p>Colours are packed 0xAARRGGBB. Coordinates are in GUI space, already
|
||||
* divided by the GUI scale, with the origin at the top-left.
|
||||
*
|
||||
* <p>An instance is only valid for the duration of the render call it was handed
|
||||
* to. Holding one past that draws into a frame that no longer exists.
|
||||
*/
|
||||
public interface RenderBridge {
|
||||
|
||||
/** Width of the drawable area in GUI space. */
|
||||
int width();
|
||||
|
||||
/** Height of the drawable area in GUI space. */
|
||||
int height();
|
||||
|
||||
/** Fraction of a tick elapsed since the last one, for smooth animation. */
|
||||
float tickDelta();
|
||||
|
||||
void fill(int x, int y, int width, int height, int argb);
|
||||
|
||||
/** A vertical gradient. Cheap polish that would otherwise cost three fills and still look flat. */
|
||||
void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb);
|
||||
|
||||
/** A one-pixel outline drawn just inside the given rectangle. */
|
||||
void border(int x, int y, int width, int height, int argb);
|
||||
|
||||
void text(String text, int x, int y, int argb, boolean shadow);
|
||||
|
||||
/** Width of {@code text} in GUI pixels, for every layout decision the UI makes. */
|
||||
int textWidth(String text);
|
||||
|
||||
/** Height of one line of text including its leading. */
|
||||
int lineHeight();
|
||||
|
||||
void image(TextureHandle texture, int x, int y, int width, int height);
|
||||
|
||||
/**
|
||||
* Draws part of a texture, with UVs in the 0..1 range.
|
||||
*
|
||||
* <p>The timeline crops tiles to a square this way instead of squashing
|
||||
* them, which is the whole reason the region variant exists.
|
||||
*/
|
||||
void image(TextureHandle texture, int x, int y, int width, int height,
|
||||
float u0, float v0, float u1, float v1);
|
||||
|
||||
/**
|
||||
* Clips subsequent drawing to a rectangle until the matching
|
||||
* {@link #popClip()}. Nests.
|
||||
*/
|
||||
void pushClip(int x, int y, int width, int height);
|
||||
|
||||
void popClip();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package dev.photosync.mcapi.render;
|
||||
|
||||
/**
|
||||
* A texture living on the GPU, owned by whoever asked {@link TextureSink} for it.
|
||||
*
|
||||
* <p>Closing it is not optional: the browser can walk through thousands of
|
||||
* thumbnails in a session, and a leaked texture is leaked video memory for as
|
||||
* long as the game runs. The UI's cache is what closes these, on eviction and
|
||||
* when its screen goes away.
|
||||
*/
|
||||
public interface TextureHandle extends AutoCloseable {
|
||||
|
||||
int width();
|
||||
|
||||
int height();
|
||||
|
||||
/** Releases the GPU resource. Must be called on the render thread. Idempotent. */
|
||||
@Override
|
||||
void close();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package dev.photosync.mcapi.render;
|
||||
|
||||
import dev.photosync.core.thumbnail.ThumbImage;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* Turns pixels into something drawable.
|
||||
*
|
||||
* <p>Both methods must be called on the render thread, which is why the loaders
|
||||
* in {@code :shared:core} deal in bytes and leave the upload to the UI: network
|
||||
* work happens on a worker, and only the last cheap step crosses back onto the
|
||||
* thread that can actually talk to the GPU.
|
||||
*/
|
||||
public interface TextureSink {
|
||||
|
||||
/**
|
||||
* Uploads a decoded placeholder -- at most 32x32 -- from a ThumbHash.
|
||||
*
|
||||
* <p>Kept separate from {@link #decode} because these are already pixels and
|
||||
* routing them through an image decoder would mean encoding a PNG first.
|
||||
*/
|
||||
TextureHandle upload(ThumbImage image);
|
||||
|
||||
/**
|
||||
* Decodes and uploads PNG or JPEG bytes.
|
||||
*
|
||||
* @throws IOException if the bytes are not an image the game can read
|
||||
*/
|
||||
TextureHandle decode(byte[] encoded) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package dev.photosync.mcapi.screen;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/** Opens and closes {@link ScreenModel}s. Call on the render thread. */
|
||||
public interface ScreenHost {
|
||||
|
||||
void open(ScreenModel screen);
|
||||
|
||||
/** Closes whatever is open, returning the player to the game. */
|
||||
void close();
|
||||
|
||||
/** The PhotoSync screen currently open, if the open screen is one of ours. */
|
||||
Optional<ScreenModel> current();
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package dev.photosync.mcapi.screen;
|
||||
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
|
||||
/**
|
||||
* A PhotoSync screen, in terms that owe nothing to Minecraft.
|
||||
*
|
||||
* <p>Each platform module has exactly one class that extends Minecraft's
|
||||
* {@code Screen} and forwards its lifecycle and input here. That class is the
|
||||
* only thing about the mod's entire interface that a new Minecraft version can
|
||||
* break.
|
||||
*
|
||||
* <p>The input methods answer whether they consumed the event, matching what
|
||||
* vanilla screens expect, so the adapter can pass the result straight through.
|
||||
*/
|
||||
public interface ScreenModel {
|
||||
|
||||
/** Shown in the window title and read by screen readers. Already translated. */
|
||||
String title();
|
||||
|
||||
/**
|
||||
* Called when the screen opens and again on every resize, with the current
|
||||
* GUI dimensions. Everything laid out in pixels should be computed here.
|
||||
*/
|
||||
void layout(int width, int height);
|
||||
|
||||
void render(RenderBridge render, int mouseX, int mouseY);
|
||||
|
||||
/** Once per client tick, for cursor blink and other time-based state. */
|
||||
default void tick() {
|
||||
}
|
||||
|
||||
default boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** {@code amount} is positive when scrolling up, as vanilla reports it. */
|
||||
default boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
default boolean charTyped(char character, int modifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The screen is going away, for any reason. Release textures here. */
|
||||
default void closed() {
|
||||
}
|
||||
|
||||
/** Whether opening this screen should pause a singleplayer world. */
|
||||
default boolean pausesGame() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether Escape closes the screen. False while a modal is up -- the quit
|
||||
* dialog in particular, which has to be answered rather than dismissed.
|
||||
*/
|
||||
default boolean closeOnEscape() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Screens, layout and interaction, drawn entirely through mc-api's RenderBridge.
|
||||
// Nothing in this module may import a Minecraft or Fabric type.
|
||||
dependencies {
|
||||
api project(':shared:core')
|
||||
api project(':shared:mc-api')
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package dev.photosync.ui;
|
||||
|
||||
import dev.photosync.mcapi.Translator;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* PhotoSync's visual vocabulary: the handful of compound shapes that appear on
|
||||
* more than one screen, drawn on top of {@code RenderBridge}'s primitives.
|
||||
*
|
||||
* <p>This is not a bag of helpers. It is the object that knows what a PhotoSync
|
||||
* panel looks like, and it owns the {@link Theme} because every one of these
|
||||
* decisions is a theme decision. Widgets and screens each hold one; nothing in
|
||||
* the module reaches for a colour without going through it.
|
||||
*/
|
||||
@Getter
|
||||
@Accessors(fluent = true)
|
||||
@RequiredArgsConstructor
|
||||
public final class Chrome {
|
||||
|
||||
private final Theme theme;
|
||||
private final Translator text;
|
||||
|
||||
/** Shorthand for the common case of a key with no arguments. */
|
||||
public String translate(String key, Object... arguments) {
|
||||
return text.get(key, arguments);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Surfaces
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Dims the whole screen so the panel reads as being in front of the world. */
|
||||
public void scrim(RenderBridge render) {
|
||||
render.fill(0, 0, render.width(), render.height(), theme.scrim());
|
||||
}
|
||||
|
||||
/** The main window: filled body, one-pixel border, header strip along the top. */
|
||||
public void panel(RenderBridge render, Rect bounds) {
|
||||
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.panel());
|
||||
render.border(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.panelBorder());
|
||||
}
|
||||
|
||||
/** A recessed area -- list viewports and the timeline grid sit in one of these. */
|
||||
public void well(RenderBridge render, Rect bounds) {
|
||||
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.surfaceSunken());
|
||||
}
|
||||
|
||||
public void divider(RenderBridge render, int x, int y, int width) {
|
||||
render.fill(x, y, width, 1, theme.panelBorder());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Text
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public void label(RenderBridge render, String value, int x, int y, int argb) {
|
||||
render.text(value, x, y, argb, true);
|
||||
}
|
||||
|
||||
/** Draws {@code value} centred horizontally within {@code bounds}. */
|
||||
public void centered(RenderBridge render, String value, Rect bounds, int argb) {
|
||||
int x = bounds.x() + (bounds.width() - render.textWidth(value)) / 2;
|
||||
int y = bounds.y() + (bounds.height() - render.lineHeight()) / 2 + 1;
|
||||
render.text(value, x, y, argb, true);
|
||||
}
|
||||
|
||||
/** Draws {@code value}, vertically centred in {@code bounds} and clipped to its width. */
|
||||
public void fitted(RenderBridge render, String value, Rect bounds, int argb) {
|
||||
int y = bounds.y() + (bounds.height() - render.lineHeight()) / 2 + 1;
|
||||
render.text(elide(render, value, bounds.width()), bounds.x(), y, argb, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shortens {@code value} with an ellipsis until it fits.
|
||||
*
|
||||
* <p>Needed constantly: file names, album names, error messages and server
|
||||
* URLs are all attacker-of-layout length, and the alternative to eliding is
|
||||
* text spilling across a neighbouring column.
|
||||
*/
|
||||
public String elide(RenderBridge render, String value, int maxWidth) {
|
||||
if (render.textWidth(value) <= maxWidth) {
|
||||
return value;
|
||||
}
|
||||
String ellipsis = "...";
|
||||
int budget = maxWidth - render.textWidth(ellipsis);
|
||||
if (budget <= 0) {
|
||||
return "";
|
||||
}
|
||||
// Linear from the end rather than a binary search: these strings are
|
||||
// short, and this runs a few dozen times a frame at most.
|
||||
int end = value.length();
|
||||
while (end > 0 && render.textWidth(value.substring(0, end)) > budget) {
|
||||
end--;
|
||||
}
|
||||
return value.substring(0, end) + ellipsis;
|
||||
}
|
||||
|
||||
/** Wraps to at most {@code maxLines}, eliding the last one if it still overflows. */
|
||||
public java.util.List<String> wrap(RenderBridge render, String value, int maxWidth, int maxLines) {
|
||||
java.util.List<String> lines = new java.util.ArrayList<>();
|
||||
StringBuilder line = new StringBuilder();
|
||||
for (String word : value.split("\\s+")) {
|
||||
String candidate = line.isEmpty() ? word : line + " " + word;
|
||||
if (render.textWidth(candidate) <= maxWidth || line.isEmpty()) {
|
||||
line.setLength(0);
|
||||
line.append(candidate);
|
||||
} else {
|
||||
lines.add(line.toString());
|
||||
line.setLength(0);
|
||||
line.append(word);
|
||||
if (lines.size() == maxLines - 1) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!line.isEmpty() && lines.size() < maxLines) {
|
||||
lines.add(lines.size() == maxLines - 1 ? elide(render, line.toString(), maxWidth) : line.toString());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Indicators
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** A filled bar. {@code fraction} is clamped, so a bad total cannot draw outside. */
|
||||
public void progressBar(RenderBridge render, Rect bounds, double fraction, int argb) {
|
||||
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.surfaceSunken());
|
||||
int filled = (int) Math.round(bounds.width() * Math.max(0.0, Math.min(1.0, fraction)));
|
||||
if (filled > 0) {
|
||||
render.fill(bounds.x(), bounds.y(), filled, bounds.height(), argb);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An indeterminate bar, for work whose size is not known yet.
|
||||
*
|
||||
* <p>Driven by wall-clock time rather than a tick counter so it keeps moving
|
||||
* while the game is paused -- which is exactly when the player is looking at
|
||||
* a PhotoSync screen.
|
||||
*/
|
||||
public void busyBar(RenderBridge render, Rect bounds, long nowMillis, int argb) {
|
||||
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(), theme.surfaceSunken());
|
||||
int span = Math.max(8, bounds.width() / 4);
|
||||
int travel = bounds.width() + span;
|
||||
int offset = (int) ((nowMillis / 4) % travel) - span;
|
||||
int start = Math.max(bounds.x(), bounds.x() + offset);
|
||||
int end = Math.min(bounds.right(), bounds.x() + offset + span);
|
||||
if (end > start) {
|
||||
render.fill(start, bounds.y(), end - start, bounds.height(), argb);
|
||||
}
|
||||
}
|
||||
|
||||
/** A small filled pill with a label, used for states and counts. */
|
||||
public void badge(RenderBridge render, String value, int x, int y, int background, int foreground) {
|
||||
int width = render.textWidth(value) + 6;
|
||||
int height = render.lineHeight() + 2;
|
||||
render.fill(x, y, width, height, background);
|
||||
render.text(value, x + 3, y + 2, foreground, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* The marker drawn over a video's still preview.
|
||||
*
|
||||
* <p>PhotoSync never plays video, so this is the whole of its video support:
|
||||
* a triangle in the corner that says "this is a clip, go and watch it
|
||||
* somewhere that can".
|
||||
*/
|
||||
public void videoMarker(RenderBridge render, Rect tile, String duration) {
|
||||
int size = 9;
|
||||
int x = tile.right() - size - 3;
|
||||
int y = tile.bottom() - size - 3;
|
||||
render.fill(x - 1, y - 1, size + 2, size + 2, theme.overlay());
|
||||
// A play triangle from horizontal runs -- there is no primitive for a
|
||||
// polygon, and at nine pixels nobody can tell the difference.
|
||||
for (int row = 0; row < size; row++) {
|
||||
int distance = Math.abs(row - size / 2);
|
||||
int length = Math.max(1, (size / 2) - distance + 1);
|
||||
render.fill(x + 2, y + row, length, 1, theme.text());
|
||||
}
|
||||
if (duration != null && !duration.isEmpty()) {
|
||||
int width = render.textWidth(duration);
|
||||
render.fill(tile.x() + 2, tile.bottom() - render.lineHeight() - 3, width + 4, render.lineHeight() + 2,
|
||||
theme.overlay());
|
||||
render.text(duration, tile.x() + 4, tile.bottom() - render.lineHeight() - 2, theme.text(), false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Centred message for an empty list, a load failure, or "not configured yet". */
|
||||
public void notice(RenderBridge render, Rect bounds, String headline, String detail) {
|
||||
int lineHeight = render.lineHeight();
|
||||
int totalHeight = detail == null || detail.isEmpty() ? lineHeight : lineHeight * 2 + 3;
|
||||
int top = bounds.y() + (bounds.height() - totalHeight) / 2;
|
||||
centered(render, headline, new Rect(bounds.x(), top, bounds.width(), lineHeight), theme.textMuted());
|
||||
if (detail != null && !detail.isEmpty()) {
|
||||
centered(render, elide(render, detail, bounds.width() - 16),
|
||||
new Rect(bounds.x(), top + lineHeight + 3, bounds.width(), lineHeight), theme.textFaint());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package dev.photosync.ui;
|
||||
|
||||
import dev.photosync.core.PhotoSync;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
import dev.photosync.core.provider.Album;
|
||||
import dev.photosync.core.provider.AlbumRef;
|
||||
import dev.photosync.mcapi.ClientBridge;
|
||||
import dev.photosync.mcapi.GameContext;
|
||||
import dev.photosync.mcapi.lifecycle.QuitGuard;
|
||||
import dev.photosync.ui.notify.Notifications;
|
||||
import dev.photosync.ui.screen.AlbumPickerScreen;
|
||||
import dev.photosync.ui.screen.PhotoSyncScreen;
|
||||
import dev.photosync.ui.screen.PhotoSyncScreen.Tab;
|
||||
import dev.photosync.ui.screen.QueueScreen;
|
||||
import dev.photosync.ui.screen.QuitDialog;
|
||||
import dev.photosync.ui.screen.SettingsScreen;
|
||||
import dev.photosync.ui.screen.TimelineScreen;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* What the screens share: the game, the core, and the handful of decisions that
|
||||
* outlive any one of them.
|
||||
*
|
||||
* <p>Screens are cheap and short-lived -- switching tabs builds a new one -- so
|
||||
* anything that must survive a tab switch lives here rather than in a screen.
|
||||
* That is three things: which album the browser is pointed at, the settings
|
||||
* draft, and the album names the picker has seen.
|
||||
*
|
||||
* <p>The draft deserves a word. It is null while the saved config is what the
|
||||
* player is looking at, and non-null once they have edited something. That makes
|
||||
* "is there anything to save?" a null check plus an equality test on two records,
|
||||
* rather than a dirty flag that someone has to remember to clear.
|
||||
*/
|
||||
@Accessors(fluent = true)
|
||||
public final class PhotoSyncUi {
|
||||
|
||||
@Getter
|
||||
private final PhotoSync core;
|
||||
@Getter
|
||||
private final ClientBridge bridge;
|
||||
@Getter
|
||||
private final Chrome chrome;
|
||||
@Getter
|
||||
private final Notifications notifications;
|
||||
|
||||
/** Album display names learned from the picker, so settings can show one. */
|
||||
private final Map<String, String> albumNames = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* Whether the browser is showing everything rather than the upload album.
|
||||
* A view preference, not a setting -- it is not worth persisting, and the
|
||||
* album the player configured is the right thing to open on.
|
||||
*/
|
||||
@Getter
|
||||
private boolean browsingLibrary;
|
||||
|
||||
private PhotoSyncConfig draft;
|
||||
private AlbumRef browsingTarget;
|
||||
|
||||
public PhotoSyncUi(PhotoSync core, ClientBridge bridge, Theme theme) {
|
||||
this.core = core;
|
||||
this.bridge = bridge;
|
||||
this.chrome = new Chrome(theme, bridge.text());
|
||||
this.notifications = new Notifications(chrome, () -> core.config().current().notifications());
|
||||
}
|
||||
|
||||
public GameContext game() {
|
||||
return bridge.game();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Navigation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The entry point the key binding calls. */
|
||||
public void open() {
|
||||
open(core.config().current().isReady() ? Tab.QUEUE : Tab.SETTINGS);
|
||||
}
|
||||
|
||||
public void open(Tab tab) {
|
||||
bridge.screens().open(screenFor(tab));
|
||||
}
|
||||
|
||||
public void openAlbumPicker() {
|
||||
bridge.screens().open(new AlbumPickerScreen(this));
|
||||
}
|
||||
|
||||
public void close() {
|
||||
bridge.screens().close();
|
||||
}
|
||||
|
||||
private PhotoSyncScreen screenFor(Tab tab) {
|
||||
return switch (tab) {
|
||||
case QUEUE -> new QueueScreen(this);
|
||||
case BROWSE -> new TimelineScreen(this);
|
||||
case SETTINGS -> new SettingsScreen(this);
|
||||
};
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Browsing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public void browsingLibrary(boolean value) {
|
||||
this.browsingLibrary = value;
|
||||
ensureBrowsing();
|
||||
}
|
||||
|
||||
/**
|
||||
* Points the browser at whatever the player is currently asking to see.
|
||||
*
|
||||
* <p>Called on every layout, so it has to be free when nothing changed:
|
||||
* {@code TimelineBrowser.open} throws away everything it has loaded, which
|
||||
* would turn a window resize into a full reload.
|
||||
*/
|
||||
public void ensureBrowsing() {
|
||||
AlbumRef target = browsingLibrary ? AlbumRef.library() : core.config().current().album();
|
||||
if (target.equals(browsingTarget)) {
|
||||
return;
|
||||
}
|
||||
browsingTarget = target;
|
||||
core.browser().open(target);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The settings draft
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The config the settings screen is editing: the draft, or the saved one. */
|
||||
public PhotoSyncConfig draft() {
|
||||
return draft == null ? core.config().current() : draft;
|
||||
}
|
||||
|
||||
public void draft(PhotoSyncConfig edited) {
|
||||
this.draft = edited;
|
||||
}
|
||||
|
||||
public boolean draftIsDirty() {
|
||||
return draft != null && !draft.equals(core.config().current());
|
||||
}
|
||||
|
||||
/** Throws the edits away; the screen rebuilds from the saved config. */
|
||||
public void resetDraft() {
|
||||
this.draft = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the draft. Called by the Save button and again when the settings
|
||||
* screen closes, so an edit cannot be lost by pressing Escape -- which is
|
||||
* what a player who has finished typing will do.
|
||||
*/
|
||||
public void applyDraft() {
|
||||
PhotoSyncConfig pending = draft;
|
||||
this.draft = null;
|
||||
if (pending == null || pending.equals(core.config().current())) {
|
||||
return;
|
||||
}
|
||||
core.config().update(current -> pending);
|
||||
// The album may have moved with it, and the browser is showing the old one.
|
||||
ensureBrowsing();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Albums
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
public void rememberAlbums(List<Album> albums) {
|
||||
albums.forEach(album -> albumNames.put(album.id(), album.name()));
|
||||
}
|
||||
|
||||
/** What the settings screen's album button says. */
|
||||
public String albumLabel() {
|
||||
String id = draft().albumId();
|
||||
if (id.isEmpty()) {
|
||||
return chrome.translate("photosync.album.library");
|
||||
}
|
||||
// The id is a poor label, but it is honest: it means the player picked
|
||||
// this album on another machine and we have not seen the list yet.
|
||||
return albumNames.getOrDefault(id, id);
|
||||
}
|
||||
|
||||
/** Chosen from the server's own list, so it is saved rather than drafted. */
|
||||
public void chooseAlbum(String albumId) {
|
||||
core.config().update(current -> current.toBuilder().albumId(albumId).build());
|
||||
if (draft != null) {
|
||||
draft = draft.toBuilder().albumId(albumId).build();
|
||||
}
|
||||
ensureBrowsing();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Quitting
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Puts the quit dialog up. Called from the {@link QuitGuard} handler.
|
||||
*
|
||||
* <p>Idempotent, and it has to be: closing the window sets a flag GLFW keeps
|
||||
* set, so the client asks the guard again on every frame until it either
|
||||
* gets a yes or the player clicks something. Re-opening the dialog each time
|
||||
* would reset its state and eat the click.
|
||||
*/
|
||||
public void confirmQuit() {
|
||||
if (bridge.screens().current().filter(QuitDialog.class::isInstance).isPresent()) {
|
||||
return;
|
||||
}
|
||||
bridge.screens().open(new QuitDialog(this));
|
||||
}
|
||||
|
||||
/** Opens the gate and lets the game go. */
|
||||
public void quitNow() {
|
||||
QuitGuard.get().allowOnce();
|
||||
bridge.screens().close();
|
||||
game().quit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package dev.photosync.ui;
|
||||
|
||||
/**
|
||||
* An axis-aligned rectangle in GUI space.
|
||||
*
|
||||
* <p>Layout in this module is arithmetic on these rather than a constraint
|
||||
* solver: the screens are simple enough that a solver would be more machinery
|
||||
* than the problem deserves, and every position stays inspectable in a debugger.
|
||||
*/
|
||||
public record Rect(int x, int y, int width, int height) {
|
||||
|
||||
public static final Rect EMPTY = new Rect(0, 0, 0, 0);
|
||||
|
||||
public int right() {
|
||||
return x + width;
|
||||
}
|
||||
|
||||
public int bottom() {
|
||||
return y + height;
|
||||
}
|
||||
|
||||
public int centerX() {
|
||||
return x + width / 2;
|
||||
}
|
||||
|
||||
public int centerY() {
|
||||
return y + height / 2;
|
||||
}
|
||||
|
||||
public boolean isEmpty() {
|
||||
return width <= 0 || height <= 0;
|
||||
}
|
||||
|
||||
/** Mouse coordinates arrive as doubles, so this takes them as they come. */
|
||||
public boolean contains(double pointX, double pointY) {
|
||||
return pointX >= x && pointX < right() && pointY >= y && pointY < bottom();
|
||||
}
|
||||
|
||||
/** Shrinks on all four sides. A negative amount grows instead. */
|
||||
public Rect inset(int amount) {
|
||||
return inset(amount, amount, amount, amount);
|
||||
}
|
||||
|
||||
public Rect inset(int left, int top, int right, int bottom) {
|
||||
return new Rect(x + left, y + top, width - left - right, height - top - bottom);
|
||||
}
|
||||
|
||||
public Rect translate(int deltaX, int deltaY) {
|
||||
return new Rect(x + deltaX, y + deltaY, width, height);
|
||||
}
|
||||
|
||||
public Rect withHeight(int newHeight) {
|
||||
return new Rect(x, y, width, newHeight);
|
||||
}
|
||||
|
||||
public Rect withWidth(int newWidth) {
|
||||
return new Rect(x, y, newWidth, height);
|
||||
}
|
||||
|
||||
/** The top strip of this rectangle. */
|
||||
public Rect top(int amount) {
|
||||
return new Rect(x, y, width, Math.min(amount, height));
|
||||
}
|
||||
|
||||
/** The bottom strip of this rectangle. */
|
||||
public Rect bottom(int amount) {
|
||||
int taken = Math.min(amount, height);
|
||||
return new Rect(x, bottom() - taken, width, taken);
|
||||
}
|
||||
|
||||
/** The left column of this rectangle. */
|
||||
public Rect left(int amount) {
|
||||
return new Rect(x, y, Math.min(amount, width), height);
|
||||
}
|
||||
|
||||
/** The right column of this rectangle. */
|
||||
public Rect right(int amount) {
|
||||
int taken = Math.min(amount, width);
|
||||
return new Rect(right() - taken, y, taken, height);
|
||||
}
|
||||
|
||||
/** What is left after taking {@code amount} off the top. */
|
||||
public Rect dropTop(int amount) {
|
||||
return new Rect(x, y + amount, width, Math.max(0, height - amount));
|
||||
}
|
||||
|
||||
/** What is left after taking {@code amount} off the bottom. */
|
||||
public Rect dropBottom(int amount) {
|
||||
return new Rect(x, y, width, Math.max(0, height - amount));
|
||||
}
|
||||
|
||||
/** What is left after taking {@code amount} off the left. */
|
||||
public Rect dropLeft(int amount) {
|
||||
return new Rect(x + amount, y, Math.max(0, width - amount), height);
|
||||
}
|
||||
|
||||
/** What is left after taking {@code amount} off the right. */
|
||||
public Rect dropRight(int amount) {
|
||||
return new Rect(x, y, Math.max(0, width - amount), height);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package dev.photosync.ui;
|
||||
|
||||
import lombok.Builder;
|
||||
|
||||
/**
|
||||
* Every colour and metric the interface uses, in one value.
|
||||
*
|
||||
* <p>Gathered here rather than spread across the widgets so that a colour has a
|
||||
* name and a single definition, and so that a screen cannot quietly invent its
|
||||
* own shade of grey. Widgets receive one of these through {@link Chrome}; none
|
||||
* of them hold colours of their own.
|
||||
*
|
||||
* <p>Colours are packed 0xAARRGGBB, matching {@code RenderBridge}.
|
||||
*/
|
||||
@Builder(toBuilder = true)
|
||||
public record Theme(
|
||||
int scrim,
|
||||
int panel,
|
||||
int panelBorder,
|
||||
int header,
|
||||
int surface,
|
||||
int surfaceHover,
|
||||
int surfacePressed,
|
||||
int surfaceSunken,
|
||||
int accent,
|
||||
int accentHover,
|
||||
int accentText,
|
||||
int text,
|
||||
int textMuted,
|
||||
int textFaint,
|
||||
int success,
|
||||
int warning,
|
||||
int danger,
|
||||
int scrollTrack,
|
||||
int scrollThumb,
|
||||
int scrollThumbHover,
|
||||
int tilePlaceholder,
|
||||
int overlay,
|
||||
int padding,
|
||||
int gap,
|
||||
int rowHeight,
|
||||
int controlHeight,
|
||||
int headerHeight,
|
||||
int footerHeight,
|
||||
int tileGap) {
|
||||
|
||||
/**
|
||||
* The one theme PhotoSync ships.
|
||||
*
|
||||
* <p>Dark regardless of anything else on screen: these panels sit over a
|
||||
* rendered world, and a light surface there is a flashbang in a night scene.
|
||||
*/
|
||||
public static Theme dark() {
|
||||
return Theme.builder()
|
||||
// Not opaque -- the world stays faintly visible behind the panel,
|
||||
// which is what makes an in-game screen feel like part of the game.
|
||||
.scrim(0xB8000000)
|
||||
.panel(0xF01A1A21)
|
||||
.panelBorder(0xFF3B3B49)
|
||||
.header(0xFF23232D)
|
||||
.surface(0xFF272733)
|
||||
.surfaceHover(0xFF33333F)
|
||||
.surfacePressed(0xFF3E3E4D)
|
||||
.surfaceSunken(0xFF15151B)
|
||||
.accent(0xFF4C8DFF)
|
||||
.accentHover(0xFF6BA1FF)
|
||||
.accentText(0xFFFFFFFF)
|
||||
.text(0xFFE9E9F1)
|
||||
.textMuted(0xFF9C9CAD)
|
||||
.textFaint(0xFF63636F)
|
||||
.success(0xFF5BC98A)
|
||||
.warning(0xFFE3B44A)
|
||||
.danger(0xFFE86A6A)
|
||||
.scrollTrack(0xFF1E1E26)
|
||||
.scrollThumb(0xFF454556)
|
||||
.scrollThumbHover(0xFF5C5C71)
|
||||
.tilePlaceholder(0xFF2A2A35)
|
||||
.overlay(0xA0000000)
|
||||
.padding(8)
|
||||
.gap(4)
|
||||
.rowHeight(30)
|
||||
.controlHeight(18)
|
||||
.headerHeight(28)
|
||||
.footerHeight(18)
|
||||
.tileGap(4)
|
||||
.build();
|
||||
}
|
||||
|
||||
/** The same colour at a different opacity, for hints and disabled states. */
|
||||
public int fade(int argb, float alpha) {
|
||||
int scaled = Math.round(((argb >>> 24) & 0xFF) * Math.max(0f, Math.min(1f, alpha)));
|
||||
return (scaled << 24) | (argb & 0x00FFFFFF);
|
||||
}
|
||||
|
||||
/** Picks the right surface shade for a control's current interaction state. */
|
||||
public int surfaceFor(boolean hovered, boolean pressed) {
|
||||
if (pressed) {
|
||||
return surfacePressed;
|
||||
}
|
||||
return hovered ? surfaceHover : surface;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package dev.photosync.ui.image;
|
||||
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.ThumbnailSize;
|
||||
import dev.photosync.core.thumbnail.ThumbHash;
|
||||
import dev.photosync.core.thumbnail.ThumbImage;
|
||||
import dev.photosync.core.thumbnail.ThumbnailLoader;
|
||||
import dev.photosync.mcapi.render.TextureHandle;
|
||||
import dev.photosync.mcapi.render.TextureSink;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Iterator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* The textures behind the timeline grid: what is on the GPU, what is on its way,
|
||||
* and what has to go.
|
||||
*
|
||||
* <p>An asset shows something on the very first frame it appears, because the
|
||||
* timeline page already carried a ThumbHash -- twenty-odd bytes that decode to a
|
||||
* blurred 32x32 -- and swaps in the real thumbnail when it arrives. That is the
|
||||
* difference between a grid that fills in and a grid of grey boxes.
|
||||
*
|
||||
* <p>Everything here runs on the render thread. The fetching does not: this
|
||||
* class only ever <em>polls</em> {@link ThumbnailLoader}'s futures, so a slow
|
||||
* server stalls a tile rather than a frame.
|
||||
*
|
||||
* <p>Eviction is least-recently-drawn, bounded by the player's cache setting,
|
||||
* and skips anything drawn in the current frame. Without that last rule a grid
|
||||
* with more visible tiles than the cache holds would evict a texture it is about
|
||||
* to draw and re-request it forever.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class ThumbnailCache implements AutoCloseable {
|
||||
|
||||
/** What to draw for one asset, and whether it is the real thing yet. */
|
||||
public record Thumbnail(TextureHandle texture, boolean placeholder) {
|
||||
}
|
||||
|
||||
private final TextureSink textures;
|
||||
private final ThumbnailLoader loader;
|
||||
private final ThumbnailSize size;
|
||||
|
||||
/** Access-ordered, so its iteration order is the eviction order. */
|
||||
private final LinkedHashMap<String, Entry> entries = new LinkedHashMap<>(64, 0.75f, true);
|
||||
|
||||
private int capacity;
|
||||
private long frame;
|
||||
|
||||
public ThumbnailCache(TextureSink textures, ThumbnailLoader loader, ThumbnailSize size, int capacity) {
|
||||
this.textures = textures;
|
||||
this.loader = loader;
|
||||
this.size = size;
|
||||
this.capacity = Math.max(8, capacity);
|
||||
}
|
||||
|
||||
/** Follows the player's setting without discarding what is already loaded. */
|
||||
public void capacity(int value) {
|
||||
this.capacity = Math.max(8, value);
|
||||
}
|
||||
|
||||
/** Call once at the top of a frame, before any {@link #of} in that frame. */
|
||||
public void beginFrame() {
|
||||
frame++;
|
||||
}
|
||||
|
||||
/**
|
||||
* What to draw for {@code asset} right now, starting a fetch if this is the
|
||||
* first time it has been asked for.
|
||||
*
|
||||
* <p>Empty means there is genuinely nothing yet -- no ThumbHash and no
|
||||
* thumbnail -- which the grid draws as a plain tile.
|
||||
*/
|
||||
public Optional<Thumbnail> of(RemoteAsset asset) {
|
||||
Entry entry = entries.computeIfAbsent(asset.id(), id -> new Entry(asset));
|
||||
entry.touched = frame;
|
||||
entry.poll();
|
||||
return entry.thumbnail();
|
||||
}
|
||||
|
||||
/** Whether this asset's thumbnail failed outright, so the grid can mark it. */
|
||||
public boolean failed(String assetId) {
|
||||
Entry entry = entries.get(assetId);
|
||||
return entry != null && entry.failed;
|
||||
}
|
||||
|
||||
/** Call at the end of a frame, once every visible tile has been asked for. */
|
||||
public void endFrame() {
|
||||
if (entries.size() <= capacity) {
|
||||
return;
|
||||
}
|
||||
Iterator<Map.Entry<String, Entry>> stale = entries.entrySet().iterator();
|
||||
while (entries.size() > capacity && stale.hasNext()) {
|
||||
Entry entry = stale.next().getValue();
|
||||
// Never evict something drawn this frame: on a screen with more
|
||||
// tiles than the cache holds, that would thrash rather than cache.
|
||||
if (entry.touched == frame) {
|
||||
continue;
|
||||
}
|
||||
entry.close();
|
||||
stale.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops everything -- when the album changes, or the screen closes. */
|
||||
public void clear() {
|
||||
entries.values().forEach(Entry::close);
|
||||
entries.clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
clear();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private final class Entry implements AutoCloseable {
|
||||
|
||||
private final RemoteAsset asset;
|
||||
|
||||
private TextureHandle texture;
|
||||
private boolean real;
|
||||
private boolean failed;
|
||||
private CompletableFuture<byte[]> pending;
|
||||
private long touched;
|
||||
|
||||
private Entry(RemoteAsset asset) {
|
||||
this.asset = asset;
|
||||
this.texture = decodeHash(asset);
|
||||
}
|
||||
|
||||
private Optional<Thumbnail> thumbnail() {
|
||||
return texture == null ? Optional.empty() : Optional.of(new Thumbnail(texture, !real));
|
||||
}
|
||||
|
||||
/**
|
||||
* Advances this entry by whatever is available without blocking: start a
|
||||
* request, or take delivery of one.
|
||||
*/
|
||||
private void poll() {
|
||||
if (real || failed) {
|
||||
return;
|
||||
}
|
||||
if (pending == null) {
|
||||
// Empty means the loader is saturated -- a "not now", not a
|
||||
// failure. Asking again next frame is the retry.
|
||||
pending = loader.request(asset.id(), size).orElse(null);
|
||||
return;
|
||||
}
|
||||
if (!pending.isDone()) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture<byte[]> finished = pending;
|
||||
pending = null;
|
||||
try {
|
||||
adopt(textures.decode(finished.join()));
|
||||
real = true;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.debug("Thumbnail {} is not drawable: {}", asset.id(), e.toString());
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Swaps in a new texture and releases whatever it replaces. */
|
||||
private void adopt(TextureHandle replacement) {
|
||||
if (texture != null) {
|
||||
texture.close();
|
||||
}
|
||||
texture = replacement;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (pending != null) {
|
||||
pending.cancel(false);
|
||||
pending = null;
|
||||
}
|
||||
if (texture != null) {
|
||||
texture.close();
|
||||
texture = null;
|
||||
}
|
||||
}
|
||||
|
||||
private TextureHandle decodeHash(RemoteAsset source) {
|
||||
return source.thumbHash()
|
||||
.flatMap(ThumbHash::decode)
|
||||
.map(ThumbnailCache.this::upload)
|
||||
.orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
private TextureHandle upload(ThumbImage image) {
|
||||
return textures.upload(image);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package dev.photosync.ui.notify;
|
||||
|
||||
import dev.photosync.core.config.NotificationKind;
|
||||
import dev.photosync.core.config.NotificationSettings;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.Theme;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The one-line messages in the bottom-left corner.
|
||||
*
|
||||
* <p>Sized to be ignorable. A player who takes a screenshot already knows they
|
||||
* took one; the message is there to say the upload worked, and it has earned
|
||||
* about a second and a half of the corner of their eye for that. So: no icon, no
|
||||
* panel, no sound, no animation beyond a fade -- one line of text that leaves.
|
||||
*
|
||||
* <p>{@link #show} is called from upload workers and from the capture thread, so
|
||||
* the list is concurrent. Everything else happens on the render thread.
|
||||
*/
|
||||
public final class Notifications {
|
||||
|
||||
/** Above this, the oldest is dropped rather than growing a wall of text. */
|
||||
private static final int MAX_VISIBLE = 3;
|
||||
private static final long FADE_MILLIS = 400;
|
||||
|
||||
private record Toast(NotificationKind kind, String message, long expiresAt) {
|
||||
}
|
||||
|
||||
private final Chrome chrome;
|
||||
private final Supplier<NotificationSettings> settings;
|
||||
private final List<Toast> live = new CopyOnWriteArrayList<>();
|
||||
|
||||
public Notifications(Chrome chrome, Supplier<NotificationSettings> settings) {
|
||||
this.chrome = chrome;
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Posts a message, if the player has that kind switched on. Safe to call
|
||||
* from any thread.
|
||||
*
|
||||
* @param message already translated -- the caller knows the arguments
|
||||
*/
|
||||
public void show(NotificationKind kind, String message) {
|
||||
NotificationSettings current = settings.get();
|
||||
if (!current.shows(kind)) {
|
||||
return;
|
||||
}
|
||||
live.add(new Toast(kind, message, System.currentTimeMillis() + current.lingerMillis()));
|
||||
while (live.size() > MAX_VISIBLE) {
|
||||
live.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops everything on screen -- used when the player turns notifications off. */
|
||||
public void clear() {
|
||||
live.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the stack, newest at the bottom.
|
||||
*
|
||||
* <p>Anchored to the very bottom of the screen rather than above it: that
|
||||
* strip is to the left of the hotbar and below the chat, which is the only
|
||||
* part of the corner that is reliably empty.
|
||||
*/
|
||||
public void render(RenderBridge render, long nowMillis) {
|
||||
live.removeIf(toast -> toast.expiresAt() <= nowMillis);
|
||||
if (live.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Theme theme = chrome.theme();
|
||||
int line = render.lineHeight() + 2;
|
||||
int y = render.height() - 4 - line;
|
||||
// Reverse order so the newest sits at the bottom and older ones rise.
|
||||
for (int i = live.size() - 1; i >= 0; i--) {
|
||||
Toast toast = live.get(i);
|
||||
float alpha = fade(toast, nowMillis);
|
||||
int background = theme.fade(theme.panel(), alpha * 0.85f);
|
||||
int width = render.textWidth(toast.message()) + 8;
|
||||
render.fill(4, y, width, line, background);
|
||||
render.fill(4, y, 1, line, theme.fade(colour(theme, toast.kind()), alpha));
|
||||
render.text(toast.message(), 8, y + 2, theme.fade(theme.text(), alpha), false);
|
||||
y -= line + 2;
|
||||
}
|
||||
}
|
||||
|
||||
private float fade(Toast toast, long nowMillis) {
|
||||
long remaining = toast.expiresAt() - nowMillis;
|
||||
return remaining >= FADE_MILLIS ? 1f : Math.max(0f, remaining / (float) FADE_MILLIS);
|
||||
}
|
||||
|
||||
private int colour(Theme theme, NotificationKind kind) {
|
||||
return switch (kind) {
|
||||
case CAPTURED -> theme.accent();
|
||||
case UPLOADED -> theme.success();
|
||||
case FAILED -> theme.danger();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Every screen, widget and pixel PhotoSync draws.
|
||||
*
|
||||
* <p>Nothing here imports a Minecraft or Fabric type. The UI talks to the game
|
||||
* exclusively through {@code :shared:mc-api} -- {@code RenderBridge} for
|
||||
* drawing, {@code ScreenModel} for lifecycle and input -- which is what lets one
|
||||
* copy of this code serve nine Minecraft versions.
|
||||
*
|
||||
* <p>It follows that this module draws its own widgets rather than using
|
||||
* vanilla's. That trade is examined in {@code docs/PORTING.md}; the short
|
||||
* version is that Minecraft's drawing primitives have been stable since 1.20
|
||||
* while its widget classes have not, so building on the former costs a few
|
||||
* hundred lines once and the latter would cost an adapter per widget per
|
||||
* version.
|
||||
*/
|
||||
package dev.photosync.ui;
|
||||
@@ -0,0 +1,269 @@
|
||||
package dev.photosync.ui.screen;
|
||||
|
||||
import dev.photosync.core.provider.Album;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.mcapi.Keys;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.widget.Button;
|
||||
import dev.photosync.ui.widget.ScrollModel;
|
||||
import dev.photosync.ui.widget.TextField;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Where uploads land: one album, or the whole library.
|
||||
*
|
||||
* <p>A separate screen rather than a dropdown in the settings form, because the
|
||||
* list comes off the network and can be long, empty, or an error -- three states
|
||||
* a dropdown has nowhere to put. It is reached from the settings screen and
|
||||
* returns to it, so it wears the settings tab.
|
||||
*
|
||||
* <p>Picking writes straight through to the saved config instead of the draft.
|
||||
* The player did not type this value, they chose it from the server's own list;
|
||||
* making them press Save afterwards would be asking them to confirm something
|
||||
* they already confirmed by clicking.
|
||||
*/
|
||||
public final class AlbumPickerScreen extends PhotoSyncScreen {
|
||||
|
||||
private static final int ROW_HEIGHT = 20;
|
||||
|
||||
private final ScrollModel scroll;
|
||||
private final TextField newAlbumName;
|
||||
|
||||
private Rect listArea = Rect.EMPTY;
|
||||
private Button back;
|
||||
private Button create;
|
||||
|
||||
private volatile List<Album> albums = List.of();
|
||||
private volatile String error = "";
|
||||
private volatile boolean loading;
|
||||
private boolean requested;
|
||||
|
||||
public AlbumPickerScreen(PhotoSyncUi ui) {
|
||||
super(ui);
|
||||
this.scroll = new ScrollModel(ui.chrome());
|
||||
this.newAlbumName = new TextField(chrome, ui.bridge().clipboard(), value -> {
|
||||
});
|
||||
this.newAlbumName.hint(chrome.translate("photosync.album.new_hint"));
|
||||
this.newAlbumName.maxLength(64);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Tab tab() {
|
||||
return Tab.SETTINGS;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void layoutBody(Rect area) {
|
||||
int gap = theme().gap();
|
||||
int control = theme().controlHeight();
|
||||
|
||||
Rect actions = area.bottom(control);
|
||||
Rect creation = area.dropBottom(control + gap).bottom(control);
|
||||
listArea = area.dropBottom((control + gap) * 2);
|
||||
scroll.viewport(listArea);
|
||||
|
||||
int createWidth = 64;
|
||||
// The text field is added first so a click on the button, which overlaps
|
||||
// nothing, still reaches it -- the list hit-tests by rectangle either way.
|
||||
newAlbumName.bounds(creation.dropRight(createWidth + gap));
|
||||
widgets.add(newAlbumName);
|
||||
create = widgets.add(new Button(chrome, chrome.translate("photosync.album.create"), this::createAlbum));
|
||||
create.bounds(creation.right(createWidth));
|
||||
|
||||
back = widgets.add(new Button(chrome, chrome.translate("photosync.album.back"),
|
||||
() -> ui.open(Tab.SETTINGS)));
|
||||
back.bounds(actions.left(80));
|
||||
|
||||
Button refresh = widgets.add(new Button(chrome, chrome.translate("photosync.album.refresh"), this::load));
|
||||
refresh.bounds(actions.right(80));
|
||||
|
||||
if (!requested) {
|
||||
requested = true;
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Talking to the server
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void load() {
|
||||
Optional<PhotoProvider> provider = ui.core().session().provider();
|
||||
if (provider.isEmpty()) {
|
||||
error = chrome.translate("photosync.album.not_configured");
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = "";
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
List<Album> loaded = provider.get().albums();
|
||||
albums = loaded;
|
||||
ui.rememberAlbums(loaded);
|
||||
} catch (ProviderException e) {
|
||||
error = e.getMessage();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void createAlbum() {
|
||||
String name = newAlbumName.value().trim();
|
||||
Optional<PhotoProvider> provider = ui.core().session().provider();
|
||||
if (name.isEmpty() || provider.isEmpty() || loading) {
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
error = "";
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try {
|
||||
Album created = provider.get().createAlbum(name);
|
||||
List<Album> merged = new ArrayList<>(albums);
|
||||
merged.add(created);
|
||||
albums = List.copyOf(merged);
|
||||
ui.rememberAlbums(albums);
|
||||
// Creating an album is only ever a prelude to using it.
|
||||
ui.game().submit(() -> {
|
||||
ui.chooseAlbum(created.id());
|
||||
newAlbumName.reset("");
|
||||
});
|
||||
} catch (ProviderException e) {
|
||||
error = e.getMessage();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
|
||||
scroll.contentHeight((albums.size() + 1) * ROW_HEIGHT);
|
||||
scroll.advance(System.currentTimeMillis());
|
||||
create.enabled(!newAlbumName.value().isBlank() && !loading);
|
||||
|
||||
chrome.well(render, listArea);
|
||||
if (!error.isEmpty()) {
|
||||
chrome.notice(render, listArea, chrome.translate("photosync.album.failed"), error);
|
||||
return;
|
||||
}
|
||||
|
||||
render.pushClip(listArea.x(), listArea.y(), listArea.width(), listArea.height());
|
||||
int width = listArea.width() - scroll.gutter();
|
||||
// Index -1 is the library row, which is always offered and always first:
|
||||
// it is the one choice that cannot fail to exist.
|
||||
for (int index = -1; index < albums.size(); index++) {
|
||||
Rect row = rowBounds(index, width);
|
||||
if (row.bottom() >= listArea.y() && row.y() <= listArea.bottom()) {
|
||||
renderRow(render, index, row, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
render.popClip();
|
||||
|
||||
scroll.render(render, mouseX, mouseY);
|
||||
if (loading) {
|
||||
chrome.busyBar(render, listArea.bottom(2), System.currentTimeMillis(), theme().accent());
|
||||
}
|
||||
}
|
||||
|
||||
private void renderRow(RenderBridge render, int index, Rect row, int mouseX, int mouseY) {
|
||||
boolean library = index < 0;
|
||||
Album album = library ? null : albums.get(index);
|
||||
String id = library ? "" : album.id();
|
||||
boolean selected = ui.draft().albumId().equals(id);
|
||||
boolean hovered = row.contains(mouseX, mouseY) && listArea.contains(mouseX, mouseY);
|
||||
|
||||
if (selected || hovered) {
|
||||
render.fill(row.x(), row.y(), row.width(), row.height(),
|
||||
selected ? theme().fade(theme().accent(), 0.30f) : theme().surfaceHover());
|
||||
}
|
||||
if (selected) {
|
||||
render.fill(row.x(), row.y(), 2, row.height(), theme().accent());
|
||||
}
|
||||
|
||||
String name = library ? chrome.translate("photosync.album.library") : album.name();
|
||||
String count = library ? "" : String.valueOf(album.assetCount());
|
||||
int countWidth = count.isEmpty() ? 0 : render.textWidth(count) + 8;
|
||||
chrome.fitted(render, chrome.elide(render, name, row.width() - 10 - countWidth),
|
||||
row.dropLeft(6).dropRight(countWidth), selected ? theme().text() : theme().textMuted());
|
||||
if (!count.isEmpty()) {
|
||||
chrome.fitted(render, count, row.dropRight(6).right(countWidth), theme().textFaint());
|
||||
}
|
||||
}
|
||||
|
||||
private Rect rowBounds(int index, int width) {
|
||||
return new Rect(listArea.x(), listArea.y() + (index + 1) * ROW_HEIGHT - scroll.offset(), width, ROW_HEIGHT);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String footerText() {
|
||||
if (loading) {
|
||||
return chrome.translate("photosync.album.loading");
|
||||
}
|
||||
return chrome.translate("photosync.album.status", ui.albumLabel());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (scroll.mouseClicked(mouseX, mouseY, button)) {
|
||||
return true;
|
||||
}
|
||||
if (button == 0 && listArea.contains(mouseX, mouseY) && error.isEmpty()) {
|
||||
int width = listArea.width() - scroll.gutter();
|
||||
for (int index = -1; index < albums.size(); index++) {
|
||||
if (rowBounds(index, width).contains(mouseX, mouseY)) {
|
||||
ui.chooseAlbum(index < 0 ? "" : albums.get(index).id());
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return scroll.mouseDragged(mouseX, mouseY, button)
|
||||
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
return scroll.mouseScrolled(mouseX, mouseY, amount, ROW_HEIGHT)
|
||||
|| super.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
/** Enter in the name field creates the album, which is what it looks like it should do. */
|
||||
@Override
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
if (newAlbumName.focused() && Keys.confirms(key)) {
|
||||
createAlbum();
|
||||
return true;
|
||||
}
|
||||
return super.keyPressed(key, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean closeOnEscape() {
|
||||
return !widgets.hasFocus();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
package dev.photosync.ui.screen;
|
||||
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.mcapi.screen.ScreenModel;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.Theme;
|
||||
import dev.photosync.ui.widget.WidgetList;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The frame every PhotoSync screen sits in: a centred panel with a title, the
|
||||
* three tabs, a body and a status line.
|
||||
*
|
||||
* <p>Having one frame is what makes the mod feel like one thing. It also means
|
||||
* the tabs are always in the same place, so "where do I change the album?" has
|
||||
* the same answer whichever screen the player happens to be looking at -- which
|
||||
* is the whole of the interaction design brief: set it up once, then only ever
|
||||
* think about screenshots.
|
||||
*
|
||||
* <p>Subclasses fill in the body. They get a {@link WidgetList} that already
|
||||
* handles focus and mouse capture, and a laid-out rectangle to put things in.
|
||||
*/
|
||||
public abstract class PhotoSyncScreen implements ScreenModel {
|
||||
|
||||
/** The three things the mod does, in the order a new player meets them. */
|
||||
@Getter
|
||||
@Accessors(fluent = true)
|
||||
@RequiredArgsConstructor
|
||||
public enum Tab {
|
||||
QUEUE("photosync.tab.queue"),
|
||||
BROWSE("photosync.tab.browse"),
|
||||
SETTINGS("photosync.tab.settings");
|
||||
|
||||
private final String titleKey;
|
||||
}
|
||||
|
||||
private static final int TAB_HEIGHT = 16;
|
||||
private static final int CLOSE_SIZE = 11;
|
||||
|
||||
protected final PhotoSyncUi ui;
|
||||
protected final Chrome chrome;
|
||||
protected final WidgetList widgets = new WidgetList();
|
||||
|
||||
private Rect panel = Rect.EMPTY;
|
||||
private Rect header = Rect.EMPTY;
|
||||
private Rect body = Rect.EMPTY;
|
||||
private Rect footer = Rect.EMPTY;
|
||||
private Rect closeButton = Rect.EMPTY;
|
||||
private final List<Rect> tabBounds = new ArrayList<>(Tab.values().length);
|
||||
|
||||
protected PhotoSyncScreen(PhotoSyncUi ui) {
|
||||
this.ui = ui;
|
||||
this.chrome = ui.chrome();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// What subclasses provide
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
protected abstract Tab tab();
|
||||
|
||||
/** Lay out the body. The widget list has already been cleared. */
|
||||
protected abstract void layoutBody(Rect area);
|
||||
|
||||
protected abstract void renderBody(RenderBridge render, int mouseX, int mouseY);
|
||||
|
||||
/** The status line along the bottom. Empty for none. */
|
||||
protected String footerText() {
|
||||
return "";
|
||||
}
|
||||
|
||||
protected int footerColour() {
|
||||
return theme().textFaint();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Frame
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public String title() {
|
||||
return chrome.translate(tab().titleKey());
|
||||
}
|
||||
|
||||
protected Theme theme() {
|
||||
return chrome.theme();
|
||||
}
|
||||
|
||||
protected Rect body() {
|
||||
return body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void layout(int width, int height) {
|
||||
Theme theme = theme();
|
||||
// Big enough for five columns of thumbnails, capped so it stays a panel
|
||||
// rather than swallowing the screen on a large display.
|
||||
int panelWidth = Math.max(220, Math.min(width - 24, 640));
|
||||
int panelHeight = Math.max(160, Math.min(height - 24, 420));
|
||||
panel = new Rect((width - panelWidth) / 2, (height - panelHeight) / 2, panelWidth, panelHeight);
|
||||
|
||||
Rect inner = panel.inset(theme.padding());
|
||||
header = inner.top(theme.headerHeight());
|
||||
closeButton = new Rect(header.right() - CLOSE_SIZE, header.y() + 2, CLOSE_SIZE, CLOSE_SIZE);
|
||||
|
||||
Rect tabRow = inner.dropTop(theme.headerHeight()).top(TAB_HEIGHT);
|
||||
tabBounds.clear();
|
||||
Tab[] all = Tab.values();
|
||||
int tabWidth = tabRow.width() / all.length;
|
||||
for (int i = 0; i < all.length; i++) {
|
||||
int x = tabRow.x() + i * tabWidth;
|
||||
int wide = i == all.length - 1 ? tabRow.right() - x : tabWidth;
|
||||
tabBounds.add(new Rect(x, tabRow.y(), wide, tabRow.height()));
|
||||
}
|
||||
|
||||
Rect below = inner.dropTop(theme.headerHeight() + TAB_HEIGHT + theme.gap());
|
||||
footer = below.bottom(theme.footerHeight());
|
||||
body = below.dropBottom(theme.footerHeight() + theme.gap());
|
||||
|
||||
widgets.clear();
|
||||
layoutBody(body);
|
||||
}
|
||||
|
||||
@Override
|
||||
public final void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
chrome.scrim(render);
|
||||
chrome.panel(render, panel);
|
||||
|
||||
chrome.fitted(render, title(), header.dropRight(CLOSE_SIZE + 4).withHeight(theme().headerHeight() - 6),
|
||||
theme().text());
|
||||
renderClose(render, mouseX, mouseY);
|
||||
renderTabs(render, mouseX, mouseY);
|
||||
|
||||
renderBody(render, mouseX, mouseY);
|
||||
widgets.render(render, mouseX, mouseY);
|
||||
|
||||
String status = footerText();
|
||||
if (!status.isEmpty()) {
|
||||
chrome.fitted(render, chrome.elide(render, status, footer.width()), footer, footerColour());
|
||||
}
|
||||
}
|
||||
|
||||
private void renderClose(RenderBridge render, int mouseX, int mouseY) {
|
||||
boolean hovered = closeButton.contains(mouseX, mouseY);
|
||||
int colour = hovered ? theme().danger() : theme().textMuted();
|
||||
// A cross from two diagonals; there is no line primitive and at eleven
|
||||
// pixels a glyph would sit off-centre in the box.
|
||||
for (int i = 2; i < CLOSE_SIZE - 2; i++) {
|
||||
render.fill(closeButton.x() + i, closeButton.y() + i, 1, 1, colour);
|
||||
render.fill(closeButton.x() + i, closeButton.bottom() - i - 1, 1, 1, colour);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderTabs(RenderBridge render, int mouseX, int mouseY) {
|
||||
Tab[] all = Tab.values();
|
||||
for (int i = 0; i < all.length; i++) {
|
||||
Rect bounds = tabBounds.get(i);
|
||||
boolean selected = all[i] == tab();
|
||||
boolean hovered = bounds.contains(mouseX, mouseY);
|
||||
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(),
|
||||
selected ? theme().surface() : theme().fade(theme().surface(), hovered ? 0.6f : 0.25f));
|
||||
if (selected) {
|
||||
render.fill(bounds.x(), bounds.bottom() - 1, bounds.width(), 1, theme().accent());
|
||||
}
|
||||
chrome.centered(render, chrome.translate(all[i].titleKey()), bounds,
|
||||
selected ? theme().text() : theme().textMuted());
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void tick() {
|
||||
widgets.tick();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button == 0 && closeButton.contains(mouseX, mouseY)) {
|
||||
ui.close();
|
||||
return true;
|
||||
}
|
||||
if (button == 0) {
|
||||
for (int i = 0; i < tabBounds.size(); i++) {
|
||||
if (tabBounds.get(i).contains(mouseX, mouseY)) {
|
||||
ui.open(Tab.values()[i]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return widgets.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return widgets.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return widgets.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
return widgets.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
return widgets.keyPressed(key, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean charTyped(char character, int modifiers) {
|
||||
return widgets.charTyped(character, modifiers);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
package dev.photosync.ui.screen;
|
||||
|
||||
import dev.photosync.core.upload.QueuedUpload;
|
||||
import dev.photosync.core.upload.UploadJob;
|
||||
import dev.photosync.core.upload.UploadQueue;
|
||||
import dev.photosync.core.upload.UploadState;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.mcapi.render.TextureHandle;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.widget.Button;
|
||||
import dev.photosync.ui.widget.ScrollModel;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.CompletionException;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* The screenshots this game knows about and what has happened to them.
|
||||
*
|
||||
* <p>This is the screen the mod is for. Everything else is setup; this is where
|
||||
* a player who has just pressed F2 looks to see that it went somewhere.
|
||||
*
|
||||
* <p>Rows are deliberately plain -- a name, a state, a bar. The preview lives in
|
||||
* a single pane on the right, decoded one at a time, because a screenshot is a
|
||||
* full-resolution PNG and a column of them would be tens of megabytes of texture
|
||||
* for a list the player scrolls past in two seconds.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class QueueScreen extends PhotoSyncScreen {
|
||||
|
||||
private static final int DETAIL_WIDTH = 140;
|
||||
private static final int MIN_WIDTH_FOR_DETAIL = 340;
|
||||
|
||||
private final UploadQueue queue;
|
||||
private final ScrollModel scroll;
|
||||
private final Preview preview = new Preview();
|
||||
|
||||
private Rect listArea = Rect.EMPTY;
|
||||
private Rect detailArea = Rect.EMPTY;
|
||||
|
||||
private String selectedId;
|
||||
private List<QueuedUpload> rows = List.of();
|
||||
|
||||
private Button retryAll;
|
||||
private Button clearFinished;
|
||||
private Button retryOne;
|
||||
private Button cancelOne;
|
||||
private Button revealOne;
|
||||
private Button forgetOne;
|
||||
|
||||
public QueueScreen(PhotoSyncUi ui) {
|
||||
super(ui);
|
||||
this.queue = ui.core().queue();
|
||||
this.scroll = new ScrollModel(ui.chrome());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Tab tab() {
|
||||
return Tab.QUEUE;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void layoutBody(Rect area) {
|
||||
int gap = theme().gap();
|
||||
Rect actions = area.bottom(theme().controlHeight());
|
||||
Rect content = area.dropBottom(theme().controlHeight() + gap);
|
||||
|
||||
boolean roomForDetail = content.width() >= MIN_WIDTH_FOR_DETAIL;
|
||||
detailArea = roomForDetail ? content.right(DETAIL_WIDTH) : Rect.EMPTY;
|
||||
listArea = roomForDetail ? content.dropRight(DETAIL_WIDTH + gap) : content;
|
||||
scroll.viewport(listArea);
|
||||
|
||||
retryAll = widgets.add(new Button(chrome, chrome.translate("photosync.queue.retry_all"),
|
||||
queue::retryAllFailed));
|
||||
retryAll.bounds(actions.left(actions.width() / 2 - gap / 2));
|
||||
|
||||
clearFinished = widgets.add(new Button(chrome, chrome.translate("photosync.queue.clear_finished"),
|
||||
queue::clearFinished));
|
||||
clearFinished.bounds(actions.right(actions.width() / 2 - gap / 2));
|
||||
|
||||
layoutDetailButtons();
|
||||
}
|
||||
|
||||
private void layoutDetailButtons() {
|
||||
if (detailArea.isEmpty()) {
|
||||
retryOne = null;
|
||||
cancelOne = null;
|
||||
revealOne = null;
|
||||
forgetOne = null;
|
||||
return;
|
||||
}
|
||||
int gap = theme().gap();
|
||||
int height = theme().controlHeight();
|
||||
int half = (detailArea.width() - 8 - gap) / 2;
|
||||
Rect column = detailArea.inset(4);
|
||||
Rect first = new Rect(column.x(), column.bottom() - height * 2 - gap, column.width(), height);
|
||||
Rect second = new Rect(column.x(), column.bottom() - height, column.width(), height);
|
||||
|
||||
retryOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.retry"),
|
||||
() -> withSelection(job -> queue.retry(job.id()))));
|
||||
retryOne.bounds(first.left(half));
|
||||
|
||||
cancelOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.cancel"),
|
||||
() -> withSelection(job -> queue.cancel(job.id()))));
|
||||
cancelOne.emphasized(Button.Emphasis.DANGER).bounds(first.right(half));
|
||||
|
||||
revealOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.reveal"),
|
||||
() -> withSelection(job -> ui.game().reveal(job.path()))));
|
||||
revealOne.bounds(second.left(half));
|
||||
|
||||
forgetOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.forget"),
|
||||
() -> withSelection(job -> {
|
||||
queue.forget(job.id());
|
||||
select(null);
|
||||
})));
|
||||
forgetOne.bounds(second.right(half));
|
||||
}
|
||||
|
||||
/** The detail buttons all act on whatever is selected, or on nothing at all. */
|
||||
private void withSelection(Consumer<UploadJob> action) {
|
||||
selected().map(QueuedUpload::job).ifPresent(action);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
|
||||
// Newest first, matching the browser -- the screenshot you just took is
|
||||
// the one you are looking for. (Not List::reversed: shared code targets
|
||||
// Java 17, because 1.20 through 1.20.4 run on a Java 17 JVM.)
|
||||
List<QueuedUpload> ordered = new ArrayList<>(queue.snapshot());
|
||||
Collections.reverse(ordered);
|
||||
rows = ordered;
|
||||
scroll.contentHeight(rows.size() * theme().rowHeight());
|
||||
scroll.advance(System.currentTimeMillis());
|
||||
updateButtons();
|
||||
|
||||
chrome.well(render, listArea);
|
||||
if (rows.isEmpty()) {
|
||||
chrome.notice(render, listArea, chrome.translate("photosync.queue.empty"),
|
||||
chrome.translate("photosync.queue.empty.hint"));
|
||||
} else {
|
||||
renderRows(render, mouseX, mouseY);
|
||||
}
|
||||
scroll.render(render, mouseX, mouseY);
|
||||
|
||||
if (!detailArea.isEmpty()) {
|
||||
renderDetail(render);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderRows(RenderBridge render, int mouseX, int mouseY) {
|
||||
int rowHeight = theme().rowHeight();
|
||||
int gutter = scroll.gutter();
|
||||
render.pushClip(listArea.x(), listArea.y(), listArea.width(), listArea.height());
|
||||
int first = Math.max(0, scroll.offset() / rowHeight);
|
||||
int last = Math.min(rows.size(), (scroll.offset() + listArea.height()) / rowHeight + 1);
|
||||
for (int i = first; i < last; i++) {
|
||||
Rect bounds = new Rect(listArea.x(), listArea.y() + i * rowHeight - scroll.offset(),
|
||||
listArea.width() - gutter, rowHeight);
|
||||
renderRow(render, rows.get(i), bounds, bounds.contains(mouseX, mouseY));
|
||||
}
|
||||
render.popClip();
|
||||
}
|
||||
|
||||
private void renderRow(RenderBridge render, QueuedUpload row, Rect bounds, boolean hovered) {
|
||||
UploadJob job = row.job();
|
||||
boolean selected = job.id().equals(selectedId);
|
||||
if (selected || hovered) {
|
||||
render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(),
|
||||
selected ? theme().surface() : theme().surfaceHover());
|
||||
}
|
||||
if (selected) {
|
||||
render.fill(bounds.x(), bounds.y(), 1, bounds.height(), theme().accent());
|
||||
}
|
||||
|
||||
Rect inner = bounds.inset(5, 3, 5, 3);
|
||||
String badge = chrome.translate(stateKey(job.state()));
|
||||
int badgeWidth = render.textWidth(badge) + 6;
|
||||
chrome.badge(render, badge, inner.right() - badgeWidth, inner.y(),
|
||||
theme().fade(stateColour(job.state()), 0.25f), stateColour(job.state()));
|
||||
|
||||
render.text(chrome.elide(render, job.fileName(), inner.width() - badgeWidth - 6),
|
||||
inner.x(), inner.y() + 2, theme().text(), false);
|
||||
|
||||
Rect secondLine = new Rect(inner.x(), inner.y() + render.lineHeight() + 3, inner.width(), 4);
|
||||
switch (job.state()) {
|
||||
case UPLOADING -> chrome.progressBar(render, secondLine, row.fraction(), theme().accent());
|
||||
case PENDING -> chrome.progressBar(render, secondLine, 0, theme().accent());
|
||||
case RETRYING -> chrome.busyBar(render, secondLine, System.currentTimeMillis(), theme().warning());
|
||||
default -> renderRowMessage(render, job, secondLine);
|
||||
}
|
||||
}
|
||||
|
||||
private void renderRowMessage(RenderBridge render, UploadJob job, Rect line) {
|
||||
String message = job.failureMessage().orElseGet(() -> formatSize(job.sizeBytes()));
|
||||
int colour = job.state() == UploadState.FAILED ? theme().danger() : theme().textFaint();
|
||||
render.text(chrome.elide(render, message, line.width()), line.x(), line.y() - 2, colour, false);
|
||||
}
|
||||
|
||||
private void renderDetail(RenderBridge render) {
|
||||
chrome.well(render, detailArea);
|
||||
Optional<QueuedUpload> selection = selected();
|
||||
if (selection.isEmpty()) {
|
||||
chrome.notice(render, detailArea, chrome.translate("photosync.queue.no_selection"), "");
|
||||
return;
|
||||
}
|
||||
UploadJob job = selection.get().job();
|
||||
preview.follow(job.path());
|
||||
|
||||
Rect inner = detailArea.inset(4);
|
||||
int line = render.lineHeight() + 2;
|
||||
// Square, but never so tall that it pushes the buttons off the panel.
|
||||
int side = Math.max(24, Math.min(inner.width(),
|
||||
inner.height() - theme().controlHeight() * 2 - theme().gap() - line * 5));
|
||||
Rect image = new Rect(inner.x(), inner.y(), inner.width(), side);
|
||||
render.fill(image.x(), image.y(), image.width(), image.height(), theme().tilePlaceholder());
|
||||
preview.texture().ifPresentOrElse(
|
||||
texture -> drawFitted(render, texture, image),
|
||||
() -> chrome.notice(render, image, chrome.translate(preview.failed()
|
||||
? "photosync.queue.preview_failed"
|
||||
: "photosync.queue.preview_loading"), ""));
|
||||
|
||||
int y = image.bottom() + 4;
|
||||
render.text(chrome.elide(render, job.fileName(), inner.width()), inner.x(), y, theme().text(), false);
|
||||
render.text(formatSize(job.sizeBytes()), inner.x(), y + line, theme().textFaint(), false);
|
||||
job.failureMessage().ifPresent(message -> {
|
||||
List<String> lines = chrome.wrap(render, message, inner.width(), 3);
|
||||
for (int i = 0; i < lines.size(); i++) {
|
||||
render.text(lines.get(i), inner.x(), y + line * (i + 2), theme().danger(), false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** Letterboxes rather than stretches: a squashed screenshot is worse than a border. */
|
||||
private void drawFitted(RenderBridge render, TextureHandle texture, Rect area) {
|
||||
double scale = Math.min(area.width() / (double) texture.width(), area.height() / (double) texture.height());
|
||||
int width = Math.max(1, (int) Math.round(texture.width() * scale));
|
||||
int height = Math.max(1, (int) Math.round(texture.height() * scale));
|
||||
render.image(texture, area.centerX() - width / 2, area.centerY() - height / 2, width, height);
|
||||
}
|
||||
|
||||
private void updateButtons() {
|
||||
Optional<UploadState> state = selected().map(row -> row.job().state());
|
||||
retryAll.enabled(queue.failedCount() > 0);
|
||||
clearFinished.enabled(rows.stream().anyMatch(row -> row.job().state().isFinished()));
|
||||
if (retryOne == null) {
|
||||
return;
|
||||
}
|
||||
retryOne.enabled(state.filter(value -> value == UploadState.FAILED).isPresent());
|
||||
cancelOne.enabled(state.filter(UploadState::isActive).isPresent());
|
||||
revealOne.enabled(state.isPresent());
|
||||
forgetOne.enabled(state.filter(value -> !value.isActive()).isPresent());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String footerText() {
|
||||
int active = queue.activeCount();
|
||||
int failed = queue.failedCount();
|
||||
if (active == 0 && failed == 0) {
|
||||
return chrome.translate("photosync.queue.idle");
|
||||
}
|
||||
return chrome.translate("photosync.queue.status", active, failed);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (scroll.mouseClicked(mouseX, mouseY, button)) {
|
||||
return true;
|
||||
}
|
||||
if (button == 0 && listArea.contains(mouseX, mouseY)) {
|
||||
int index = (int) ((mouseY - listArea.y() + scroll.offset()) / theme().rowHeight());
|
||||
select(index >= 0 && index < rows.size() ? rows.get(index).job().id() : null);
|
||||
return true;
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return scroll.mouseDragged(mouseX, mouseY, button)
|
||||
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
return scroll.mouseScrolled(mouseX, mouseY, amount, theme().rowHeight())
|
||||
|| super.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closed() {
|
||||
preview.close();
|
||||
}
|
||||
|
||||
private Optional<QueuedUpload> selected() {
|
||||
return rows.stream().filter(row -> row.job().id().equals(selectedId)).findFirst();
|
||||
}
|
||||
|
||||
private void select(String jobId) {
|
||||
if (!Objects.equals(selectedId, jobId)) {
|
||||
selectedId = jobId;
|
||||
preview.reset();
|
||||
}
|
||||
}
|
||||
|
||||
private String stateKey(UploadState state) {
|
||||
return "photosync.state." + state.name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
private int stateColour(UploadState state) {
|
||||
return switch (state) {
|
||||
case COMPLETED -> theme().success();
|
||||
case FAILED -> theme().danger();
|
||||
case RETRYING -> theme().warning();
|
||||
case CANCELLED -> theme().textFaint();
|
||||
case PENDING, UPLOADING -> theme().accent();
|
||||
};
|
||||
}
|
||||
|
||||
private String formatSize(long bytes) {
|
||||
if (bytes < 1024) {
|
||||
return bytes + " B";
|
||||
}
|
||||
if (bytes < 1024 * 1024) {
|
||||
return String.format(Locale.ROOT, "%.1f KB", bytes / 1024.0);
|
||||
}
|
||||
return String.format(Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The one decoded screenshot on screen.
|
||||
*
|
||||
* <p>The file is read on a worker and decoded on the render thread, because
|
||||
* only the render thread may make a texture and only a worker should touch a
|
||||
* disk. Exactly one texture is alive at a time.
|
||||
*/
|
||||
private final class Preview implements AutoCloseable {
|
||||
|
||||
private Path source;
|
||||
private CompletableFuture<byte[]> reading;
|
||||
private TextureHandle texture;
|
||||
private boolean failed;
|
||||
|
||||
/** Called every frame with the selected file; only acts when it changes. */
|
||||
private void follow(Path path) {
|
||||
if (!path.equals(source)) {
|
||||
close();
|
||||
source = path;
|
||||
reading = CompletableFuture.supplyAsync(() -> readAll(path));
|
||||
}
|
||||
poll();
|
||||
}
|
||||
|
||||
private void poll() {
|
||||
if (reading == null || !reading.isDone()) {
|
||||
return;
|
||||
}
|
||||
CompletableFuture<byte[]> finished = reading;
|
||||
reading = null;
|
||||
try {
|
||||
texture = ui.bridge().textures().decode(finished.join());
|
||||
} catch (IOException | RuntimeException e) {
|
||||
log.debug("Cannot preview {}", source, e);
|
||||
failed = true;
|
||||
}
|
||||
}
|
||||
|
||||
private Optional<TextureHandle> texture() {
|
||||
return Optional.ofNullable(texture);
|
||||
}
|
||||
|
||||
private boolean failed() {
|
||||
return failed;
|
||||
}
|
||||
|
||||
/** Forgets the current image so the next {@link #follow} reloads. */
|
||||
private void reset() {
|
||||
close();
|
||||
source = null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (reading != null) {
|
||||
reading.cancel(false);
|
||||
reading = null;
|
||||
}
|
||||
if (texture != null) {
|
||||
texture.close();
|
||||
texture = null;
|
||||
}
|
||||
failed = false;
|
||||
}
|
||||
|
||||
private byte[] readAll(Path path) {
|
||||
try {
|
||||
return Files.readAllBytes(path);
|
||||
} catch (IOException e) {
|
||||
throw new CompletionException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package dev.photosync.ui.screen;
|
||||
|
||||
import dev.photosync.core.upload.QueuedUpload;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.mcapi.screen.ScreenModel;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.Theme;
|
||||
import dev.photosync.ui.widget.Button;
|
||||
import dev.photosync.ui.widget.WidgetList;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The screen that stands between an in-flight upload and a closed game.
|
||||
*
|
||||
* <p>Quitting mid-upload is the one moment where the mod has to interrupt the
|
||||
* player, so it is worth being exact about what it offers. Waiting is the
|
||||
* default and needs no click: the dialog watches the queue and lets the game go
|
||||
* as soon as it empties. "Quit anyway" is always available and never destroys
|
||||
* anything -- the queue is on disk and resumes next launch -- so the button says
|
||||
* what happens next rather than warning about consequences that do not exist.
|
||||
*
|
||||
* <p>Escape does nothing here. It is the one modal in the mod: the game is
|
||||
* already on its way out, and a dialog that can be dismissed without answering
|
||||
* would leave the player in a session they have asked to end.
|
||||
*/
|
||||
public final class QuitDialog implements ScreenModel {
|
||||
|
||||
private static final int PANEL_WIDTH = 260;
|
||||
private static final int PANEL_HEIGHT = 116;
|
||||
|
||||
private final PhotoSyncUi ui;
|
||||
private final Chrome chrome;
|
||||
private final WidgetList widgets = new WidgetList();
|
||||
|
||||
/** How many were outstanding when the dialog opened, so progress has a denominator. */
|
||||
private final int initialCount;
|
||||
|
||||
private Rect panel = Rect.EMPTY;
|
||||
private Rect body = Rect.EMPTY;
|
||||
private boolean released;
|
||||
|
||||
public QuitDialog(PhotoSyncUi ui) {
|
||||
this.ui = ui;
|
||||
this.chrome = ui.chrome();
|
||||
this.initialCount = Math.max(1, ui.core().queue().activeCount());
|
||||
}
|
||||
|
||||
private Theme theme() {
|
||||
return chrome.theme();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String title() {
|
||||
return chrome.translate("photosync.quit.title");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void layout(int width, int height) {
|
||||
panel = new Rect((width - PANEL_WIDTH) / 2, (height - PANEL_HEIGHT) / 2, PANEL_WIDTH, PANEL_HEIGHT);
|
||||
body = panel.inset(theme().padding());
|
||||
|
||||
widgets.clear();
|
||||
Rect actions = body.bottom(theme().controlHeight());
|
||||
int half = (actions.width() - theme().gap()) / 2;
|
||||
|
||||
Button stay = widgets.add(new Button(chrome, chrome.translate("photosync.quit.keep_playing"), this::dismiss));
|
||||
stay.bounds(actions.left(half));
|
||||
|
||||
Button quit = widgets.add(new Button(chrome, chrome.translate("photosync.quit.anyway"), this::release));
|
||||
quit.emphasized(Button.Emphasis.DANGER).bounds(actions.right(half));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
chrome.scrim(render);
|
||||
chrome.panel(render, panel);
|
||||
|
||||
int line = render.lineHeight();
|
||||
int y = body.y();
|
||||
chrome.label(render, title(), body.x(), y, theme().text());
|
||||
|
||||
List<QueuedUpload> active = ui.core().queue().snapshot().stream()
|
||||
.filter(entry -> entry.job().state().isActive())
|
||||
.toList();
|
||||
y += line + 6;
|
||||
chrome.label(render, chrome.translate("photosync.quit.remaining", active.size()),
|
||||
body.x(), y, theme().textMuted());
|
||||
|
||||
y += line + 4;
|
||||
Rect bar = new Rect(body.x(), y, body.width(), 4);
|
||||
// Overall progress, measured in files rather than bytes: the count is the
|
||||
// number the player was just shown, and mixing the two units would make
|
||||
// the bar disagree with the line above it.
|
||||
double done = (initialCount - active.size()) / (double) initialCount;
|
||||
chrome.progressBar(render, bar, done, theme().accent());
|
||||
|
||||
y += 10;
|
||||
String current = active.isEmpty()
|
||||
? chrome.translate("photosync.quit.finishing")
|
||||
: chrome.elide(render, active.get(0).job().fileName(), body.width());
|
||||
chrome.label(render, current, body.x(), y, theme().textFaint());
|
||||
|
||||
y += line + 2;
|
||||
chrome.label(render, chrome.translate("photosync.quit.hint"), body.x(), y, theme().textFaint());
|
||||
|
||||
widgets.render(render, mouseX, mouseY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Polled rather than driven by an upload event, because the interesting
|
||||
* transition is "the queue became empty" and a tick is the cheapest place to
|
||||
* notice it that is already on the render thread.
|
||||
*/
|
||||
@Override
|
||||
public void tick() {
|
||||
widgets.tick();
|
||||
if (!ui.core().isBusy()) {
|
||||
release();
|
||||
}
|
||||
}
|
||||
|
||||
/** Lets the shutdown proceed, exactly once. */
|
||||
private void release() {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
ui.quitNow();
|
||||
}
|
||||
|
||||
private void dismiss() {
|
||||
ui.close();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
return widgets.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return widgets.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean closeOnEscape() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
package dev.photosync.ui.screen;
|
||||
|
||||
import dev.photosync.core.config.AutoCaptureSettings;
|
||||
import dev.photosync.core.config.BrowserSettings;
|
||||
import dev.photosync.core.config.NotificationSettings;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
import dev.photosync.core.config.UploadSettings;
|
||||
import dev.photosync.core.provider.PhotoProvider;
|
||||
import dev.photosync.core.provider.ProviderConnection;
|
||||
import dev.photosync.core.provider.ProviderDescriptor;
|
||||
import dev.photosync.core.provider.ProviderException;
|
||||
import dev.photosync.core.provider.ProviderId;
|
||||
import dev.photosync.core.provider.ProviderIdentity;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.widget.Button;
|
||||
import dev.photosync.ui.widget.ScrollModel;
|
||||
import dev.photosync.ui.widget.Slider;
|
||||
import dev.photosync.ui.widget.TextField;
|
||||
import dev.photosync.ui.widget.Toggle;
|
||||
import dev.photosync.ui.widget.Widget;
|
||||
import dev.photosync.ui.widget.WidgetList;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.UnaryOperator;
|
||||
|
||||
/**
|
||||
* Everything the player configures, in one scrolling column.
|
||||
*
|
||||
* <p>Edits go into a draft rather than straight into the live config, for two
|
||||
* reasons. Typing a server URL a character at a time would otherwise rebuild the
|
||||
* HTTP client on every keystroke; and "test connection" has to be able to try
|
||||
* credentials that have not been committed yet. The draft is applied when the
|
||||
* player leaves the screen, which is stated in the footer -- there is no way to
|
||||
* make an edit and quietly lose it.
|
||||
*
|
||||
* <p>The controls read and write the draft through lambdas instead of holding
|
||||
* their own copies, so the draft stays the single source of truth even when
|
||||
* something else -- reverting, or picking an album on another screen -- changes
|
||||
* it underneath them.
|
||||
*/
|
||||
public final class SettingsScreen extends PhotoSyncScreen {
|
||||
|
||||
/** One line of the form. A null widget makes it a section heading. */
|
||||
private static final class Row {
|
||||
|
||||
private final String labelKey;
|
||||
private final Widget widget;
|
||||
private final boolean fullWidth;
|
||||
private int top;
|
||||
private int height;
|
||||
|
||||
private Row(String labelKey, Widget widget, boolean fullWidth) {
|
||||
this.labelKey = labelKey;
|
||||
this.widget = widget;
|
||||
this.fullWidth = fullWidth;
|
||||
}
|
||||
|
||||
private boolean isHeading() {
|
||||
return widget == null;
|
||||
}
|
||||
}
|
||||
|
||||
private final ScrollModel scroll;
|
||||
private final WidgetList content = new WidgetList();
|
||||
private final List<Row> rows = new ArrayList<>();
|
||||
|
||||
private Rect viewport = Rect.EMPTY;
|
||||
private int measuredLineHeight = -1;
|
||||
|
||||
private Button save;
|
||||
private Button revert;
|
||||
private Button test;
|
||||
private volatile String testStatus = "";
|
||||
|
||||
public SettingsScreen(PhotoSyncUi ui) {
|
||||
super(ui);
|
||||
this.scroll = new ScrollModel(ui.chrome());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Tab tab() {
|
||||
return Tab.SETTINGS;
|
||||
}
|
||||
|
||||
private PhotoSyncConfig draft() {
|
||||
return ui.draft();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Building the form
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void layoutBody(Rect area) {
|
||||
int gap = theme().gap();
|
||||
Rect actions = area.bottom(theme().controlHeight());
|
||||
viewport = area.dropBottom(theme().controlHeight() + gap);
|
||||
scroll.viewport(viewport);
|
||||
|
||||
int half = (actions.width() - gap) / 2;
|
||||
save = widgets.add(new Button(chrome, chrome.translate("photosync.settings.save"), ui::applyDraft));
|
||||
save.emphasized(Button.Emphasis.PRIMARY).bounds(actions.left(half));
|
||||
revert = widgets.add(new Button(chrome, chrome.translate("photosync.settings.revert"), () -> {
|
||||
ui.resetDraft();
|
||||
rebuild();
|
||||
}));
|
||||
revert.bounds(actions.right(half));
|
||||
|
||||
rebuild();
|
||||
}
|
||||
|
||||
/** Rebuilds every control from scratch, which is also how "revert" takes effect. */
|
||||
private void rebuild() {
|
||||
content.clear();
|
||||
rows.clear();
|
||||
measuredLineHeight = -1;
|
||||
|
||||
buildConnection();
|
||||
buildUpload();
|
||||
buildAutoCapture();
|
||||
buildNotifications();
|
||||
buildBrowser();
|
||||
}
|
||||
|
||||
private void buildConnection() {
|
||||
heading("photosync.settings.section.connection");
|
||||
|
||||
List<ProviderDescriptor> providers = ui.core().catalog().descriptors();
|
||||
ProviderDescriptor descriptor = ui.core().catalog().describe(draft().provider()).orElse(providers.get(0));
|
||||
if (providers.size() > 1) {
|
||||
// Only worth a control when there is something to choose between.
|
||||
control("photosync.settings.provider", new Button(chrome, chrome.translate(descriptor.nameKey()),
|
||||
this::cycleProvider));
|
||||
}
|
||||
|
||||
TextField endpoint = new TextField(chrome, ui.bridge().clipboard(),
|
||||
value -> edit(config -> config.toBuilder()
|
||||
.connection(new ProviderConnection(value, config.connection().secret()))
|
||||
.build()));
|
||||
endpoint.reset(draft().connection().endpoint());
|
||||
endpoint.hint(chrome.translate(descriptor.endpointHintKey()));
|
||||
control(descriptor.endpointKey(), endpoint);
|
||||
|
||||
TextField secret = new TextField(chrome, ui.bridge().clipboard(),
|
||||
value -> edit(config -> config.toBuilder()
|
||||
.connection(new ProviderConnection(config.connection().endpoint(), value))
|
||||
.build()));
|
||||
secret.reset(draft().connection().secret());
|
||||
secret.hint(chrome.translate(descriptor.secretHintKey()));
|
||||
secret.masked(true);
|
||||
control(descriptor.secretKey(), secret);
|
||||
|
||||
test = new Button(chrome, chrome.translate("photosync.settings.test"), this::testConnection);
|
||||
control("photosync.settings.connection_state", test);
|
||||
|
||||
if (descriptor.supportsAlbums()) {
|
||||
control("photosync.settings.album",
|
||||
new Button(chrome, ui.albumLabel(), () -> ui.openAlbumPicker()));
|
||||
}
|
||||
}
|
||||
|
||||
private void buildUpload() {
|
||||
heading("photosync.settings.section.upload");
|
||||
toggle("photosync.settings.upload_on_capture", "photosync.settings.upload_on_capture.detail",
|
||||
() -> draft().upload().uploadOnCapture(),
|
||||
value -> upload(settings -> settings.toBuilder().uploadOnCapture(value).build()));
|
||||
control("photosync.settings.concurrency", new Slider(chrome,
|
||||
() -> draft().upload().concurrency(),
|
||||
value -> upload(settings -> settings.toBuilder().concurrency(value).build()),
|
||||
1, UploadSettings.MAX_CONCURRENCY, String::valueOf));
|
||||
control("photosync.settings.attempts", new Slider(chrome,
|
||||
() -> draft().upload().maxAttempts(),
|
||||
value -> upload(settings -> settings.toBuilder().maxAttempts(value).build()),
|
||||
1, 20, String::valueOf));
|
||||
control("photosync.settings.backoff", new Slider(chrome,
|
||||
() -> draft().upload().retryBackoffSeconds(),
|
||||
value -> upload(settings -> settings.toBuilder().retryBackoffSeconds(value).build()),
|
||||
1, 300, this::formatSeconds));
|
||||
toggle("photosync.settings.wait_on_quit", "photosync.settings.wait_on_quit.detail",
|
||||
() -> draft().upload().waitOnQuit(),
|
||||
value -> upload(settings -> settings.toBuilder().waitOnQuit(value).build()));
|
||||
toggle("photosync.settings.delete_local", "photosync.settings.delete_local.detail",
|
||||
() -> draft().upload().deleteLocalAfterUpload(),
|
||||
value -> upload(settings -> settings.toBuilder().deleteLocalAfterUpload(value).build()));
|
||||
}
|
||||
|
||||
private void buildAutoCapture() {
|
||||
heading("photosync.settings.section.auto_capture");
|
||||
toggle("photosync.settings.auto_capture", "photosync.settings.auto_capture.detail",
|
||||
() -> draft().autoCapture().enabled(),
|
||||
value -> autoCapture(settings -> settings.toBuilder().enabled(value).build()));
|
||||
control("photosync.settings.interval", new Slider(chrome,
|
||||
() -> draft().autoCapture().intervalSeconds(),
|
||||
value -> autoCapture(settings -> settings.toBuilder().intervalSeconds(value).build()),
|
||||
AutoCaptureSettings.MIN_INTERVAL_SECONDS, AutoCaptureSettings.MAX_INTERVAL_SECONDS,
|
||||
this::formatSeconds));
|
||||
|
||||
TextField suffix = new TextField(chrome, ui.bridge().clipboard(),
|
||||
value -> autoCapture(settings -> settings.toBuilder().fileNameSuffix(value).build()));
|
||||
suffix.reset(draft().autoCapture().fileNameSuffix());
|
||||
suffix.hint("_auto");
|
||||
suffix.maxLength(24);
|
||||
control("photosync.settings.suffix", suffix);
|
||||
|
||||
toggle("photosync.settings.only_in_world", "",
|
||||
() -> draft().autoCapture().onlyInWorld(),
|
||||
value -> autoCapture(settings -> settings.toBuilder().onlyInWorld(value).build()));
|
||||
toggle("photosync.settings.skip_when_screen_open", "",
|
||||
() -> draft().autoCapture().skipWhenScreenOpen(),
|
||||
value -> autoCapture(settings -> settings.toBuilder().skipWhenScreenOpen(value).build()));
|
||||
}
|
||||
|
||||
private void buildNotifications() {
|
||||
heading("photosync.settings.section.notifications");
|
||||
toggle("photosync.settings.notify", "photosync.settings.notify.detail",
|
||||
() -> draft().notifications().enabled(),
|
||||
value -> notifications(settings -> settings.toBuilder().enabled(value).build()));
|
||||
toggle("photosync.settings.notify_capture", "",
|
||||
() -> draft().notifications().onCapture(),
|
||||
value -> notifications(settings -> settings.toBuilder().onCapture(value).build()));
|
||||
toggle("photosync.settings.notify_uploaded", "",
|
||||
() -> draft().notifications().onUploaded(),
|
||||
value -> notifications(settings -> settings.toBuilder().onUploaded(value).build()));
|
||||
toggle("photosync.settings.notify_failed", "",
|
||||
() -> draft().notifications().onFailed(),
|
||||
value -> notifications(settings -> settings.toBuilder().onFailed(value).build()));
|
||||
control("photosync.settings.linger", new Slider(chrome,
|
||||
() -> draft().notifications().lingerMillis(),
|
||||
value -> notifications(settings -> settings.toBuilder().lingerMillis(value).build()),
|
||||
500, 15_000, millis -> String.format(Locale.ROOT, "%.1fs", millis / 1000.0)));
|
||||
}
|
||||
|
||||
private void buildBrowser() {
|
||||
heading("photosync.settings.section.browser");
|
||||
control("photosync.settings.tile_size", new Slider(chrome,
|
||||
() -> draft().browser().tileSize(),
|
||||
value -> browser(settings -> settings.toBuilder().tileSize(value).build()),
|
||||
48, 192, value -> value + " px"));
|
||||
control("photosync.settings.cache", new Slider(chrome,
|
||||
() -> draft().browser().thumbnailCacheEntries(),
|
||||
value -> browser(settings -> settings.toBuilder().thumbnailCacheEntries(value).build()),
|
||||
32, 2048, String::valueOf));
|
||||
toggle("photosync.settings.video_badge", "",
|
||||
() -> draft().browser().showVideoBadge(),
|
||||
value -> browser(settings -> settings.toBuilder().showVideoBadge(value).build()));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Row helpers
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void heading(String key) {
|
||||
rows.add(new Row(key, null, true));
|
||||
}
|
||||
|
||||
private void control(String labelKey, Widget widget) {
|
||||
rows.add(new Row(labelKey, content.add(widget), false));
|
||||
}
|
||||
|
||||
private void toggle(String labelKey, String detailKey, BooleanSupplier reader, Consumer<Boolean> writer) {
|
||||
Toggle widget = new Toggle(chrome, chrome.translate(labelKey), reader, writer);
|
||||
if (!detailKey.isEmpty()) {
|
||||
widget.describedAs(chrome.translate(detailKey));
|
||||
}
|
||||
rows.add(new Row(labelKey, content.add(widget), true));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Draft edits
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void edit(UnaryOperator<PhotoSyncConfig> change) {
|
||||
ui.draft(change.apply(draft()));
|
||||
}
|
||||
|
||||
private void upload(UnaryOperator<UploadSettings> change) {
|
||||
edit(config -> config.toBuilder().upload(change.apply(config.upload())).build());
|
||||
}
|
||||
|
||||
private void autoCapture(UnaryOperator<AutoCaptureSettings> change) {
|
||||
edit(config -> config.toBuilder().autoCapture(change.apply(config.autoCapture())).build());
|
||||
}
|
||||
|
||||
private void notifications(UnaryOperator<NotificationSettings> change) {
|
||||
edit(config -> config.toBuilder().notifications(change.apply(config.notifications())).build());
|
||||
}
|
||||
|
||||
private void browser(UnaryOperator<BrowserSettings> change) {
|
||||
edit(config -> config.toBuilder().browser(change.apply(config.browser())).build());
|
||||
}
|
||||
|
||||
private void cycleProvider() {
|
||||
List<ProviderDescriptor> providers = ui.core().catalog().descriptors();
|
||||
ProviderId current = draft().provider();
|
||||
int index = 0;
|
||||
for (int i = 0; i < providers.size(); i++) {
|
||||
if (providers.get(i).id().equals(current)) {
|
||||
index = i;
|
||||
}
|
||||
}
|
||||
ProviderId next = providers.get((index + 1) % providers.size()).id();
|
||||
edit(config -> config.toBuilder().provider(next).build());
|
||||
// The next provider labels its credentials differently, so the whole
|
||||
// connection section has to be built again.
|
||||
rebuild();
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries the draft's credentials without committing them, on a worker so a
|
||||
* server that is down does not freeze the screen.
|
||||
*/
|
||||
private void testConnection() {
|
||||
ProviderId id = draft().provider();
|
||||
ProviderConnection connection = draft().connection();
|
||||
if (!connection.isConfigured()) {
|
||||
testStatus = chrome.translate("photosync.settings.test.incomplete");
|
||||
return;
|
||||
}
|
||||
testStatus = chrome.translate("photosync.settings.test.running");
|
||||
test.enabled(false);
|
||||
CompletableFuture.runAsync(() -> {
|
||||
try (PhotoProvider probe = ui.core().session().probe(id, connection)) {
|
||||
ProviderIdentity identity = probe.identify();
|
||||
testStatus = chrome.translate("photosync.settings.test.ok",
|
||||
identity.accountName(), identity.serverVersion());
|
||||
} catch (ProviderException e) {
|
||||
testStatus = chrome.translate("photosync.settings.test.failed", e.getMessage());
|
||||
} catch (RuntimeException e) {
|
||||
testStatus = chrome.translate("photosync.settings.test.failed", e.toString());
|
||||
}
|
||||
}).whenComplete((ignored, failure) -> ui.game().submit(() -> test.enabled(true)));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
|
||||
measure(render);
|
||||
scroll.advance(System.currentTimeMillis());
|
||||
save.enabled(ui.draftIsDirty());
|
||||
revert.enabled(ui.draftIsDirty());
|
||||
|
||||
chrome.well(render, viewport);
|
||||
int labelWidth = Math.min(130, viewport.width() * 45 / 100);
|
||||
int usable = viewport.width() - scroll.gutter();
|
||||
|
||||
render.pushClip(viewport.x(), viewport.y(), viewport.width(), viewport.height());
|
||||
for (Row row : rows) {
|
||||
int y = viewport.y() + row.top - scroll.offset();
|
||||
boolean onScreen = y + row.height >= viewport.y() && y <= viewport.bottom();
|
||||
if (row.isHeading()) {
|
||||
if (onScreen) {
|
||||
renderHeading(render, row, y, usable);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
row.widget.visible(onScreen);
|
||||
if (!onScreen) {
|
||||
continue;
|
||||
}
|
||||
if (row.fullWidth) {
|
||||
row.widget.bounds(new Rect(viewport.x() + 6, y + 2, usable - 12, row.height - 4));
|
||||
} else {
|
||||
chrome.fitted(render, chrome.translate(row.labelKey),
|
||||
new Rect(viewport.x() + 6, y, labelWidth - 8, row.height), theme().textMuted());
|
||||
row.widget.bounds(new Rect(viewport.x() + labelWidth, y + 3,
|
||||
usable - labelWidth - 6, theme().controlHeight()));
|
||||
}
|
||||
}
|
||||
content.render(render, mouseX, mouseY);
|
||||
render.popClip();
|
||||
|
||||
scroll.render(render, mouseX, mouseY);
|
||||
}
|
||||
|
||||
private void renderHeading(RenderBridge render, Row row, int y, int usable) {
|
||||
int baseline = y + row.height - render.lineHeight() - 3;
|
||||
chrome.label(render, chrome.translate(row.labelKey), viewport.x() + 4, baseline, theme().accent());
|
||||
chrome.divider(render, viewport.x() + 4, y + row.height - 2, usable - 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heights depend on the font, which is only knowable at render time -- a
|
||||
* toggle with a description is two lines tall and one without is one.
|
||||
*/
|
||||
private void measure(RenderBridge render) {
|
||||
if (measuredLineHeight == render.lineHeight()) {
|
||||
return;
|
||||
}
|
||||
measuredLineHeight = render.lineHeight();
|
||||
int y = 0;
|
||||
for (Row row : rows) {
|
||||
row.height = heightOf(row, render);
|
||||
row.top = y;
|
||||
y += row.height;
|
||||
}
|
||||
scroll.contentHeight(y);
|
||||
}
|
||||
|
||||
private int heightOf(Row row, RenderBridge render) {
|
||||
if (row.isHeading()) {
|
||||
return render.lineHeight() + 12;
|
||||
}
|
||||
if (row.widget instanceof Toggle toggle) {
|
||||
return toggle.preferredHeight(render) + 4;
|
||||
}
|
||||
return theme().controlHeight() + 6;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String footerText() {
|
||||
if (!testStatus.isEmpty()) {
|
||||
return testStatus;
|
||||
}
|
||||
return chrome.translate(ui.draftIsDirty()
|
||||
? "photosync.settings.dirty"
|
||||
: "photosync.settings.clean");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected int footerColour() {
|
||||
return ui.draftIsDirty() && testStatus.isEmpty() ? theme().warning() : theme().textFaint();
|
||||
}
|
||||
|
||||
private String formatSeconds(int seconds) {
|
||||
if (seconds < 60) {
|
||||
return seconds + "s";
|
||||
}
|
||||
return seconds % 60 == 0
|
||||
? seconds / 60 + "m"
|
||||
: String.format(Locale.ROOT, "%dm %ds", seconds / 60, seconds % 60);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (scroll.mouseClicked(mouseX, mouseY, button)) {
|
||||
return true;
|
||||
}
|
||||
if (viewport.contains(mouseX, mouseY)) {
|
||||
return content.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
content.focus(null);
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
boolean handled = scroll.mouseReleased();
|
||||
handled |= content.mouseReleased(mouseX, mouseY, button);
|
||||
return handled || super.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
if (scroll.mouseDragged(mouseX, mouseY, button)
|
||||
|| content.mouseDragged(mouseX, mouseY, button, deltaX, deltaY)) {
|
||||
return true;
|
||||
}
|
||||
return super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
// The control under the cursor gets first refusal, so a slider can be
|
||||
// nudged by the wheel without the page moving underneath it.
|
||||
if (viewport.contains(mouseX, mouseY) && content.mouseScrolled(mouseX, mouseY, amount)) {
|
||||
return true;
|
||||
}
|
||||
return scroll.mouseScrolled(mouseX, mouseY, amount, theme().rowHeight())
|
||||
|| super.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
return content.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean charTyped(char character, int modifiers) {
|
||||
return content.charTyped(character, modifiers) || super.charTyped(character, modifiers);
|
||||
}
|
||||
|
||||
/** Escape leaves the screen; the draft is applied on the way out. */
|
||||
@Override
|
||||
public boolean closeOnEscape() {
|
||||
return !content.hasFocus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closed() {
|
||||
ui.applyDraft();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,461 @@
|
||||
package dev.photosync.ui.screen;
|
||||
|
||||
import dev.photosync.core.config.BrowserSettings;
|
||||
import dev.photosync.core.provider.RemoteAsset;
|
||||
import dev.photosync.core.provider.ThumbnailSize;
|
||||
import dev.photosync.core.timeline.TimelineBrowser;
|
||||
import dev.photosync.core.timeline.TimelineSection;
|
||||
import dev.photosync.core.timeline.TimelineState;
|
||||
import dev.photosync.mcapi.Keys;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.mcapi.render.TextureHandle;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.image.ThumbnailCache;
|
||||
import dev.photosync.ui.widget.Button;
|
||||
import dev.photosync.ui.widget.ScrollModel;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.FormatStyle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* The album, by day, from now back to whenever the player started.
|
||||
*
|
||||
* <p>The trick that makes this usable over a library of tens of thousands of
|
||||
* photos is that the backend can say how many assets each month holds without
|
||||
* sending any of them. That count gives every section its exact height before a
|
||||
* single byte of image data is fetched, so the scrollbar is honest from the
|
||||
* first frame and the browser only ever asks for the months the viewport
|
||||
* actually reaches -- the same bargain a virtual list makes on the web, made
|
||||
* against a server that was designed for it.
|
||||
*
|
||||
* <p>A month arrives already split into days by {@link TimelineBrowser}, and
|
||||
* replaces its own placeholder section in place. Scroll position is measured in
|
||||
* pixels from the top of the content, and the swap does not move it, so the
|
||||
* grid fills in around the player rather than jumping under them.
|
||||
*/
|
||||
public final class TimelineScreen extends PhotoSyncScreen {
|
||||
|
||||
/** One section's slot in the scrollable column, measured once per layout. */
|
||||
private record Block(TimelineSection section, int top, int height, int columns, int tileSize) {
|
||||
|
||||
private int gridTop(int headerHeight) {
|
||||
return top + headerHeight;
|
||||
}
|
||||
}
|
||||
|
||||
private final TimelineBrowser browser;
|
||||
private final ScrollModel scroll;
|
||||
private final ThumbnailCache tiles;
|
||||
private final ThumbnailCache detail;
|
||||
|
||||
private final DateTimeFormatter dayFormat =
|
||||
DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
|
||||
private final DateTimeFormatter monthFormat =
|
||||
DateTimeFormatter.ofPattern("LLLL yyyy", Locale.getDefault());
|
||||
|
||||
private Rect grid = Rect.EMPTY;
|
||||
private List<Block> blocks = List.of();
|
||||
private int measuredRevision = -1;
|
||||
private int measuredWidth = -1;
|
||||
private int measuredTileSize = -1;
|
||||
private int headerHeight = 14;
|
||||
|
||||
private RemoteAsset opened;
|
||||
private Button albumToggle;
|
||||
|
||||
public TimelineScreen(PhotoSyncUi ui) {
|
||||
super(ui);
|
||||
this.browser = ui.core().browser();
|
||||
this.scroll = new ScrollModel(ui.chrome());
|
||||
BrowserSettings settings = ui.core().config().current().browser();
|
||||
this.tiles = new ThumbnailCache(ui.bridge().textures(), ui.core().thumbnails(),
|
||||
ThumbnailSize.GRID, settings.thumbnailCacheEntries());
|
||||
// Three is enough for the one open photo and the two either side of it.
|
||||
this.detail = new ThumbnailCache(ui.bridge().textures(), ui.core().thumbnails(), ThumbnailSize.DETAIL, 3);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Tab tab() {
|
||||
return Tab.BROWSE;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Layout
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void layoutBody(Rect area) {
|
||||
// Opens the album on first sight of this screen, and is a no-op on every
|
||||
// resize and tab switch after that.
|
||||
ui.ensureBrowsing();
|
||||
int gap = theme().gap();
|
||||
Rect actions = area.bottom(theme().controlHeight());
|
||||
grid = area.dropBottom(theme().controlHeight() + gap);
|
||||
scroll.viewport(grid);
|
||||
// A resize invalidates the column count, so re-measure on the next frame.
|
||||
measuredWidth = -1;
|
||||
|
||||
int half = (actions.width() - gap) / 2;
|
||||
albumToggle = widgets.add(new Button(chrome, albumLabel(), () -> {
|
||||
ui.browsingLibrary(!ui.browsingLibrary());
|
||||
albumToggle.label(albumLabel());
|
||||
}));
|
||||
albumToggle.bounds(actions.left(half));
|
||||
|
||||
Button refresh = widgets.add(new Button(chrome, chrome.translate("photosync.browse.refresh"),
|
||||
() -> {
|
||||
tiles.clear();
|
||||
browser.reload();
|
||||
}));
|
||||
refresh.bounds(actions.right(half));
|
||||
}
|
||||
|
||||
private String albumLabel() {
|
||||
return chrome.translate(ui.browsingLibrary()
|
||||
? "photosync.browse.showing_library"
|
||||
: "photosync.browse.showing_album");
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the column when something that changes its shape has changed:
|
||||
* new sections, a resize, or a new tile size.
|
||||
*/
|
||||
private void measure(RenderBridge render) {
|
||||
BrowserSettings settings = ui.core().config().current().browser();
|
||||
int tileSize = settings.tileSize();
|
||||
int revision = browser.revision();
|
||||
int usable = grid.width() - scroll.gutter();
|
||||
if (revision == measuredRevision && usable == measuredWidth && tileSize == measuredTileSize) {
|
||||
return;
|
||||
}
|
||||
measuredRevision = revision;
|
||||
measuredWidth = usable;
|
||||
measuredTileSize = tileSize;
|
||||
tiles.capacity(settings.thumbnailCacheEntries());
|
||||
headerHeight = render.lineHeight() + 6;
|
||||
|
||||
int tileGap = theme().tileGap();
|
||||
int columns = Math.max(1, (usable + tileGap) / (tileSize + tileGap));
|
||||
List<Block> built = new ArrayList<>();
|
||||
int top = 0;
|
||||
for (TimelineSection section : browser.sections()) {
|
||||
int rows = Math.max(1, (section.assetCount() + columns - 1) / columns);
|
||||
int height = headerHeight + rows * (tileSize + tileGap);
|
||||
built.add(new Block(section, top, height, columns, tileSize));
|
||||
top += height;
|
||||
}
|
||||
blocks = List.copyOf(built);
|
||||
scroll.contentHeight(top);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
|
||||
measure(render);
|
||||
scroll.advance(System.currentTimeMillis());
|
||||
chrome.well(render, grid);
|
||||
|
||||
if (renderState(render)) {
|
||||
renderGrid(render, mouseX, mouseY);
|
||||
scroll.render(render, mouseX, mouseY);
|
||||
}
|
||||
if (opened != null) {
|
||||
renderOpened(render, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws whatever stands in for the grid, and says whether the grid itself should be drawn. */
|
||||
private boolean renderState(RenderBridge render) {
|
||||
switch (browser.state()) {
|
||||
case NOT_CONFIGURED -> {
|
||||
chrome.notice(render, grid, chrome.translate("photosync.browse.not_configured"),
|
||||
chrome.translate("photosync.browse.not_configured.hint"));
|
||||
return false;
|
||||
}
|
||||
case LOADING -> {
|
||||
chrome.notice(render, grid.dropBottom(render.lineHeight() + 8),
|
||||
chrome.translate("photosync.browse.loading"), "");
|
||||
chrome.busyBar(render, new Rect(grid.centerX() - 60, grid.centerY() + 8, 120, 3),
|
||||
System.currentTimeMillis(), theme().accent());
|
||||
return false;
|
||||
}
|
||||
case FAILED -> {
|
||||
chrome.notice(render, grid, chrome.translate("photosync.browse.failed"),
|
||||
browser.error().orElse(""));
|
||||
return false;
|
||||
}
|
||||
case EMPTY -> {
|
||||
chrome.notice(render, grid, chrome.translate("photosync.browse.empty"), "");
|
||||
return false;
|
||||
}
|
||||
case READY -> {
|
||||
return !blocks.isEmpty();
|
||||
}
|
||||
default -> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
render.pushClip(grid.x(), grid.y(), grid.width(), grid.height());
|
||||
for (int i = firstVisible(prefetchTop); i < blocks.size(); i++) {
|
||||
Block block = blocks.get(i);
|
||||
if (block.top() > prefetchBottom) {
|
||||
break;
|
||||
}
|
||||
if (block.section() instanceof TimelineSection.PendingMonth pending) {
|
||||
browser.request(pending.bucket());
|
||||
}
|
||||
boolean onScreen = block.top() + block.height() >= offset && block.top() <= offset + grid.height();
|
||||
if (onScreen) {
|
||||
renderBlock(render, block, offset, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
render.popClip();
|
||||
tiles.endFrame();
|
||||
}
|
||||
|
||||
private void renderBlock(RenderBridge render, Block block, int offset, int mouseX, int mouseY) {
|
||||
int y = grid.y() + block.top() - offset;
|
||||
TimelineSection section = block.section();
|
||||
TimelineSection.PendingMonth pending =
|
||||
section instanceof TimelineSection.PendingMonth month ? month : null;
|
||||
|
||||
render.fill(grid.x(), y, grid.width() - scroll.gutter(), headerHeight, theme().fade(theme().surface(), 0.5f));
|
||||
chrome.label(render, headerLabel(section, pending != null), grid.x() + 4, y + 3, theme().textMuted());
|
||||
String count = String.valueOf(section.assetCount());
|
||||
render.text(count, grid.x() + grid.width() - scroll.gutter() - render.textWidth(count) - 4, y + 3,
|
||||
theme().textFaint(), false);
|
||||
|
||||
int tileGap = theme().tileGap();
|
||||
int tileSize = block.tileSize();
|
||||
List<RemoteAsset> assets = section.assets();
|
||||
for (int index = 0; index < section.assetCount(); index++) {
|
||||
int column = index % block.columns();
|
||||
int row = index / block.columns();
|
||||
Rect tile = new Rect(
|
||||
grid.x() + column * (tileSize + tileGap),
|
||||
y + headerHeight + row * (tileSize + tileGap),
|
||||
tileSize, tileSize);
|
||||
if (tile.bottom() < grid.y() || tile.y() > grid.bottom()) {
|
||||
continue;
|
||||
}
|
||||
if (index < assets.size()) {
|
||||
renderTile(render, assets.get(index), tile, tile.contains(mouseX, mouseY));
|
||||
} else {
|
||||
// A month whose page has not arrived: the count is exact, so the
|
||||
// space is already correct and only the picture is missing.
|
||||
render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
|
||||
}
|
||||
}
|
||||
if (pending != null && browser.pageError(pending.bucket()).isPresent()) {
|
||||
chrome.label(render, chrome.translate("photosync.browse.page_failed"),
|
||||
grid.x() + 4, y + headerHeight + 2, theme().danger());
|
||||
}
|
||||
}
|
||||
|
||||
private void renderTile(RenderBridge render, RemoteAsset asset, Rect tile, boolean hovered) {
|
||||
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())) {
|
||||
chrome.centered(render, "!", tile, theme().danger());
|
||||
}
|
||||
if (asset.isVideo() && ui.core().config().current().browser().showVideoBadge()) {
|
||||
chrome.videoMarker(render, tile, formatDuration(asset.duration()));
|
||||
}
|
||||
if (hovered) {
|
||||
render.border(tile.x(), tile.y(), tile.width(), tile.height(), theme().accent());
|
||||
}
|
||||
}
|
||||
|
||||
/** Centre-crops to a square. Squashing a landscape shot into a tile looks broken. */
|
||||
private void drawCropped(RenderBridge render, TextureHandle texture, Rect tile) {
|
||||
float aspect = texture.width() / (float) Math.max(1, texture.height());
|
||||
float half = 0.5f;
|
||||
float uHalf = aspect > 1 ? half / aspect : half;
|
||||
float vHalf = aspect > 1 ? half : half * aspect;
|
||||
render.image(texture, tile.x(), tile.y(), tile.width(), tile.height(),
|
||||
half - uHalf, half - vHalf, half + uHalf, half + vHalf);
|
||||
}
|
||||
|
||||
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);
|
||||
if (image.isPresent()) {
|
||||
drawContained(render, image.get().texture(), frame);
|
||||
} else {
|
||||
chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
|
||||
}
|
||||
chrome.centered(render, chrome.translate("photosync.browse.close_hint"),
|
||||
area.bottom(render.lineHeight() + 2), theme().textFaint());
|
||||
detail.endFrame();
|
||||
}
|
||||
|
||||
/** Fits the whole image inside {@code area}, preserving its shape. */
|
||||
private void drawContained(RenderBridge render, TextureHandle texture, Rect area) {
|
||||
double scale = Math.min(area.width() / (double) texture.width(), area.height() / (double) texture.height());
|
||||
int width = Math.max(1, (int) Math.round(texture.width() * scale));
|
||||
int height = Math.max(1, (int) Math.round(texture.height() * scale));
|
||||
render.image(texture, area.centerX() - width / 2, area.centerY() - height / 2, width, height);
|
||||
}
|
||||
|
||||
private String headerLabel(TimelineSection section, boolean pending) {
|
||||
LocalDate date = section.date();
|
||||
return pending ? monthFormat.format(date) : dayFormat.format(date);
|
||||
}
|
||||
|
||||
private String formatDuration(Duration duration) {
|
||||
if (duration.isZero() || duration.isNegative()) {
|
||||
return "";
|
||||
}
|
||||
long total = duration.getSeconds();
|
||||
return String.format(Locale.ROOT, "%d:%02d", total / 60, total % 60);
|
||||
}
|
||||
|
||||
/** Binary search for the first block whose bottom edge is at or past {@code y}. */
|
||||
private int firstVisible(int y) {
|
||||
int low = 0;
|
||||
int high = blocks.size() - 1;
|
||||
int found = blocks.size();
|
||||
while (low <= high) {
|
||||
int mid = (low + high) >>> 1;
|
||||
Block block = blocks.get(mid);
|
||||
if (block.top() + block.height() >= y) {
|
||||
found = mid;
|
||||
high = mid - 1;
|
||||
} else {
|
||||
low = mid + 1;
|
||||
}
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String footerText() {
|
||||
if (browser.state() != TimelineState.READY) {
|
||||
return "";
|
||||
}
|
||||
return chrome.translate("photosync.browse.status", browser.assetCount(), blocks.size());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (opened != null) {
|
||||
opened = null;
|
||||
return true;
|
||||
}
|
||||
if (scroll.mouseClicked(mouseX, mouseY, button)) {
|
||||
return true;
|
||||
}
|
||||
if (button == 0 && grid.contains(mouseX, mouseY)) {
|
||||
assetAt(mouseX, mouseY).ifPresent(asset -> opened = asset);
|
||||
return true;
|
||||
}
|
||||
return super.mouseClicked(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return scroll.mouseDragged(mouseX, mouseY, button)
|
||||
|| super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
int step = measuredTileSize > 0 ? measuredTileSize + theme().tileGap() : theme().rowHeight();
|
||||
return scroll.mouseScrolled(mouseX, mouseY, amount, step)
|
||||
|| super.mouseScrolled(mouseX, mouseY, amount);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
if (opened != null && key == Keys.ESCAPE) {
|
||||
opened = null;
|
||||
return true;
|
||||
}
|
||||
return switch (key) {
|
||||
case Keys.PAGE_UP -> {
|
||||
scroll.scrollBy(-grid.height());
|
||||
yield true;
|
||||
}
|
||||
case Keys.PAGE_DOWN -> {
|
||||
scroll.scrollBy(grid.height());
|
||||
yield true;
|
||||
}
|
||||
case Keys.HOME -> {
|
||||
scroll.scrollTo(0);
|
||||
yield true;
|
||||
}
|
||||
case Keys.END -> {
|
||||
scroll.scrollTo(scroll.maxOffset());
|
||||
yield true;
|
||||
}
|
||||
default -> super.keyPressed(key, scanCode, modifiers);
|
||||
};
|
||||
}
|
||||
|
||||
/** Escape dismisses the open photo before it dismisses the screen. */
|
||||
@Override
|
||||
public boolean closeOnEscape() {
|
||||
return opened == null;
|
||||
}
|
||||
|
||||
private Optional<RemoteAsset> assetAt(double mouseX, double mouseY) {
|
||||
int y = (int) (mouseY - grid.y()) + scroll.offset();
|
||||
int index = firstVisible(y);
|
||||
if (index >= blocks.size()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
Block block = blocks.get(index);
|
||||
int withinGrid = y - block.gridTop(headerHeight);
|
||||
if (withinGrid < 0) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int stride = block.tileSize() + theme().tileGap();
|
||||
int column = (int) (mouseX - grid.x()) / stride;
|
||||
int row = withinGrid / stride;
|
||||
if (column < 0 || column >= block.columns()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
int position = row * block.columns() + column;
|
||||
List<RemoteAsset> assets = block.section().assets();
|
||||
return position >= 0 && position < assets.size() ? Optional.of(assets.get(position)) : Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void closed() {
|
||||
tiles.close();
|
||||
detail.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* A labelled rectangle that runs something when clicked.
|
||||
*
|
||||
* <p>The label is mutable because several of them change in place -- "Test
|
||||
* connection" becomes "Testing..." and then "Connected" -- and swapping the
|
||||
* widget out would lose the layout it was given.
|
||||
*/
|
||||
@Accessors(fluent = true)
|
||||
public final class Button extends Widget {
|
||||
|
||||
/** How much a button asks to stand out. */
|
||||
public enum Emphasis {
|
||||
/** The obvious next action. One per screen at most. */
|
||||
PRIMARY,
|
||||
/** Everything else. */
|
||||
NORMAL,
|
||||
/** Destructive: cancel an upload, quit with work outstanding. */
|
||||
DANGER
|
||||
}
|
||||
|
||||
@Setter
|
||||
private String label;
|
||||
|
||||
@Setter
|
||||
private Emphasis emphasis = Emphasis.NORMAL;
|
||||
|
||||
private final Runnable action;
|
||||
private boolean pressed;
|
||||
|
||||
public Button(Chrome chrome, String label, Runnable action) {
|
||||
super(chrome);
|
||||
this.label = label;
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public Button emphasized(Emphasis value) {
|
||||
this.emphasis = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
boolean hovered = hovering(mouseX, mouseY);
|
||||
int background = background(hovered);
|
||||
render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), background);
|
||||
render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(), borderColour(hovered));
|
||||
chrome.centered(render, chrome.elide(render, label, bounds().width() - 6), bounds(), foreground());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button != 0 || !hovering(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
pressed = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
if (!pressed) {
|
||||
return false;
|
||||
}
|
||||
pressed = false;
|
||||
// Only fire if the cursor is still on the button, so a click can be
|
||||
// taken back by dragging off it -- the convention everywhere else.
|
||||
if (hovering(mouseX, mouseY)) {
|
||||
action.run();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private int background(boolean hovered) {
|
||||
if (!enabled()) {
|
||||
return theme().fade(theme().surface(), 0.6f);
|
||||
}
|
||||
return switch (emphasis) {
|
||||
case PRIMARY -> pressed || hovered ? theme().accentHover() : theme().accent();
|
||||
case DANGER -> pressed ? theme().danger() : theme().surfaceFor(hovered, false);
|
||||
case NORMAL -> theme().surfaceFor(hovered, pressed);
|
||||
};
|
||||
}
|
||||
|
||||
private int borderColour(boolean hovered) {
|
||||
if (!enabled()) {
|
||||
return theme().panelBorder();
|
||||
}
|
||||
return switch (emphasis) {
|
||||
case PRIMARY -> theme().accentHover();
|
||||
case DANGER -> hovered ? theme().danger() : theme().panelBorder();
|
||||
case NORMAL -> hovered ? theme().scrollThumbHover() : theme().panelBorder();
|
||||
};
|
||||
}
|
||||
|
||||
private int foreground() {
|
||||
if (!enabled()) {
|
||||
return theme().textFaint();
|
||||
}
|
||||
return switch (emphasis) {
|
||||
case PRIMARY -> theme().accentText();
|
||||
case DANGER -> theme().danger();
|
||||
case NORMAL -> theme().text();
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.Theme;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* A vertically scrolling viewport: how far down it is, how far down it may go,
|
||||
* and the bar on the right that says so.
|
||||
*
|
||||
* <p>It is not a {@link Widget} because it does not draw the thing being
|
||||
* scrolled. The queue draws rows and the timeline draws a grid of tiles, and
|
||||
* both need to interleave their own clipping and their own hit-testing with the
|
||||
* offset -- so they own a ScrollModel and ask it questions, rather than handing
|
||||
* it their content.
|
||||
*
|
||||
* <p>Movement is eased against the wall clock rather than snapping. The
|
||||
* difference is entirely cosmetic and entirely worth it: a timeline that jumps
|
||||
* by exactly one row per notch reads as a list of rows, while one that glides
|
||||
* reads as a photo album.
|
||||
*/
|
||||
@Accessors(fluent = true)
|
||||
public final class ScrollModel {
|
||||
|
||||
private static final int BAR_WIDTH = 4;
|
||||
private static final int MIN_THUMB_HEIGHT = 16;
|
||||
|
||||
private final Chrome chrome;
|
||||
|
||||
@Getter
|
||||
private Rect viewport = Rect.EMPTY;
|
||||
|
||||
@Getter
|
||||
private int contentHeight;
|
||||
|
||||
private double offset;
|
||||
private double target;
|
||||
private long lastFrameMillis;
|
||||
|
||||
private boolean draggingThumb;
|
||||
private double dragAnchor;
|
||||
|
||||
public ScrollModel(Chrome chrome) {
|
||||
this.chrome = chrome;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Geometry
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Called from the screen's layout pass. Keeps the current position where it can. */
|
||||
public void viewport(Rect bounds) {
|
||||
this.viewport = bounds;
|
||||
clampImmediately();
|
||||
}
|
||||
|
||||
/**
|
||||
* Called whenever the content grows or shrinks -- a month loading in the
|
||||
* timeline, an upload finishing in the queue.
|
||||
*/
|
||||
public void contentHeight(int height) {
|
||||
this.contentHeight = Math.max(0, height);
|
||||
clampImmediately();
|
||||
}
|
||||
|
||||
public int maxOffset() {
|
||||
return Math.max(0, contentHeight - viewport.height());
|
||||
}
|
||||
|
||||
public boolean scrollable() {
|
||||
return maxOffset() > 0;
|
||||
}
|
||||
|
||||
/** The current position, rounded, for the arithmetic that positions content. */
|
||||
public int offset() {
|
||||
return (int) Math.round(offset);
|
||||
}
|
||||
|
||||
/** Where the view is heading, which is what "scroll another notch" builds on. */
|
||||
public double destination() {
|
||||
return target;
|
||||
}
|
||||
|
||||
public void scrollBy(double delta) {
|
||||
scrollTo(target + delta);
|
||||
}
|
||||
|
||||
public void scrollTo(double position) {
|
||||
target = Math.max(0, Math.min(maxOffset(), position));
|
||||
}
|
||||
|
||||
/** Moves without animating -- for jumping to a date, or restoring a saved position. */
|
||||
public void jumpTo(double position) {
|
||||
scrollTo(position);
|
||||
offset = target;
|
||||
}
|
||||
|
||||
private void clampImmediately() {
|
||||
target = Math.max(0, Math.min(maxOffset(), target));
|
||||
offset = Math.max(0, Math.min(maxOffset(), offset));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Animation
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Advances the eased position. Call once per frame before drawing content,
|
||||
* so the content and the bar agree on where the view is.
|
||||
*
|
||||
* <p>Wall clock, not ticks: PhotoSync screens usually pause the game, and a
|
||||
* tick-driven animation would sit still.
|
||||
*/
|
||||
public void advance(long nowMillis) {
|
||||
long elapsed = lastFrameMillis == 0 ? 16 : Math.max(0, Math.min(100, nowMillis - lastFrameMillis));
|
||||
lastFrameMillis = nowMillis;
|
||||
double remaining = target - offset;
|
||||
if (Math.abs(remaining) < 0.5) {
|
||||
offset = target;
|
||||
return;
|
||||
}
|
||||
offset += remaining * (1 - Math.exp(-elapsed / 45.0));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param step how far one notch travels -- a row for the queue, a tile row
|
||||
* for the timeline
|
||||
* @return whether the wheel was over this viewport and did something
|
||||
*/
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount, int step) {
|
||||
if (!scrollable() || !viewport.contains(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
scrollBy(-amount * step);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button != 0 || !scrollable() || !trackBounds().contains(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
Rect thumb = thumbBounds();
|
||||
if (thumb.contains(mouseX, mouseY)) {
|
||||
dragAnchor = mouseY - thumb.y();
|
||||
} else {
|
||||
// Clicking the empty track centres the thumb on the cursor, which is
|
||||
// what a long list needs; paging by a screen would take forever.
|
||||
dragAnchor = thumb.height() / 2.0;
|
||||
jumpToThumbTop(mouseY - dragAnchor);
|
||||
}
|
||||
draggingThumb = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button) {
|
||||
if (!draggingThumb) {
|
||||
return false;
|
||||
}
|
||||
jumpToThumbTop(mouseY - dragAnchor);
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean mouseReleased() {
|
||||
boolean was = draggingThumb;
|
||||
draggingThumb = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
/** Dragging the bar is a direct manipulation, so it does not ease behind the cursor. */
|
||||
private void jumpToThumbTop(double thumbTop) {
|
||||
Rect track = trackBounds();
|
||||
double travel = track.height() - thumbBounds().height();
|
||||
double ratio = travel <= 0 ? 0 : (thumbTop - track.y()) / travel;
|
||||
jumpTo(Math.max(0, Math.min(1, ratio)) * maxOffset());
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// The bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** The full-height strip the thumb runs in, at the right edge of the viewport. */
|
||||
public Rect trackBounds() {
|
||||
return new Rect(viewport.right() - BAR_WIDTH, viewport.y(), BAR_WIDTH, viewport.height());
|
||||
}
|
||||
|
||||
public Rect thumbBounds() {
|
||||
Rect track = trackBounds();
|
||||
if (!scrollable()) {
|
||||
return track;
|
||||
}
|
||||
int height = Math.max(MIN_THUMB_HEIGHT,
|
||||
(int) ((long) track.height() * viewport.height() / contentHeight));
|
||||
int travel = track.height() - height;
|
||||
int y = track.y() + (int) Math.round(travel * (offset / maxOffset()));
|
||||
return new Rect(track.x(), y, track.width(), height);
|
||||
}
|
||||
|
||||
/**
|
||||
* The width the content should leave clear on the right. Zero when the view
|
||||
* is not scrollable, so a short list uses the full width rather than
|
||||
* reserving space for a bar that is not there.
|
||||
*/
|
||||
public int gutter() {
|
||||
return scrollable() ? BAR_WIDTH + 2 : 0;
|
||||
}
|
||||
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
if (!scrollable()) {
|
||||
return;
|
||||
}
|
||||
Theme theme = chrome.theme();
|
||||
Rect track = trackBounds();
|
||||
Rect thumb = thumbBounds();
|
||||
render.fill(track.x(), track.y(), track.width(), track.height(), theme.scrollTrack());
|
||||
boolean hot = draggingThumb || thumb.contains(mouseX, mouseY);
|
||||
render.fill(thumb.x(), thumb.y(), thumb.width(), thumb.height(),
|
||||
hot ? theme.scrollThumbHover() : theme.scrollThumb());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.Keys;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.function.IntConsumer;
|
||||
import java.util.function.IntSupplier;
|
||||
|
||||
/**
|
||||
* An integer chosen by dragging, shown as the label the player actually cares
|
||||
* about rather than the raw number.
|
||||
*
|
||||
* <p>Every numeric setting in PhotoSync has a sensible range that the config
|
||||
* clamps to anyway, which is what makes a slider the right control here: it
|
||||
* cannot produce a value the mod would have to reject, so there is no error
|
||||
* state to design.
|
||||
*/
|
||||
@Accessors(fluent = true)
|
||||
public final class Slider extends Widget {
|
||||
|
||||
private final IntSupplier reader;
|
||||
private final IntConsumer writer;
|
||||
private final int minimum;
|
||||
private final int maximum;
|
||||
private final Formatter labels;
|
||||
|
||||
/** Turns the raw value into the text drawn on the track, e.g. {@code 300 -> "5 min"}. */
|
||||
@FunctionalInterface
|
||||
public interface Formatter {
|
||||
String format(int value);
|
||||
}
|
||||
|
||||
private boolean dragging;
|
||||
|
||||
public Slider(Chrome chrome, IntSupplier reader, IntConsumer writer, int minimum, int maximum, Formatter labels) {
|
||||
super(chrome);
|
||||
this.reader = reader;
|
||||
this.writer = writer;
|
||||
this.minimum = minimum;
|
||||
this.maximum = Math.max(minimum + 1, maximum);
|
||||
this.labels = labels;
|
||||
}
|
||||
|
||||
/** Focusable so the arrow keys can nudge it after a click. */
|
||||
@Override
|
||||
public boolean focusable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
boolean hovered = hovering(mouseX, mouseY);
|
||||
int value = clamp(reader.getAsInt());
|
||||
|
||||
render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), theme().surfaceSunken());
|
||||
render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(),
|
||||
hovered || dragging ? theme().scrollThumbHover() : theme().panelBorder());
|
||||
|
||||
int handleWidth = 6;
|
||||
int travel = bounds().width() - 2 - handleWidth;
|
||||
int handleX = bounds().x() + 1 + (int) Math.round(travel * fraction(value));
|
||||
render.fill(bounds().x() + 1, bounds().y() + 1, handleX - bounds().x() - 1, bounds().height() - 2,
|
||||
enabled() ? theme().fade(theme().accent(), 0.45f) : theme().surface());
|
||||
render.fill(handleX, bounds().y() + 1, handleWidth, bounds().height() - 2,
|
||||
enabled() ? (dragging || hovered ? theme().accentHover() : theme().accent()) : theme().textFaint());
|
||||
|
||||
chrome.centered(render, labels.format(value), bounds(),
|
||||
enabled() ? theme().text() : theme().textFaint());
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button != 0 || !hovering(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
dragging = true;
|
||||
applyAt(mouseX);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
if (!dragging) {
|
||||
return false;
|
||||
}
|
||||
applyAt(mouseX);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
boolean was = dragging;
|
||||
dragging = false;
|
||||
return was;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
if (!hovering(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
writer.accept(clamp(reader.getAsInt() + (int) Math.signum(amount) * step()));
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
int direction = key == Keys.LEFT ? -1 : key == Keys.RIGHT ? 1 : 0;
|
||||
if (direction == 0) {
|
||||
return false;
|
||||
}
|
||||
writer.accept(clamp(reader.getAsInt() + direction * step()));
|
||||
return true;
|
||||
}
|
||||
|
||||
private void applyAt(double mouseX) {
|
||||
double ratio = (mouseX - bounds().x() - 3) / Math.max(1, bounds().width() - 6);
|
||||
writer.accept(clamp(minimum + (int) Math.round(ratio * (maximum - minimum))));
|
||||
}
|
||||
|
||||
/** One percent of the range, so a keypress or wheel notch moves a useful amount. */
|
||||
private int step() {
|
||||
return Math.max(1, (maximum - minimum) / 100);
|
||||
}
|
||||
|
||||
private double fraction(int value) {
|
||||
return (double) (value - minimum) / (maximum - minimum);
|
||||
}
|
||||
|
||||
private int clamp(int value) {
|
||||
return Math.max(minimum, Math.min(maximum, value));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.Clipboard;
|
||||
import dev.photosync.mcapi.Keys;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* A single line of editable text.
|
||||
*
|
||||
* <p>Paste is the reason this widget is as complete as it is. An Immich API key
|
||||
* is sixty-odd random characters that nobody types by hand, so Control-V --
|
||||
* Command-V on macOS -- has to work, and once you have paste you need a
|
||||
* selection to paste over, and once you have a selection you need shift-arrow
|
||||
* and Control-A to make one.
|
||||
*
|
||||
* <p>{@code masked} draws asterisks. It is display-only: the value is still the
|
||||
* real string, and the widget offers no "reveal" toggle because the player can
|
||||
* always paste it somewhere they control.
|
||||
*/
|
||||
@Accessors(fluent = true)
|
||||
public final class TextField extends Widget {
|
||||
|
||||
private final Clipboard clipboard;
|
||||
private final Consumer<String> onChange;
|
||||
|
||||
@Getter
|
||||
private String value = "";
|
||||
private int cursor;
|
||||
private int selectionAnchor;
|
||||
private int scrollX;
|
||||
|
||||
@Setter
|
||||
private String hint = "";
|
||||
|
||||
@Setter
|
||||
private boolean masked;
|
||||
|
||||
@Setter
|
||||
private int maxLength = 512;
|
||||
|
||||
public TextField(Chrome chrome, Clipboard clipboard, Consumer<String> onChange) {
|
||||
super(chrome);
|
||||
this.clipboard = clipboard;
|
||||
this.onChange = onChange;
|
||||
}
|
||||
|
||||
/** Replaces the contents without notifying {@code onChange} -- for loading a draft in. */
|
||||
public void reset(String newValue) {
|
||||
this.value = newValue == null ? "" : newValue;
|
||||
this.cursor = this.value.length();
|
||||
this.selectionAnchor = this.cursor;
|
||||
this.scrollX = 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean focusable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void focus(boolean value) {
|
||||
super.focus(value);
|
||||
if (!value) {
|
||||
selectionAnchor = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Rendering
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
boolean hovered = hovering(mouseX, mouseY);
|
||||
render.fill(bounds().x(), bounds().y(), bounds().width(), bounds().height(), theme().surfaceSunken());
|
||||
render.border(bounds().x(), bounds().y(), bounds().width(), bounds().height(),
|
||||
focused() ? theme().accent() : hovered ? theme().scrollThumbHover() : theme().panelBorder());
|
||||
|
||||
int innerX = bounds().x() + 4;
|
||||
int innerWidth = bounds().width() - 8;
|
||||
int baseline = bounds().y() + (bounds().height() - render.lineHeight()) / 2 + 1;
|
||||
|
||||
if (value.isEmpty() && !focused()) {
|
||||
render.text(chrome.elide(render, hint, innerWidth), innerX, baseline, theme().textFaint(), false);
|
||||
return;
|
||||
}
|
||||
|
||||
String shown = displayed();
|
||||
keepCursorVisible(render, innerWidth);
|
||||
|
||||
render.pushClip(innerX, bounds().y() + 1, innerWidth, bounds().height() - 2);
|
||||
int textX = innerX - scrollX;
|
||||
|
||||
if (hasSelection()) {
|
||||
int from = Math.min(cursor, selectionAnchor);
|
||||
int to = Math.max(cursor, selectionAnchor);
|
||||
int selectionStart = textX + render.textWidth(shown.substring(0, from));
|
||||
int selectionWidth = render.textWidth(shown.substring(from, to));
|
||||
render.fill(selectionStart, bounds().y() + 2, selectionWidth, bounds().height() - 4,
|
||||
theme().fade(theme().accent(), 0.45f));
|
||||
}
|
||||
|
||||
render.text(shown, textX, baseline, enabled() ? theme().text() : theme().textFaint(), false);
|
||||
|
||||
// 530ms is close enough to the blink rate everything else uses that it
|
||||
// reads as a cursor rather than as something being wrong.
|
||||
if (focused() && (System.currentTimeMillis() / 530) % 2 == 0) {
|
||||
int caretX = textX + render.textWidth(shown.substring(0, cursor));
|
||||
render.fill(caretX, bounds().y() + 3, 1, bounds().height() - 6, theme().text());
|
||||
}
|
||||
render.popClip();
|
||||
}
|
||||
|
||||
private void keepCursorVisible(RenderBridge render, int innerWidth) {
|
||||
String shown = displayed();
|
||||
int caretX = render.textWidth(shown.substring(0, cursor));
|
||||
if (caretX - scrollX > innerWidth - 2) {
|
||||
scrollX = caretX - innerWidth + 2;
|
||||
}
|
||||
if (caretX - scrollX < 0) {
|
||||
scrollX = caretX;
|
||||
}
|
||||
int total = render.textWidth(shown);
|
||||
scrollX = Math.max(0, Math.min(scrollX, Math.max(0, total - innerWidth + 2)));
|
||||
}
|
||||
|
||||
private String displayed() {
|
||||
return masked ? "*".repeat(value.length()) : value;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Input
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button != 0 || !hovering(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
// Focus is enough to place the caret sensibly for these fields, which
|
||||
// hold a URL or a key rather than prose. Click-to-position would need a
|
||||
// font metric per character and buys very little here.
|
||||
cursor = value.length();
|
||||
selectionAnchor = 0;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
if (!focused() || !enabled()) {
|
||||
return false;
|
||||
}
|
||||
if (Keys.shortcut(modifiers)) {
|
||||
return shortcut(key);
|
||||
}
|
||||
return switch (key) {
|
||||
case Keys.BACKSPACE -> {
|
||||
if (hasSelection()) {
|
||||
deleteSelection();
|
||||
} else if (cursor > 0) {
|
||||
replace(cursor - 1, cursor, "");
|
||||
}
|
||||
yield true;
|
||||
}
|
||||
case Keys.DELETE -> {
|
||||
if (hasSelection()) {
|
||||
deleteSelection();
|
||||
} else if (cursor < value.length()) {
|
||||
replace(cursor, cursor + 1, "");
|
||||
}
|
||||
yield true;
|
||||
}
|
||||
case Keys.LEFT -> {
|
||||
moveTo(Math.max(0, cursor - 1), Keys.shift(modifiers));
|
||||
yield true;
|
||||
}
|
||||
case Keys.RIGHT -> {
|
||||
moveTo(Math.min(value.length(), cursor + 1), Keys.shift(modifiers));
|
||||
yield true;
|
||||
}
|
||||
case Keys.HOME -> {
|
||||
moveTo(0, Keys.shift(modifiers));
|
||||
yield true;
|
||||
}
|
||||
case Keys.END -> {
|
||||
moveTo(value.length(), Keys.shift(modifiers));
|
||||
yield true;
|
||||
}
|
||||
// Swallowed so the screen does not also act on them while typing.
|
||||
case Keys.ESCAPE, Keys.TAB -> false;
|
||||
default -> Keys.confirms(key);
|
||||
};
|
||||
}
|
||||
|
||||
private boolean shortcut(int key) {
|
||||
switch (key) {
|
||||
case Keys.A -> {
|
||||
selectionAnchor = 0;
|
||||
cursor = value.length();
|
||||
return true;
|
||||
}
|
||||
case Keys.C -> {
|
||||
if (hasSelection() && !masked) {
|
||||
clipboard.write(selectedText());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case Keys.X -> {
|
||||
if (hasSelection()) {
|
||||
if (!masked) {
|
||||
clipboard.write(selectedText());
|
||||
}
|
||||
deleteSelection();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
case Keys.V -> {
|
||||
insert(clipboard.read());
|
||||
return true;
|
||||
}
|
||||
default -> {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean charTyped(char character, int modifiers) {
|
||||
if (!focused() || !enabled() || character < ' ' || character == 127) {
|
||||
return false;
|
||||
}
|
||||
insert(String.valueOf(character));
|
||||
return true;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Editing
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
private void insert(String text) {
|
||||
if (text == null || text.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
// Newlines and control characters arrive via paste and would render as
|
||||
// boxes; a pasted key with a trailing newline is the common case.
|
||||
String clean = text.replaceAll("[\\p{Cntrl}]", "");
|
||||
int from = Math.min(cursor, selectionAnchor);
|
||||
int to = Math.max(cursor, selectionAnchor);
|
||||
int room = maxLength - (value.length() - (to - from));
|
||||
if (room <= 0) {
|
||||
return;
|
||||
}
|
||||
replace(from, to, clean.length() > room ? clean.substring(0, room) : clean);
|
||||
}
|
||||
|
||||
private void deleteSelection() {
|
||||
replace(Math.min(cursor, selectionAnchor), Math.max(cursor, selectionAnchor), "");
|
||||
}
|
||||
|
||||
private void replace(int from, int to, String replacement) {
|
||||
value = value.substring(0, from) + replacement + value.substring(to);
|
||||
cursor = from + replacement.length();
|
||||
selectionAnchor = cursor;
|
||||
onChange.accept(value);
|
||||
}
|
||||
|
||||
private void moveTo(int position, boolean extendSelection) {
|
||||
cursor = position;
|
||||
if (!extendSelection) {
|
||||
selectionAnchor = cursor;
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasSelection() {
|
||||
return cursor != selectionAnchor;
|
||||
}
|
||||
|
||||
private String selectedText() {
|
||||
return value.substring(Math.min(cursor, selectionAnchor), Math.max(cursor, selectionAnchor));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.Rect;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
import java.util.function.BooleanSupplier;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* An on/off switch with a label, and an optional line of explanation under it.
|
||||
*
|
||||
* <p>It reads and writes through a supplier and a consumer rather than holding
|
||||
* a boolean. The settings screen edits a draft config, and a widget with its own
|
||||
* copy of the value is a second source of truth that drifts the moment anything
|
||||
* else -- a reset button, a provider change -- touches the draft.
|
||||
*/
|
||||
@Accessors(fluent = true)
|
||||
public final class Toggle extends Widget {
|
||||
|
||||
private final String label;
|
||||
private final BooleanSupplier reader;
|
||||
private final Consumer<Boolean> writer;
|
||||
|
||||
@Setter
|
||||
private String description;
|
||||
|
||||
public Toggle(Chrome chrome, String label, BooleanSupplier reader, Consumer<Boolean> writer) {
|
||||
super(chrome);
|
||||
this.label = label;
|
||||
this.reader = reader;
|
||||
this.writer = writer;
|
||||
}
|
||||
|
||||
public Toggle describedAs(String value) {
|
||||
this.description = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
boolean on = reader.getAsBoolean();
|
||||
boolean hovered = hovering(mouseX, mouseY);
|
||||
|
||||
int size = 11;
|
||||
int boxY = bounds().y() + (render.lineHeight() - size) / 2 + 1;
|
||||
Rect box = new Rect(bounds().x(), boxY, size, size);
|
||||
|
||||
render.fill(box.x(), box.y(), box.width(), box.height(),
|
||||
on ? theme().accent() : theme().surfaceFor(hovered, false));
|
||||
render.border(box.x(), box.y(), box.width(), box.height(),
|
||||
on ? theme().accentHover() : hovered ? theme().scrollThumbHover() : theme().panelBorder());
|
||||
if (on) {
|
||||
// A check mark from two runs: a short down-right stroke and a long
|
||||
// up-right one. Legible at eleven pixels, which a glyph would not be.
|
||||
for (int i = 0; i < 3; i++) {
|
||||
render.fill(box.x() + 2 + i, box.y() + 4 + i, 1, 2, theme().accentText());
|
||||
}
|
||||
for (int i = 0; i < 4; i++) {
|
||||
render.fill(box.x() + 5 + i, box.y() + 6 - i, 1, 2, theme().accentText());
|
||||
}
|
||||
}
|
||||
|
||||
int textX = box.right() + 6;
|
||||
int colour = enabled() ? theme().text() : theme().textFaint();
|
||||
render.text(chrome.elide(render, label, bounds().right() - textX), textX, bounds().y() + 1, colour, true);
|
||||
if (description != null && !description.isEmpty()) {
|
||||
render.text(chrome.elide(render, description, bounds().right() - textX),
|
||||
textX, bounds().y() + render.lineHeight() + 2, theme().textFaint(), false);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
if (button != 0 || !hovering(mouseX, mouseY)) {
|
||||
return false;
|
||||
}
|
||||
writer.accept(!reader.getAsBoolean());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Two lines when there is a description, one otherwise. */
|
||||
public int preferredHeight(RenderBridge render) {
|
||||
return description == null || description.isEmpty()
|
||||
? render.lineHeight() + 4
|
||||
: render.lineHeight() * 2 + 4;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.Rect;
|
||||
import dev.photosync.ui.Theme;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.experimental.Accessors;
|
||||
|
||||
/**
|
||||
* A rectangle that draws itself and may react to input.
|
||||
*
|
||||
* <p>The input methods mirror {@code ScreenModel}'s and answer the same
|
||||
* question -- "did you consume this?" -- so a screen can hand an event to its
|
||||
* widgets and pass the answer straight back to the game.
|
||||
*/
|
||||
@Getter
|
||||
@Accessors(fluent = true)
|
||||
public abstract class Widget {
|
||||
|
||||
protected final Chrome chrome;
|
||||
|
||||
@Setter
|
||||
private Rect bounds = Rect.EMPTY;
|
||||
|
||||
@Setter
|
||||
private boolean enabled = true;
|
||||
|
||||
@Setter
|
||||
private boolean visible = true;
|
||||
|
||||
private boolean focused;
|
||||
|
||||
protected Widget(Chrome chrome) {
|
||||
this.chrome = chrome;
|
||||
}
|
||||
|
||||
protected Theme theme() {
|
||||
return chrome.theme();
|
||||
}
|
||||
|
||||
/** Visible and enabled: the only state in which a widget takes input. */
|
||||
public boolean active() {
|
||||
return visible && enabled;
|
||||
}
|
||||
|
||||
public abstract void render(RenderBridge render, int mouseX, int mouseY);
|
||||
|
||||
/** Whether keyboard focus can land here. Only text entry says yes. */
|
||||
public boolean focusable() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Called by {@link WidgetList} when focus arrives or leaves. */
|
||||
public void focus(boolean value) {
|
||||
this.focused = value;
|
||||
}
|
||||
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean charTyped(char character, int modifiers) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
}
|
||||
|
||||
protected boolean hovering(double mouseX, double mouseY) {
|
||||
return active() && bounds.contains(mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package dev.photosync.ui.widget;
|
||||
|
||||
import dev.photosync.mcapi.Keys;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The widgets on one screen, and the two pieces of state that only make sense
|
||||
* for the set as a whole: which one has keyboard focus, and which one is
|
||||
* currently capturing the mouse.
|
||||
*
|
||||
* <p>Capture matters more than it looks. Without it, dragging a slider and
|
||||
* letting the cursor slip off the track drops the drag, which every player
|
||||
* notices and nobody reports as a bug -- they just conclude the slider is
|
||||
* fiddly.
|
||||
*/
|
||||
public final class WidgetList {
|
||||
|
||||
private final List<Widget> widgets = new ArrayList<>();
|
||||
|
||||
private Widget focused;
|
||||
private Widget capturing;
|
||||
|
||||
/** Returns its argument so a screen can add and keep a reference in one line. */
|
||||
public <W extends Widget> W add(W widget) {
|
||||
widgets.add(widget);
|
||||
return widget;
|
||||
}
|
||||
|
||||
public void clear() {
|
||||
// Through focus(null) rather than by assignment, so the outgoing widget
|
||||
// is told it lost focus and stops drawing a caret.
|
||||
focus(null);
|
||||
capturing = null;
|
||||
widgets.clear();
|
||||
}
|
||||
|
||||
public void render(RenderBridge render, int mouseX, int mouseY) {
|
||||
for (Widget widget : widgets) {
|
||||
if (widget.visible()) {
|
||||
widget.render(render, mouseX, mouseY);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
widgets.forEach(Widget::tick);
|
||||
}
|
||||
|
||||
public boolean mouseClicked(double mouseX, double mouseY, int button) {
|
||||
// Reverse order so the widget drawn last -- and therefore on top --
|
||||
// gets first refusal on the click.
|
||||
for (int i = widgets.size() - 1; i >= 0; i--) {
|
||||
Widget widget = widgets.get(i);
|
||||
if (!widget.active()) {
|
||||
continue;
|
||||
}
|
||||
if (widget.mouseClicked(mouseX, mouseY, button)) {
|
||||
capturing = widget;
|
||||
focus(widget.focusable() ? widget : null);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A click on empty space commits whatever was being typed.
|
||||
focus(null);
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean mouseReleased(double mouseX, double mouseY, int button) {
|
||||
Widget target = capturing;
|
||||
capturing = null;
|
||||
return target != null && target.mouseReleased(mouseX, mouseY, button);
|
||||
}
|
||||
|
||||
public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
|
||||
return capturing != null && capturing.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
|
||||
}
|
||||
|
||||
public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
|
||||
for (int i = widgets.size() - 1; i >= 0; i--) {
|
||||
Widget widget = widgets.get(i);
|
||||
if (widget.active() && widget.mouseScrolled(mouseX, mouseY, amount)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean keyPressed(int key, int scanCode, int modifiers) {
|
||||
if (focused != null && focused.active() && focused.keyPressed(key, scanCode, modifiers)) {
|
||||
return true;
|
||||
}
|
||||
if (key == Keys.TAB && !widgets.isEmpty()) {
|
||||
return cycleFocus(Keys.shift(modifiers) ? -1 : 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean charTyped(char character, int modifiers) {
|
||||
return focused != null && focused.active() && focused.charTyped(character, modifiers);
|
||||
}
|
||||
|
||||
/** True while a text field is taking input, so Escape can close the field rather than the screen. */
|
||||
public boolean hasFocus() {
|
||||
return focused != null;
|
||||
}
|
||||
|
||||
public void focus(Widget widget) {
|
||||
if (focused == widget) {
|
||||
return;
|
||||
}
|
||||
if (focused != null) {
|
||||
focused.focus(false);
|
||||
}
|
||||
focused = widget;
|
||||
if (focused != null) {
|
||||
focused.focus(true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean cycleFocus(int direction) {
|
||||
List<Widget> candidates = widgets.stream().filter(w -> w.active() && w.focusable()).toList();
|
||||
if (candidates.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
int current = candidates.indexOf(focused);
|
||||
// With nothing focused yet, Tab starts at the first widget and
|
||||
// shift-Tab at the last, rather than wherever -1 + direction lands.
|
||||
int next = current < 0
|
||||
? (direction > 0 ? 0 : candidates.size() - 1)
|
||||
: Math.floorMod(current + direction, candidates.size());
|
||||
focus(candidates.get(next));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user