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"
}