init
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
// The assembled mod, minus Minecraft. A platform module builds one
|
||||
// PhotoSyncClient and forwards the game's events to it; everything else in
|
||||
// :shared is reached through that object.
|
||||
dependencies {
|
||||
api project(':shared:core')
|
||||
api project(':shared:mc-api')
|
||||
api project(':shared:ui')
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package dev.photosync.client;
|
||||
|
||||
import dev.photosync.core.capture.CaptureOrigin;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.config.AutoCaptureSettings;
|
||||
import dev.photosync.mcapi.GameContext;
|
||||
import dev.photosync.mcapi.capture.ScreenshotService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
/**
|
||||
* The timer behind the optional "take a screenshot every N seconds" setting.
|
||||
*
|
||||
* <p>Measured against the wall clock rather than counted in ticks. Ticks stop
|
||||
* when the game is paused, which would make a five-minute interval mean
|
||||
* something different depending on how long the player spent in their inventory,
|
||||
* and the feature is only comprehensible if the number on the slider is the
|
||||
* number of seconds that elapse.
|
||||
*
|
||||
* <p>Off by default, and it stays off until the player says otherwise: silently
|
||||
* filling someone's screenshots folder is not a feature.
|
||||
*/
|
||||
@Slf4j
|
||||
public final class AutoCapture {
|
||||
|
||||
private final GameContext game;
|
||||
private final ScreenshotService screenshots;
|
||||
private final Supplier<AutoCaptureSettings> settings;
|
||||
private final Consumer<CapturedScreenshot> sink;
|
||||
|
||||
/** Zero means "not armed": the next tick sets the first deadline. */
|
||||
private long nextCaptureMillis;
|
||||
private boolean capturing;
|
||||
|
||||
public AutoCapture(GameContext game, ScreenshotService screenshots,
|
||||
Supplier<AutoCaptureSettings> settings, Consumer<CapturedScreenshot> sink) {
|
||||
this.game = game;
|
||||
this.screenshots = screenshots;
|
||||
this.settings = settings;
|
||||
this.sink = sink;
|
||||
}
|
||||
|
||||
/** Called once per client tick, from the render thread. */
|
||||
public void tick() {
|
||||
AutoCaptureSettings current = settings.get();
|
||||
if (!current.enabled()) {
|
||||
// Disarmed, so switching the feature on never fires immediately --
|
||||
// it always waits a full interval first.
|
||||
nextCaptureMillis = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
long now = System.currentTimeMillis();
|
||||
long interval = current.intervalSeconds() * 1000L;
|
||||
if (nextCaptureMillis == 0) {
|
||||
nextCaptureMillis = now + interval;
|
||||
return;
|
||||
}
|
||||
if (now < nextCaptureMillis || capturing) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.onlyInWorld() && !game.inWorld()) {
|
||||
// Sitting in the menus is a long-lived state; hold the deadline
|
||||
// forward so joining a world does not trigger an immediate burst.
|
||||
nextCaptureMillis = now + interval;
|
||||
return;
|
||||
}
|
||||
if (current.skipWhenScreenOpen() && game.screenOpen()) {
|
||||
// An open screen is momentary by comparison, so the deadline stays
|
||||
// where it is and the shot happens as soon as it closes.
|
||||
return;
|
||||
}
|
||||
|
||||
nextCaptureMillis = now + interval;
|
||||
take(current.fileNameSuffix());
|
||||
}
|
||||
|
||||
private void take(String suffix) {
|
||||
capturing = true;
|
||||
screenshots.capture(suffix).whenComplete((file, failure) -> {
|
||||
capturing = false;
|
||||
if (failure != null) {
|
||||
log.warn("Automatic capture failed", failure);
|
||||
return;
|
||||
}
|
||||
publish(file);
|
||||
});
|
||||
}
|
||||
|
||||
private void publish(Path file) {
|
||||
try {
|
||||
sink.accept(CapturedScreenshot.of(file, CaptureOrigin.AUTOMATIC));
|
||||
} catch (IOException e) {
|
||||
log.warn("Wrote {} but could not read it back", file, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package dev.photosync.client;
|
||||
|
||||
import dev.photosync.core.PhotoSync;
|
||||
import dev.photosync.core.capture.CapturedScreenshot;
|
||||
import dev.photosync.core.config.NotificationKind;
|
||||
import dev.photosync.core.config.PhotoSyncConfig;
|
||||
import dev.photosync.core.upload.UploadEvent;
|
||||
import dev.photosync.mcapi.ClientBridge;
|
||||
import dev.photosync.mcapi.capture.ScreenshotBus;
|
||||
import dev.photosync.mcapi.lifecycle.QuitGuard;
|
||||
import dev.photosync.mcapi.render.RenderBridge;
|
||||
import dev.photosync.ui.Chrome;
|
||||
import dev.photosync.ui.PhotoSyncUi;
|
||||
import dev.photosync.ui.Theme;
|
||||
import lombok.Getter;
|
||||
import lombok.experimental.Accessors;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* The mod, as one object.
|
||||
*
|
||||
* <p>A platform module builds this with its {@link ClientBridge} and then only
|
||||
* ever calls the five methods below: {@link #start()}, {@link #tick()},
|
||||
* {@link #renderHud}, {@link #openScreen()} and {@link #close()}. Everything a
|
||||
* new Minecraft version can break is on the other side of that bridge.
|
||||
*
|
||||
* <p>What lives here is the wiring nobody else can own, because it crosses the
|
||||
* modules: a screenshot becomes an upload, an upload becomes a message in the
|
||||
* corner, and a quit becomes a question.
|
||||
*/
|
||||
@Slf4j
|
||||
@Accessors(fluent = true)
|
||||
public final class PhotoSyncClient implements AutoCloseable {
|
||||
|
||||
/**
|
||||
* How long the game is allowed to hang on shutdown after the player has said
|
||||
* they are done waiting. The queue is durable, so this is a courtesy for
|
||||
* uploads that are seconds from finishing, not a guarantee.
|
||||
*/
|
||||
private static final Duration SHUTDOWN_GRACE = Duration.ofSeconds(3);
|
||||
|
||||
@Getter
|
||||
private final PhotoSync core;
|
||||
@Getter
|
||||
private final ClientBridge bridge;
|
||||
@Getter
|
||||
private final PhotoSyncUi ui;
|
||||
|
||||
private final AutoCapture autoCapture;
|
||||
|
||||
public PhotoSyncClient(ClientBridge bridge) {
|
||||
this(bridge, new PhotoSync(bridge.game().configDirectory()), Theme.dark());
|
||||
}
|
||||
|
||||
/** Takes its collaborators explicitly so a test can drive it without a game. */
|
||||
public PhotoSyncClient(ClientBridge bridge, PhotoSync core, Theme theme) {
|
||||
this.bridge = bridge;
|
||||
this.core = core;
|
||||
this.ui = new PhotoSyncUi(core, bridge, theme);
|
||||
this.autoCapture = new AutoCapture(
|
||||
bridge.game(),
|
||||
bridge.screenshots(),
|
||||
() -> core.config().current().autoCapture(),
|
||||
this::captured);
|
||||
}
|
||||
|
||||
/** Connects everything and starts draining whatever last session left behind. */
|
||||
public PhotoSyncClient start() {
|
||||
core.start();
|
||||
// Manual screenshots arrive here from the platform's Screenshot mixin;
|
||||
// automatic ones come from AutoCapture, which already knows their origin.
|
||||
ScreenshotBus.get().subscribe(this::captured);
|
||||
core.queue().onEvent(this::uploadChanged);
|
||||
QuitGuard.get().handler(this::mayQuit);
|
||||
log.info("PhotoSync ready on Minecraft {}", bridge.game().minecraftVersion());
|
||||
return this;
|
||||
}
|
||||
|
||||
public void tick() {
|
||||
autoCapture.tick();
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the corner messages. Called every frame the in-world HUD is drawn,
|
||||
* so not on the title screen and not on top of an open screen.
|
||||
*/
|
||||
public void renderHud(RenderBridge render) {
|
||||
ui.notifications().render(render, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/** The key binding's action. */
|
||||
public void openScreen() {
|
||||
ui.open();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Screenshots in, notifications out
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/** Called from an IO thread for manual shots and a worker for automatic ones. */
|
||||
private void captured(CapturedScreenshot shot) {
|
||||
PhotoSyncConfig config = core.config().current();
|
||||
ui.notifications().show(NotificationKind.CAPTURED,
|
||||
chrome().translate("photosync.notify.captured", shot.fileName()));
|
||||
|
||||
if (!config.upload().uploadOnCapture()) {
|
||||
return;
|
||||
}
|
||||
if (!config.isReady()) {
|
||||
// Queuing against a server we have no credentials for would fill the
|
||||
// queue screen with failures the player cannot act on from there.
|
||||
log.debug("Not queuing {}: no provider configured", shot.fileName());
|
||||
return;
|
||||
}
|
||||
core.queue().enqueue(shot, config.album());
|
||||
}
|
||||
|
||||
private void uploadChanged(UploadEvent event) {
|
||||
if (event instanceof UploadEvent.Completed completed) {
|
||||
ui.notifications().show(NotificationKind.UPLOADED,
|
||||
chrome().translate("photosync.notify.uploaded", completed.job().fileName()));
|
||||
} else if (event instanceof UploadEvent.Failed failed && !failed.willRetry()) {
|
||||
// Only the final failure is worth a message. The retries are visible
|
||||
// on the queue screen for anyone who wants to watch them.
|
||||
ui.notifications().show(NotificationKind.FAILED,
|
||||
chrome().translate("photosync.notify.failed", failed.job().fileName()));
|
||||
}
|
||||
}
|
||||
|
||||
private Chrome chrome() {
|
||||
return ui.chrome();
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Quitting
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Answers {@code QuitGuard}. Returning false hands the interaction to the
|
||||
* quit dialog, which comes back through {@link PhotoSyncUi#quitNow()}.
|
||||
*/
|
||||
private boolean mayQuit() {
|
||||
PhotoSyncConfig config = core.config().current();
|
||||
if (!config.upload().waitOnQuit() || !core.isBusy()) {
|
||||
return true;
|
||||
}
|
||||
bridge.game().submit(ui::confirmQuit);
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
// A short drain first: an upload that is nearly done finishes now rather
|
||||
// than being re-attempted from the start next launch.
|
||||
if (core.isBusy() && !core.drain(SHUTDOWN_GRACE)) {
|
||||
log.info("Leaving {} upload(s) queued for the next session", core.queue().activeCount());
|
||||
}
|
||||
core.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Where the mod is assembled: the object graph, and the client-side behaviour
|
||||
* that is not drawing.
|
||||
*
|
||||
* <p>{@code :shared:core} knows about uploads, {@code :shared:ui} knows about
|
||||
* pixels, and {@code :shared:mc-api} declares what the game must provide. None
|
||||
* of them know about each other's lifecycles. This module is the one place that
|
||||
* does -- it decides that a screenshot becomes an upload, that an upload becomes
|
||||
* a corner message, and that quitting mid-upload becomes a dialog.
|
||||
*
|
||||
* <p>A platform module therefore constructs exactly one class from here and
|
||||
* forwards five events to it. That is the entire surface a new Minecraft version
|
||||
* has to reconnect.
|
||||
*/
|
||||
package dev.photosync.client;
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"key.categories.photosync": "PhotoSync",
|
||||
"key.category.photosync.main": "PhotoSync",
|
||||
"key.photosync.open": "Open PhotoSync",
|
||||
|
||||
"photosync.tab.queue": "Uploads",
|
||||
"photosync.tab.browse": "Browse",
|
||||
"photosync.tab.settings": "Settings",
|
||||
|
||||
"photosync.state.pending": "Queued",
|
||||
"photosync.state.uploading": "Uploading",
|
||||
"photosync.state.retrying": "Retrying",
|
||||
"photosync.state.completed": "Done",
|
||||
"photosync.state.failed": "Failed",
|
||||
"photosync.state.cancelled": "Cancelled",
|
||||
|
||||
"photosync.queue.retry_all": "Retry failed",
|
||||
"photosync.queue.clear_finished": "Clear finished",
|
||||
"photosync.queue.retry": "Retry",
|
||||
"photosync.queue.cancel": "Cancel",
|
||||
"photosync.queue.reveal": "Show in folder",
|
||||
"photosync.queue.forget": "Remove",
|
||||
"photosync.queue.empty": "No uploads yet",
|
||||
"photosync.queue.empty.hint": "Screenshots you take will appear here.",
|
||||
"photosync.queue.no_selection": "Select a screenshot to see it here.",
|
||||
"photosync.queue.preview_failed": "Preview unavailable",
|
||||
"photosync.queue.preview_loading": "Loading preview...",
|
||||
"photosync.queue.idle": "Nothing to upload",
|
||||
"photosync.queue.status": "%s active, %s failed",
|
||||
|
||||
"photosync.browse.refresh": "Refresh",
|
||||
"photosync.browse.showing_library": "Showing everything",
|
||||
"photosync.browse.showing_album": "Showing album",
|
||||
"photosync.browse.not_configured": "Not connected",
|
||||
"photosync.browse.not_configured.hint": "Add your server and API key in Settings.",
|
||||
"photosync.browse.loading": "Loading timeline...",
|
||||
"photosync.browse.failed": "Could not load the timeline",
|
||||
"photosync.browse.empty": "Nothing here yet",
|
||||
"photosync.browse.page_failed": "Could not load these photos",
|
||||
"photosync.browse.opening": "Loading...",
|
||||
"photosync.browse.close_hint": "Esc to close",
|
||||
"photosync.browse.status": "%s photos in %s days",
|
||||
|
||||
"photosync.album.library": "Whole library",
|
||||
"photosync.album.back": "Back",
|
||||
"photosync.album.refresh": "Refresh",
|
||||
"photosync.album.create": "Create",
|
||||
"photosync.album.new_hint": "New album name",
|
||||
"photosync.album.loading": "Loading albums...",
|
||||
"photosync.album.failed": "Could not load albums",
|
||||
"photosync.album.not_configured": "Add your server and API key first.",
|
||||
"photosync.album.status": "Uploading to: %s",
|
||||
|
||||
"photosync.settings.section.connection": "Connection",
|
||||
"photosync.settings.section.upload": "Uploads",
|
||||
"photosync.settings.section.auto_capture": "Automatic screenshots",
|
||||
"photosync.settings.section.notifications": "Notifications",
|
||||
"photosync.settings.section.browser": "Browser",
|
||||
|
||||
"photosync.settings.provider": "Service",
|
||||
"photosync.settings.connection_state": "Connection",
|
||||
"photosync.settings.test": "Test",
|
||||
"photosync.settings.test.running": "Testing...",
|
||||
"photosync.settings.test.incomplete": "Fill in both fields first.",
|
||||
"photosync.settings.test.ok": "Connected as %s (server %s)",
|
||||
"photosync.settings.test.failed": "Failed: %s",
|
||||
"photosync.settings.album": "Album",
|
||||
|
||||
"photosync.settings.upload_on_capture": "Upload screenshots automatically",
|
||||
"photosync.settings.upload_on_capture.detail": "Every screenshot you take is queued for upload.",
|
||||
"photosync.settings.concurrency": "Uploads at once",
|
||||
"photosync.settings.attempts": "Attempts before giving up",
|
||||
"photosync.settings.backoff": "Wait between attempts",
|
||||
"photosync.settings.wait_on_quit": "Ask before quitting mid-upload",
|
||||
"photosync.settings.wait_on_quit.detail": "Shows progress and lets uploads finish. Unfinished ones resume next time either way.",
|
||||
"photosync.settings.delete_local": "Delete the local file after upload",
|
||||
"photosync.settings.delete_local.detail": "Only once the server has confirmed it.",
|
||||
|
||||
"photosync.settings.auto_capture": "Take screenshots on a timer",
|
||||
"photosync.settings.auto_capture.detail": "Off by default.",
|
||||
"photosync.settings.interval": "Every",
|
||||
"photosync.settings.suffix": "Name suffix",
|
||||
"photosync.settings.only_in_world": "Only while in a world",
|
||||
"photosync.settings.skip_when_screen_open": "Skip while a screen is open",
|
||||
|
||||
"photosync.settings.notify": "Show messages in the corner",
|
||||
"photosync.settings.notify.detail": "One line, bottom left, for a moment.",
|
||||
"photosync.settings.notify_capture": "When a screenshot is taken",
|
||||
"photosync.settings.notify_uploaded": "When an upload finishes",
|
||||
"photosync.settings.notify_failed": "When an upload fails",
|
||||
"photosync.settings.linger": "Message duration",
|
||||
|
||||
"photosync.settings.tile_size": "Thumbnail size",
|
||||
"photosync.settings.cache": "Thumbnails kept in memory",
|
||||
"photosync.settings.video_badge": "Mark videos in the grid",
|
||||
|
||||
"photosync.settings.save": "Save",
|
||||
"photosync.settings.revert": "Revert",
|
||||
"photosync.settings.dirty": "Unsaved changes -- saved when you close this screen.",
|
||||
"photosync.settings.clean": "All changes saved.",
|
||||
|
||||
"photosync.quit.title": "Uploads still running",
|
||||
"photosync.quit.remaining": "%s left to upload",
|
||||
"photosync.quit.finishing": "Finishing up...",
|
||||
"photosync.quit.hint": "Anything unfinished resumes next time you play.",
|
||||
"photosync.quit.keep_playing": "Keep playing",
|
||||
"photosync.quit.anyway": "Quit anyway",
|
||||
|
||||
"photosync.notify.captured": "Screenshot saved: %s",
|
||||
"photosync.notify.uploaded": "Uploaded %s",
|
||||
"photosync.notify.failed": "Upload failed: %s",
|
||||
|
||||
"photosync.provider.immich.name": "Immich",
|
||||
"photosync.provider.immich.endpoint": "Server URL",
|
||||
"photosync.provider.immich.endpoint.hint": "https://photos.example.com",
|
||||
"photosync.provider.immich.secret": "API key",
|
||||
"photosync.provider.immich.secret.hint": "Account settings -> API Keys"
|
||||
}
|
||||
Reference in New Issue
Block a user