This commit is contained in:
iceBear67
2026-08-07 18:10:38 +00:00
commit 014b18f1ba
232 changed files with 16847 additions and 0 deletions
+8
View File
@@ -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"
}
+2
View File
@@ -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);
}
}
}
@@ -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;
}
}
+5
View File
@@ -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;
}
}
+6
View File
@@ -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;
}
}