commit 014b18f1ba7c0657f4c6f4dc4fd2e762e5efa11d Author: iceBear67 Date: Fri Aug 7 18:10:38 2026 +0000 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..19bf9b9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +**/build/** +.idea +**/__pycache__/** +.gradle diff --git a/PROMPT.md b/PROMPT.md new file mode 100644 index 0000000..2b14267 --- /dev/null +++ b/PROMPT.md @@ -0,0 +1,14 @@ +写一个照片同步 Mod, 要求如下: + +1. 支持多版本(从 1.20 开始支持到现在最新的版本,目前是 26.2)和 fabric, 并且提供完善的文档描述兼容新版本的常规流程,注意事项。要有一个合理的模块层级把有必要跟mc代码纠缠在一起的部分隔离开,而无关的部分抽出来。允许兼容层的出现 +2. 支持把截图上传到 immich,并且还可以设置是哪个 album。记得留一个抽象,未来可能会加入新的 provider. +3. 要有 GUI 用于管理这些截图,并且 gui 也要覆盖模组的设置。模组的 UI/UX 和交互应该设计的易于使用,用户配置好后只需要管截图即可。GUI 还要能按照时间线排列浏览(模拟网页端虚拟列表懒加载)album 上其他时间的图片,单位是天。不要支持视频功能,如果视频有 preview 可用就用 preview 图片然后叠一层视频标记 +4. 要处理好用户退出游戏时截图仍在上传的情况 +5. 开发时,使用编译 classpath 里已有的 Gson,不要自己编写 Json 解析器,也不要自己编写其他已有库中的内容。然而,要注意控制模组的体积,无必要不要 shade 或者 jij 依赖。如果必须这么做,配置 proguard 做 minify. +6. 你不是在做 MVP, 你是在落地一个具体的产品,因此代码风格上不要到处随便飞 static 和常量以及各种 util。用可用的 Java 的特性合理的,恰当的组织你的代码,使他们符合面向对象和略微函数式一些的 Modern Java Paradiam,但注意不要过度抽象. 允许使用 Lombok 改进代码可读性。 +7. 要有一个可以配置的,默认关闭的自动截图功能,截图出来的图片需要有一个可配置的文件名后缀(如\_auto) +8. 自动截图/截图上传成功后可以在画面左下角提醒玩家(短短一行字停留一会即可,不要太影响体验) + +你的 gui 可能需要使用 mc screen 的那一套 gui, 最坏的情况你要为了所有版本都重新编写 screen 渲染逻辑。如果已经到了这个地步,那么请考虑 imgui. + +你可以先尝试做一些实验来研究可行性,然后问我有什么需要补充的信息。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..eb95057 --- /dev/null +++ b/README.md @@ -0,0 +1,201 @@ +# PhotoSync + +A Fabric client mod that sends your Minecraft screenshots to [Immich](https://immich.app) +and lets you browse the album back inside the game. + +Take a screenshot with F2 and it uploads in the background. Press F6 to see what +is uploading, scroll through the album's timeline, or change how any of it +behaves. Nothing about the vanilla screenshot flow changes — F2 still saves a PNG +to `screenshots/` and still works with the mod misconfigured or offline. + +Supports **Minecraft 1.20 through 26.2**, client side only. Requires +[Fabric API](https://modrinth.com/mod/fabric-api). + +--- + +## Install + +1. Install Fabric Loader 0.19.3 or newer and Fabric API for your Minecraft + version. +2. Drop the jar matching your version into `mods/`: + + | Your Minecraft version | Jar | + | --- | --- | + | 1.20 – 1.20.1 | `photosync-1.20.1-1.0.0.jar` | + | 1.20.2 – 1.20.4 | `photosync-1.20.4-1.0.0.jar` | + | 1.20.5 – 1.20.6 | `photosync-1.20.6-1.0.0.jar` | + | 1.21 – 1.21.1 | `photosync-1.21.1-1.0.0.jar` | + | 1.21.2 – 1.21.4 | `photosync-1.21.4-1.0.0.jar` | + | 1.21.5 | `photosync-1.21.5-1.0.0.jar` | + | 1.21.6 – 1.21.10 | `photosync-1.21.8-1.0.0.jar` | + | 1.21.11 – 1.21.x | `photosync-1.21.11-1.0.0.jar` | + | 26.1 – 26.2 | `photosync-26.2-1.0.0.jar` | + + Each jar declares its own supported range, so Fabric will refuse to load the + wrong one rather than crashing later. + +3. Launch the game. PhotoSync does nothing until you connect it to a server. + +The mod is client-side. Servers neither know nor care that you are running it, +and it works in single player. + +## Connect it to Immich + +Press **F6** (rebindable in Options → Controls → PhotoSync), open **Settings**, +and fill in two fields: + +- **Server URL** — `https://photos.example.com`. Typing just `photos.example.com` + works too; so does pasting a URL that already ends in `/api`. +- **API key** — Immich's web UI, under Account Settings → API Keys. The field is + masked, and the key is never written to the log or copied to the clipboard. + +Press **Test**. It reports the account name and server version on success, and +the actual failure on the way there — a wrong key and an unreachable host say +different things. + +Then pick an **Album**. "Whole library" uploads without adding to any album; the +picker also creates one if you type a name and press Create. The chosen album is +both where uploads go and what the Browse tab shows. + +Settings save when you close the screen. **Revert** undoes everything since you +opened it. + +## The screens + +**Uploads** is the queue: everything still in flight plus the last hundred that +finished, newest first, each with its state — queued, uploading, retrying, done, +failed — and a preview of the selected one. Failed uploads get a **Retry**; +anything at all gets **Show in folder**, which opens `screenshots/` in your file +manager. **Remove** takes an entry out of the list without touching the file, and +**Clear finished** empties the completed ones in one go. + +Uploads that fail retry on their own — five attempts by default, with the wait +doubling each time, so the last one comes 80 seconds after the fourth. Only the +final failure raises a corner message; the retries in between are visible here +for anyone who wants to watch them. + +**Browse** is the album, newest first, with a heading per day. It loads the way a +photo website does: the per-day counts arrive before any image does, so the +scrollbar is honest about the album's real size from the first frame, and the +thumbnails for a day are fetched only when you scroll near them. Click a photo to +open it larger, Escape to go back. It is read-only — PhotoSync will not delete, +move or favourite anything on your server. + +Videos in the album show their preview frame with a video badge in the corner. +There is no playback; this is a screenshot mod. + +**Settings** covers everything below. + +## Automatic screenshots + +Off by default, and it stays off until you turn it on. Once enabled, PhotoSync +takes a screenshot every five minutes (adjustable from 5 seconds to an hour) and +puts it through the same queue as a manual one. + +- **Name suffix** — appended before `.png`, `_auto` by default, so an automatic + capture lands as `2026-08-07_14.31.02_auto.png` and is easy to spot or filter. + Leave it blank for none. +- **Only while in a world** — on by default. No point photographing the main menu. +- **Skip while a screen is open** — on by default. Your inventory is not a + landscape. + +Automatic captures are recorded separately from manual ones, so the two are +distinguishable everywhere they appear. + +## Messages in the corner + +One line, bottom left, gone in three seconds. Separately switchable for +screenshot taken, upload finished, and upload failed, with an adjustable +duration — the setting that suits an automatic capture every thirty seconds is +not the one that suits two screenshots an hour. + +These draw on the in-world HUD, so they do not appear on the title screen or on +top of an open screen. An upload that finishes while you are looking at the +Uploads tab updates the list instead, which is better feedback than a toast. + +## Quitting with uploads still running + +If you quit while something is uploading, PhotoSync stops the shutdown and shows +a progress dialog: how many are left, and two buttons. **Keep playing** returns +you to the game. **Quit anyway** quits immediately. + +Either way nothing is lost. The queue is on disk and is written through on every +state change, so unfinished uploads resume the next time you play — the dialog +exists to save you the wait, not to prevent a loss. An upload that was mid-flight +when the game died is simply tried again, and Immich recognises the retry as a +duplicate rather than storing a second copy. + +You can turn the dialog off under Settings → Uploads → *Ask before quitting +mid-upload*, in which case the game gets a three-second grace period to finish +whatever is nearly done and then quits regardless. + +## Everything else in Settings + +| Setting | Default | | +| --- | --- | --- | +| Upload screenshots automatically | on | Off makes the mod a browser and a manual queue | +| Uploads at once | 2 | Up to 4 | +| Attempts before giving up | 5 | Up to 20 | +| Wait between attempts | 5s | Doubles each attempt: 5s, 10s, 20s, 40s | +| Delete the local file after upload | off | Only ever after the server confirms it | +| Thumbnail size | 96px | 48–192 | +| Thumbnails kept in memory | 256 | The mod's largest memory knob | +| Mark videos in the grid | on | | + +## Files it writes + +Everything lives in `config/photosync/` in your game directory: + +- `photosync.json` — settings, including your API key. Back it up accordingly. +- `uploads.json` — the upload queue, so it survives a crash or a quit. + +Screenshots themselves go where they always did, in `screenshots/`. PhotoSync +never moves them, and only deletes one if you asked it to and the upload was +confirmed. + +## Troubleshooting + +**"Not connected" on the Browse tab** — the server URL or API key is missing or +wrong. Settings → Test says which. + +**Screenshots are taken but nothing queues** — either *Upload screenshots +automatically* is off, or there are no credentials yet. PhotoSync deliberately +does not queue against a server it cannot reach, because the result is a list of +failures you cannot act on. + +**Thumbnails are blank in Browse** — Minecraft's image decoder reads PNG and JPEG +only, and Immich's default thumbnail format is WebP. PhotoSync detects this and +switches to JPEG previews for the rest of the session, so this should self-correct +after the first few tiles. + +**A jar refuses to load** — check the table above. Fabric enforces each jar's +declared version range, so this means the jar is for a different bucket. + +## Building from source + +```sh +./gradlew buildAllPlatforms +``` + +Jars land in `platform//build/libs/`. Nothing is shaded or bundled — +each jar is around 270 KB and contains only this mod's classes. To run one +version in a dev client: + +```sh +./gradlew :platform:1.21.11:runClient +``` + +The source tree is one shared implementation plus nine thin per-version +adapters. If you are adding a Minecraft version or wondering why the layout is +the way it is, [`docs/PORTING.md`](docs/PORTING.md) is the document for that: it +covers the bucket scheme, the measured API breakpoints between every supported +version, and the procedure for a new release. + +Adding a photo service other than Immich means implementing `PhotoProvider` and +`ProviderFactory` in `shared/core` and registering it with `ProviderCatalog`. No +UI or platform code needs to change — the settings screen builds its connection +fields from what the provider declares. + +## Licence + +MIT. diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..c362f2b --- /dev/null +++ b/build.gradle @@ -0,0 +1,207 @@ +plugins { + id 'java' + id 'fabric-loom' apply false +} + +/** + * Compatibility buckets, not Minecraft versions. + * + * Every project under :platform is compiled against one representative version + * and declares -- in its own gradle.properties -- the full range of Minecraft + * versions that the resulting jar actually runs on. The grouping comes from + * measured API breakpoints, documented in docs/PORTING.md. + */ +ext.platformProjects = { subprojects.findAll { it.path.startsWith(':platform:') } } +ext.sharedProjects = { subprojects.findAll { it.path.startsWith(':shared:') } } + +subprojects { + apply plugin: 'java' + + group = rootProject.mod_group + version = rootProject.mod_version + + repositories { + maven { name = 'Fabric'; url = 'https://maven.fabricmc.net/' } + mavenCentral() + } + + dependencies { + compileOnly "org.projectlombok:lombok:${rootProject.lombok_version}" + annotationProcessor "org.projectlombok:lombok:${rootProject.lombok_version}" + + testCompileOnly "org.projectlombok:lombok:${rootProject.lombok_version}" + testAnnotationProcessor "org.projectlombok:lombok:${rootProject.lombok_version}" + testImplementation platform("org.junit:junit-bom:${rootProject.junit_version}") + testImplementation 'org.junit.jupiter:junit-jupiter' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + } + + tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + } + + tasks.withType(Test).configureEach { + useJUnitPlatform() + } +} + +// --------------------------------------------------------------------------- +// Shared modules: zero Minecraft on the classpath. +// +// These compile to Java 17 bytecode because 1.20-1.20.4 run on a Java 17 JVM. +// That is a real constraint on the code, not just a flag: no virtual threads +// and no Java 21 pattern matching in shared code. +// --------------------------------------------------------------------------- +configure(sharedProjects()) { + // java-library so a module can re-export what its consumers legitimately + // need: :shared:ui hands RenderBridge types straight to platform code. + apply plugin: 'java-library' + + java { + toolchain { languageVersion = JavaLanguageVersion.of(21) } + } + tasks.withType(JavaCompile).configureEach { + options.release = rootProject.shared_java as int + } + + dependencies { + // Gson ships inside Minecraft's own dependency set on every supported + // version, so we compile against it but never bundle it. See + // docs/PORTING.md for the version range this has to stay within. + compileOnly "com.google.code.gson:gson:${rootProject.gson_api_version}" + compileOnly "org.slf4j:slf4j-api:${rootProject.slf4j_api_version}" + + testImplementation "com.google.code.gson:gson:${rootProject.gson_api_version}" + testImplementation "org.slf4j:slf4j-api:${rootProject.slf4j_api_version}" + testRuntimeOnly "org.slf4j:slf4j-simple:${rootProject.slf4j_api_version}" + } +} + +// --------------------------------------------------------------------------- +// Platform modules: the only code allowed to touch Minecraft types. +// --------------------------------------------------------------------------- +configure(platformProjects()) { + apply plugin: 'fabric-loom' + + def mcVersion = project.property('minecraft_version') + def mcJava = project.property('mc_java') as int + def deobfuscated = project.property('deobfuscated').toBoolean() + + // Minecraft 26.x ships already deobfuscated, so Loom has nothing to remap. + // It still insists on a mappings artifact carrying a 'named' namespace, so + // we hand it a header-only tiny-v2 file: three namespaces, zero rows, which + // Loom reads as "every name maps to itself". + // + // This is written during configuration rather than by a task because Loom + // resolves the mappings configuration while it configures the project -- + // a task output would not exist yet. It is 47 bytes and deterministic. + def identityMappings = null + if (deobfuscated) { + identityMappings = layout.buildDirectory + .file("identity-mappings/identity-mappings-${mcVersion}.jar").get().asFile + if (!identityMappings.exists()) { + identityMappings.parentFile.mkdirs() + new java.util.zip.ZipOutputStream(identityMappings.newOutputStream()).withCloseable { zip -> + zip.putNextEntry(new java.util.zip.ZipEntry('mappings/mappings.tiny')) + zip.write("tiny\t2\t0\tofficial\tintermediary\tnamed\n".getBytes('UTF-8')) + zip.closeEntry() + } + } + } + + java { + // javac can always read older bytecode, so one toolchain covers every + // bucket except 26.x, whose Minecraft classes are Java 25. + toolchain { languageVersion = JavaLanguageVersion.of(Math.max(mcJava, 21)) } + withSourcesJar() + } + + // The compatibility layer. platform/common holds the adapter code that is + // textually identical on all nine buckets -- the Fabric entrypoint, the + // ClientBridge assembly, and the two seams (I18n, KeyboardHandler) that have + // not moved since 1.20. It is a source root rather than a project because it + // imports net.minecraft, so it has to be compiled once per bucket against + // that bucket's Minecraft; there is no single jar it could produce. + // + // Everything it calls that *does* differ lives in dev.photosync.platform.impl, + // which each bucket supplies from its own src/main/java under the same + // package. Common code names those classes directly and the bucket's source + // root satisfies them -- so the invariant half is written once, and the half + // that churns is a per-bucket file the compiler checks. + sourceSets.main.java.srcDir rootProject.file('platform/common/src/main/java') + sourceSets.main.resources.srcDir rootProject.file('platform/common/src/main/resources') + + tasks.withType(JavaCompile).configureEach { + options.release = mcJava + } + + dependencies { + minecraft "com.mojang:minecraft:${mcVersion}" + + if (deobfuscated) { + mappings project.files(identityMappings) + } else { + mappings loom.officialMojangMappings() + } + + modImplementation "net.fabricmc:fabric-loader:${project.property('loader_version')}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.property('fabric_api_version')}" + + // Shared code is compiled once per bucket's bytecode target and folded + // into the platform jar below. No shading, no jar-in-jar: these are our + // own classes and they carry no third-party dependencies. + rootProject.sharedProjects().each { implementation it } + } + + loom { + runs { + client { + client() + ideConfigGenerated = true + } + } + } + + processResources { + def tokens = [ + mod_id : rootProject.mod_id, + mod_name : rootProject.mod_name, + mod_version : rootProject.mod_version, + mod_description : rootProject.mod_description, + mod_license : rootProject.mod_license, + mod_sources : rootProject.mod_sources, + minecraft_range : project.property('minecraft_range'), + loader_version : project.property('loader_version'), + java_version : String.valueOf(mcJava), + // Mixin refuses to apply a class whose bytecode is newer than the + // declared level, and platform classes are compiled at the + // bucket's own level -- so this has to follow mc_java. + mixin_compat : "JAVA_${mcJava}", + ] + inputs.properties(tokens) + filesMatching(['fabric.mod.json', '*.mixins.json']) { expand(tokens) } + } + + jar { + rootProject.sharedProjects().each { from it.sourceSets.main.output } + + manifest { + attributes( + 'Implementation-Title': rootProject.mod_name, + 'Implementation-Version': rootProject.mod_version, + 'Minecraft-Version': mcVersion, + 'Minecraft-Range': project.property('minecraft_range') + ) + } + } + + // Give every bucket's artifact a distinguishable name. + base.archivesName = "${rootProject.mod_id}-${mcVersion}" +} + +// Convenience: build every bucket's shippable jar in one go. +tasks.register('buildAllPlatforms') { + group = 'build' + description = 'Builds the remapped mod jar for every compatibility bucket.' + dependsOn platformProjects().collect { "${it.path}:build" } +} diff --git a/docs/PORTING.md b/docs/PORTING.md new file mode 100644 index 0000000..a637f5b --- /dev/null +++ b/docs/PORTING.md @@ -0,0 +1,495 @@ +# Porting PhotoSync to a new Minecraft version + +PhotoSync supports Minecraft 1.20 through 26.2 from one source tree. This +document explains how that is arranged, what actually changes between versions, +and what you have to do when a new one comes out. + +Everything in the breakpoint table below is *measured*, not remembered. It is +produced by `tools/probe-api.py`, which javaps the Minecraft jars Loom has +already downloaded and prints the signatures this mod depends on: + +```sh +./gradlew buildAllPlatforms # once, so Loom caches every jar +python3 tools/probe-api.py > /tmp/api.txt +``` + +Re-run it whenever you add a bucket. A member that vanishes from the output is +itself the finding — that is how the 26.x `GuiGraphics` removal surfaced. + +`probe-api.py` carries a fixed member list, so for anything not already in it +there is `tools/probe-class.py`, which takes classes and an optional filter and +javaps them across every cached bucket: + +```sh +python3 tools/probe-class.py net.minecraft.client.Screenshot takeScreenshot +python3 tools/probe-class.py net.minecraft.client.gui.GuiGraphics,net.minecraft.client.gui.Hud blit +``` + +--- + +## 1. Buckets, not versions + +There are 23 Minecraft releases in the supported range and nine `:platform` +projects. A **bucket** is a contiguous run of versions whose client API is +identical in every respect PhotoSync touches. We compile and test against one +representative version per bucket; the jar it produces runs on the whole range, +which each bucket declares in its own `gradle.properties`: + +| Bucket | Representative | `minecraft_range` | Java | Mappings | +| --- | --- | --- | --- | --- | +| 1 | 1.20.1 | `>=1.20 <1.20.2` | 17 | Mojang | +| 2 | 1.20.4 | `>=1.20.2 <1.20.5` | 17 | Mojang | +| 3 | 1.20.6 | `>=1.20.5 <1.21` | 21 | Mojang | +| 4 | 1.21.1 | `>=1.21 <1.21.2` | 21 | Mojang | +| 5 | 1.21.4 | `>=1.21.2 <1.21.5` | 21 | Mojang | +| 6 | 1.21.5 | `>=1.21.5 <1.21.6` | 21 | Mojang | +| 7 | 1.21.8 | `>=1.21.6 <1.21.11` | 21 | Mojang | +| 8 | 1.21.11 | `>=1.21.11 <1.22` | 21 | Mojang | +| 9 | 26.2 | `>=26.1 <26.3` | 25 | identity | + +The boundaries are not aesthetic. Each one is a signature in section 3 that +changed, and merging two buckets means one of those adapters stops compiling. + +## 2. Module layers + +``` +shared/core no Minecraft, no GUI. Providers, upload queue, config, + timeline paging, ThumbHash decoding. Unit-testable. +shared/mc-api the seam. Interfaces describing what the mod needs from the + game. No Minecraft imports -- it is the shape of the + dependency, not the dependency. +shared/ui every screen and widget, drawn through mc-api only. +shared/client wiring: builds the queue, providers and screens from config. + Owns the language file and the rest of assets/photosync. +platform/common the compat layer. Imports net.minecraft, but only members + that are identical on all nine buckets: the entrypoint, the + bridge assembly, Component.literal, the clipboard. +platform/ one bucket's adapters, and its mixins. Everything whose + signature moved lives here. +``` + +`platform/common` is a source root, not a project: the root build wires +`platform/common/src/main/{java,resources}` into every bucket's `sourceSets.main`. +It exists because roughly two thirds of the platform code does not vary, and +copying it nine times would mean nine places to fix a bug. The split rule is +mechanical — a class goes in `common` until the day it stops compiling on some +bucket, and then it moves down into all nine. + +The nine copies under `platform/` share fully-qualified names on purpose +(`dev.photosync.platform.impl.RenderAdapter` exists nine times). Only one is ever +on a classpath, `platform/common` can name them without indirection, and diffing +two buckets is a plain `diff -r`. + +`shared/*` compiles to **Java 17 bytecode** (`shared_java` in +`gradle.properties`), because buckets 1 and 2 run on a Java 17 JVM. That is a +constraint on the code, not just a compiler flag: no virtual threads, no Java 21 +pattern matching in shared code. The platform modules compile at their own +bucket's level, so 26.2's adapter may use Java 25 freely. + +Shared classes are folded into each platform jar by `jar { from ... }` in the +root build. There is no shading and no jar-in-jar — these are our own classes +and they carry no third-party dependencies. Gson and slf4j-api are `compileOnly` +because Minecraft and Fabric Loader supply both at runtime on every supported +version. + +### Why the seam is only eleven drawing calls + +The seam is deliberately narrow. `RenderBridge` exposes eleven primitives — +`fill`, `gradient`, `border`, `text`, `textWidth`, `lineHeight`, two `image` +overloads, `pushClip`/`popClip`, and the frame's `width`/`height`/`tickDelta`. +PhotoSync draws every button, scrollbar, text field and tile itself out of those. + +That is a real cost — we reimplement widgets Minecraft already has — and it buys +the thing that matters: **vanilla's widget classes churn far more than its +drawing primitives do.** `Button`'s constructor, `AbstractWidget`'s render +signature and the whole `Layout` package changed repeatedly across this range, +while `fill` and `enableScissor` did not change once in six years. Section 3 is +the evidence: nine versions, and the drawing primitives collapse to three +adapter shapes. + +This is also why the mod does **not** need imgui, which `PROMPT.md` raised as +the fallback if every version needed its own render logic. Every version does +need its own adapter, but each adapter is a few dozen lines of delegation, not a +GUI toolkit. + +A method belongs in `mc-api` only if its Minecraft implementation genuinely +differs across versions. Nothing there exposes a Minecraft concept under another +name. + +## 3. Measured API breakpoints + +`✓` means "same as the column to its left". + +### Drawing + +| | 1.20.1 | 1.20.4 | 1.20.6 | 1.21.1 | 1.21.4 | 1.21.5 | 1.21.8 | 1.21.11 | 26.2 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| host class | `GuiGraphics` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | **`GuiGraphicsExtractor`** | +| `fill` / `fillGradient` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| `enableScissor` / `disableScissor` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | +| text | `drawString(Font,String,int,int,int,boolean)` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | **`text(…)`**, same args | +| `blit` | pixel UVs + `ResourceLocation` | ✓ | ✓ | ✓ | **`Function` first arg, dest size moves** | ✓ | **`RenderPipeline` first arg** | `Identifier` rename | ✓ | + +Three adapter shapes, then. All of them use the overload that carries an +explicit source rectangle, since `RenderBridge.image` has to be able to draw part +of a texture: + +* **1.20.1 – 1.21.1** — + `blit(ResourceLocation, int x, int y, int w, int h, float u, float v, int uw, int vh, int texW, int texH)`. +* **1.21.4 – 1.21.5** — + `blit(Function, ResourceLocation, int x, int y, float u, float v, int w, int h, int uw, int vh, int texW, int texH)`. + Two things changed: a `Function` is prepended (`RenderType::guiTextured`), *and + the destination width/height moved from before the UV offsets to after them.* + The tail — `uw, vh, texW, texH` — is unchanged, which makes the swap easy to + miss when reading a diff. +* **1.21.8 – 26.2** — `RenderPipeline` replaces the function + (`RenderPipelines.GUI_TEXTURED`); argument order is otherwise the 1.21.4 one. + +UVs are texel coordinates in every version, so `TextureHandle`'s own dimensions +convert them from the normalised `u0..u1` the bridge takes. That conversion is +the same three lines on all nine adapters. + +The reorder is at least loud when you get it wrong: `u`/`v` are `float` and the +sizes are `int`, so passing the old order to the new method fails to compile +rather than drawing garbage. Do not "fix" that by casting. + +A normalised-UV overload `blit(Identifier, x, y, w, h, u0, v0, u1, v1)` appears +at 1.21.8 and would suit `RenderBridge.image` better on paper. The adapters +deliberately do not use it: its argument order differs again from the texel +form's, and buckets 1–5 have no equivalent, so adopting it would buy a shorter +method on four buckets in exchange for two spellings to keep straight. All nine +adapters stay on the explicit texel form. + +26.2 is the interesting one and it is less disruptive than it looks. `GuiGraphics` +is gone entirely, replaced by an extract/render-state pipeline: `Screen.render` +becomes `extractRenderState(GuiGraphicsExtractor, int, int, float)`. But +`GuiGraphicsExtractor` still exposes `fill`, `fillGradient`, `enableScissor`, +`disableScissor`, `text` and `blit` with recognisable signatures, so all eleven +primitives survive the transition. The adapter binds to the extractor instead of +the graphics object; nothing above the seam notices. + +### Screen + +| | 1.20.1 | 1.20.4 → 1.21.8 | 1.21.11 | 26.2 | +| --- | --- | --- | --- | --- | +| render entry | `render(GuiGraphics,int,int,float)` | ✓ | ✓ | **`extractRenderState(GuiGraphicsExtractor,…)`** | +| background | `renderBackground(GuiGraphics)` | **4 args** | ✓ | **`extractBackground(…)`** | +| `init` / `resize` | `(Minecraft,int,int)` | ✓ | **`(int,int)`** — no `Minecraft` | ✓ | +| `tick`, `removed`, `isPauseScreen`, `shouldCloseOnEsc` | ✓ | ✓ | ✓ | ✓ | + +### HUD — where notifications are drawn + +| | 1.20.1 → 1.20.6 | 1.21.1 → 1.21.11 | 26.2 | +| --- | --- | --- | --- | +| class | `Gui` | ✓ | **`Hud`** | +| hook | `render(GuiGraphics, float)` | **`render(GuiGraphics, DeltaTracker)`** | **`extractRenderState(GuiGraphicsExtractor, DeltaTracker)`** | +| partial tick | the `float` argument | `delta.getGameTimeDeltaPartialTick(false)` | ✓ | + +`false` asks for the real partial tick rather than the frozen-while-paused one, +so notifications keep animating while the game is paused behind a PhotoSync +screen. + +26.1 split `Gui` in two: the in-world overlay moved to `Hud`, and `Gui` kept an +outer `extractRenderState` that draws the current screen *after* the HUD. +Injecting into `Gui`'s would put notifications underneath our own GUI, so +`HudMixin` targets `Hud`. Grep for the class before assuming the method name is +the whole change. + +Because the injection point is the in-world HUD on every bucket, **notifications +are invisible on the title screen and inside our own screens.** That is by +design — an upload that finishes while the player is in the queue screen shows up +in the queue list, which is better feedback than a corner toast — but it does +mean a capture taken from a menu reports nothing until the player is back in a +world. + +### Input — `GuiEventListener` + +| | 1.20.1 | 1.20.4 → 1.21.8 | 1.21.11 → 26.2 | +| --- | --- | --- | --- | +| `mouseClicked` | `(double,double,int)` | ✓ | **`(MouseButtonEvent, boolean)`** | +| `mouseReleased` | `(double,double,int)` | ✓ | **`(MouseButtonEvent)`** | +| `mouseDragged` | `(double,double,int,double,double)` | ✓ | **`(MouseButtonEvent,double,double)`** | +| `mouseScrolled` | `(double,double,double)` | **4 args** | `(double,double,double,double)` | +| `keyPressed` | `(int,int,int)` | ✓ | **`(KeyEvent)`** | +| `charTyped` | `(char,int)` | ✓ | **`(CharacterEvent)`** | + +`ScreenModel` keeps the flat 1.20-era signatures because they carry every field +the event objects do; the 1.21.11+ adapters unpack. `mouseScrolled` gaining a +fourth argument at 1.20.4 is the *only* reason buckets 1 and 2 are separate — +that plus `renderBackground` above. + +Two details the table cannot hold: + +* `CharacterEvent` is `(int codepoint, int modifiers)` at 1.21.11 and + **`(int codepoint)`** at 26.2 — the modifiers were dropped. The 26.2 adapter + passes `0`, which costs nothing: nothing downstream reads them (`TextField` + filters on the character alone, and the shortcuts that care about Ctrl arrive + through `keyPressed`). +* The event carries a *code point*, not a `char`, so anything outside the basic + plane arrives as a surrogate pair. Both adapters loop over + `Character.toChars(...)` and deliver two `char`s — which is what a `String` + would have held anyway. + +### Textures + +| | 1.20.1 → 1.21.1 | 1.21.4 | 1.21.5 → 1.21.8 | 1.21.11 → 26.2 | +| --- | --- | --- | --- | --- | +| `DynamicTexture(NativeImage)` | ✓ | ✓ | **`(Supplier, NativeImage)`** | ✓ | +| `NativeImage.setPixelRGBA` | ✓ | **`setPixel`** | ✓ | ✓ | +| `TextureManager.register(String, DynamicTexture)` → id | ✓ | **removed** | — | — | +| `TextureManager.register(id, AbstractTexture)` | ✓ | ✓ | ✓ | ✓ | +| `TextureManager.release(id)` | ✓ | ✓ | ✓ | ✓ | +| id type | `ResourceLocation` | ✓ | ✓ | **`Identifier`** | + +Because the auto-naming `register(String, DynamicTexture)` overload disappears at +1.21.4 while `register(id, AbstractTexture)` exists everywhere, all nine adapters +mint their own id and use the two-argument form. One code shape, no breakpoint — +worth knowing so nobody "simplifies" the older buckets back onto the overload. + +The `setPixelRGBA` → `setPixel` rename at 1.21.4 is not just a rename: **the +channel order changed with it.** `setPixelRGBA` takes ABGR despite the name; +`setPixel` takes plain ARGB, which is what `ThumbImage` already holds. That is +why buckets 1–4 have a private `abgr()` swap in `TextureAdapter` and buckets 5–9 +do not. Copying an adapter across that boundary in either direction produces +images with red and blue exchanged — it renders, so nothing tells you but your +eyes. + +At 1.21.5 `DynamicTexture` gained a `Supplier` label, which means the id +has to be minted *before* the texture rather than after. The texture still takes +ownership of the `NativeImage` and closes it with itself; only the registration +needs releasing. + +### Key bindings + +| | 1.20.1 → 1.21.8 | 1.21.11 | 26.2 | +| --- | --- | --- | --- | +| category | translation key `String` | **`KeyMapping.Category.register(Identifier)`** | ✓ | +| Fabric module | `fabric-key-binding-api-v1` | ✓ | **`fabric-key-mapping-api-v1`** | +| Fabric helper | `KeyBindingHelper.registerKeyBinding` | ✓ | **`KeyMappingHelper.registerKeyMapping`** | + +The 1.21.11 category derives its own label: `photosync:main` becomes +`key.category.photosync.main`. The older buckets use whatever string you hand +them, which here is `key.categories.photosync`. `en_us.json` carries **both** +keys — they are two lines, and the alternative is a missing-translation string in +the controls screen on half the buckets. + +### Screenshots + +| | 1.20.1 → 1.21.4 | 1.21.5 | 1.21.8 → 26.2 | +| --- | --- | --- | --- | +| `Screenshot._grab(File,String,RenderTarget,Consumer)` | ✓ | **removed** | — | +| `Screenshot.takeScreenshot(RenderTarget)` → `NativeImage` | ✓ | **`(RenderTarget, Consumer)`** | ✓ | +| `Screenshot.grab(File,String,RenderTarget,…,Consumer)` | ✓ | ✓ | **extra `int` (downscale)** | +| `NativeImage.writeToFile(File)` / `(Path)` | ✓ | ✓ | ✓ | + +26.2 additionally gains `grab(Minecraft, boolean)`. `Minecraft.getMainRenderTarget()` +also moved at 26.1 — the target now hangs off `gameRenderer.mainRenderTarget()`. + +`writeToFile` is the anchor: it is the one member in this entire table that is +byte-for-byte identical on all nine versions. The capture mixin therefore +redirects `NativeImage.writeToFile` rather than trying to intercept `grab`, whose +shape changes three times. + +The mixin declares `method = "*"` deliberately. On **every** bucket the call site +is a private static helper rather than `grab` itself, and the helper is named +after nothing: + +``` +private static void (NativeImage, File, Consumer) + 1.20.1 – 1.21.4 method_1661 + 1.21.5 – 1.21.11 method_22691 + 26.2 lambda$grab$3 +``` + +Those names are unspellable in a Mojmap bucket — Mojang's mappings do not name +synthetics, so the intermediary name is what survives, and it changes. Matching +every method in the class and letting the redirect's target descriptor pick the +call site is the only formulation that survives all nine. + +Two facts make redirecting the write safe rather than merely convenient: + +* `NativeImage.writeToFile(File)` *delegates* to `writeToFile(Path)` on every + bucket (checked in bytecode, not assumed), so redirecting the `File` overload + catches vanilla's F2 exactly once and never fires for the mod's own + `writeToFile(Path)` calls. Redirect the `Path` overload instead and every + automatic capture publishes twice. +* The exact `Path` is known synchronously, at the moment the file exists. No + directory watching, no guessing at the filename, no race with the player + taking a second screenshot. + +Automatic captures never go through this path. They are taken by +`ScreenshotService`, which builds its own filename (that is where the +configurable `_auto` suffix comes from) and publishes to `ScreenshotBus` itself, +so the mixin only ever reports vanilla F2 presses and reports them as `MANUAL`. +The origin of a capture is known by construction rather than by a thread-local +flag. + +`CaptureAdapter` does not call `Screenshot.grab` for the same reason: `grab` +names the file itself and writes a chat message, and since we intercept its write +to notice F2, routing automatic captures through it would make the two origins +indistinguishable. It grabs the frame and writes the PNG itself — on the render +thread for the readback, on `Util.ioPool()` for the encode. + +That readback turned **asynchronous at 1.21.5**: `takeScreenshot` no longer +returns the frame, it hands it to a callback once the GPU fence clears, possibly +several frames later. The filename is therefore reserved *before* the grab on +those buckets, so captures land in the order they were requested. Reservation is +a `synchronized` `Files.createFile` — claiming the name by creating the file +empty, not by testing for absence — because two captures a second apart would +otherwise agree on a name and one would overwrite the other. A failed write +deletes the placeholder. + +### Client + +| | 1.20.1 → 1.21.1 | 1.21.4 → 1.21.8 | 1.21.11 | 26.2 | +| --- | --- | --- | --- | --- | +| current screen | `Minecraft.screen` field | ✓ | ✓ | **`Minecraft.gui.screen()`** | +| set screen | `Minecraft.setScreen` | ✓ | ✓ | **`Minecraft.gui.setScreen`** | +| render target | `Minecraft.getMainRenderTarget()` | ✓ | ✓ | **`gameRenderer.mainRenderTarget()`** | +| `Minecraft.stop()`, `getWindow()` | ✓ | ✓ | ✓ | ✓ | +| `execute(Runnable)`, `isSameThread()` | inherited from `BlockableEventLoop`, unchanged | ✓ | ✓ | ✓ | +| `Util` package | `net.minecraft.Util` | ✓ | **`net.minecraft.util.Util`** | ✓ | +| `Util.ioPool()` | `ExecutorService` | **`TracingExecutor`** | ✓ | ✓ | + +26.1 moved screen ownership off `Minecraft` and onto `Gui`, which holds it now. +`Minecraft.setScreenAndShow` still exists and would also work, but it forces a +synchronous frame on top of the switch; `Gui.setScreen` is where the plain setter +went, and it is what `ScreenAdapter` uses. + +`Util.ioPool()` returning `TracingExecutor` rather than `ExecutorService` from +1.21.4 is invisible at the call site — both have `execute(Runnable)` — but it is +a hard break if you ever assign the result to a typed local. Don't; call it +inline. + +## 4. Mappings: Mojang for 1.20–1.21, identity for 26.x + +Buckets 1–8 use `loom.officialMojangMappings()`. Minecraft 26.x ships already +deobfuscated, so there is nothing to remap — but Loom still requires a mappings +artifact that declares a `named` namespace. The root build writes a header-only +tiny-v2 jar for those buckets: + +``` +tiny 2 0 official intermediary named +``` + +Three namespaces, zero rows, which Loom reads as "every name maps to itself". It +is generated during configuration rather than by a task because Loom resolves +the mappings configuration while configuring the project, so a task output would +not exist yet. It is deterministic and 47 bytes; `deobfuscated=true` in a +bucket's `gradle.properties` turns it on. + +Consequence when reading code: in a 26.x adapter the names you see *are* the +runtime names. In a 1.20–1.21 adapter they are Mojmap names that Loom remaps on +build. + +## 5. Adding a new Minecraft version + +Most releases need no work at all — if the new version falls inside an existing +bucket's `minecraft_range`, widen the range and ship. Do that check first. + +When it does not: + +1. **Probe before writing anything.** + + ```sh + ./gradlew :platform::build + python3 tools/probe-api.py > /tmp/api.txt + ``` + + Add the new version to a scratch platform project first so Loom caches its + jar, then diff its section against the nearest existing bucket. If nothing in + section 3 moved, you do not need a new bucket. + +2. **Copy the nearest bucket.** `cp -r platform/26.2 platform/`, then edit + its `gradle.properties`: `minecraft_version`, `minecraft_range`, `mc_java`, + `deobfuscated`, `loader_version`, `fabric_api_version`. Add + `include 'platform:'` to `settings.gradle`, and narrow the *previous* + bucket's upper bound so the two ranges do not overlap. + + Copy the newest bucket, not the nearest by number — a new release is almost + always a continuation of the most recent one, and starting from an older + bucket means re-doing every change made since. + +3. **Fix what the compiler complains about.** This is the whole point of the + layering: the errors are confined to `platform/` and they are the diff + in section 3. Nothing in `shared/` should need to change. If it does, that is + a signal the seam is leaking a Minecraft concept and is worth pushing back on + before working around. + + If a class in `platform/common` is among the failures, it has stopped being + common: move it down into **all** buckets (`for v in platform/*/; do ...`), + then change the one copy that needed changing. Do not add version checks to + `common` — that is the thing the bucket layout exists to avoid. + + `tools/probe-class.py [regex]` javaps one class across + every cached bucket jar at once, which is usually faster than reading release + notes. For argument *order* questions the notes are useless anyway; javap the + jar under `~/.gradle/caches/fabric-loom/minecraftMaven/`. + +4. **Update this document.** Add a column to the affected tables in section 3 + and a row to the bucket table in section 1. The tables are the reason step 1 + is cheap next time. If a new API needs a translation key that older buckets + do not (as `KeyMapping.Category` did at 1.21.11), add it to + `shared/client/src/main/resources/assets/photosync/lang/en_us.json` alongside + the old one rather than replacing it — the file is shared by all nine jars. + +5. **Run it.** `./gradlew :platform::runClient`, then walk the checklist + below. + +### Manual checklist + +The seam is not unit-testable — its whole job is to talk to the game — so these +are checked by hand once per bucket: + +- [ ] Screens open, close on Escape, and survive a window resize +- [ ] Mouse wheel scrolls the timeline; drag scrolls the scrollbar +- [ ] Text field accepts typing, and **paste works** (an Immich API key is not + typed by hand) +- [ ] Thumbnails decode and draw, and are released when the screen closes — + watch for a texture leak across repeated opens +- [ ] Clipping: the timeline's tiles do not draw outside their viewport +- [ ] F2 produces a queue entry with the correct path +- [ ] Automatic capture produces a file with the configured suffix +- [ ] Quitting with an upload in flight shows the progress dialog, and + "quit anyway" actually quits + +## 6. Things that will bite you + +**`lombok.config` changes need a clean build.** Gradle does not track it as a +compile input, so changing it leaves stale generated accessors on the classpath +and produces "cannot find symbol" errors on methods you can see in the source. +`./gradlew :shared:core:clean` first. + +**Compile against the oldest Gson, not the newest.** `gson_api_version=2.10` is +what 1.20.1 bundles; 26.2 has 2.14. Compiling against 2.14 would let code +reference API that is missing at runtime on older buckets, and the failure would +appear only in the field. Same reasoning for `slf4j_api_version=2.0.1`. + +**Minecraft's image decoder is stb_image, which reads PNG and JPEG only** — not +WebP, not AVIF. Immich's default thumbnail format is WebP, so `ImmichProvider` +sniffs the first thumbnail's magic bytes and falls back to previews (JPEG) for +the rest of the session if it sees a RIFF/WEBP header. Any new provider has to +honour the same contract, which `PhotoProvider.thumbnail` states explicitly. + +**Everything touching a texture, screen or framebuffer must go through +`GameContext.submit`.** Uploads, thumbnail fetches and the capture timer all run +on worker threads; the render thread is the only one allowed to touch GPU state. + +**Mixin annotations are remapped in place; there is no refmap.** Loom's +non-legacy path rewrites `@Mixin`/`@Inject` targets into intermediary with +tiny-remapper, so no `*-refmap.json` is produced and none should be configured. +If you want to confirm a mixin actually resolved, unzip the built jar and +`javap -p -v` the mixin class — the annotation holds the remapped target +(`method_1592` on a Mojmap bucket, the plain name on 26.x). A mixin that silently +does nothing usually has a target that remapped to something that no longer +exists, and this is the only place that shows. + +**`@Redirect` on an overloaded method needs the delegation checked, not +assumed.** The `writeToFile(File)` → `writeToFile(Path)` delegation above is the +example: get it backwards and the mod publishes every automatic capture twice, +with no error anywhere. + +**26.x buckets are already deobfuscated, so `runClient` output names differ.** A +stack trace from bucket 9 reads like source; one from buckets 1–8 reads like +Mojmap only because Loom mapped it on the way in. Do not paste a 26.x trace into +a search for an older bucket's symbol. diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ee3d547 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,34 @@ +org.gradle.jvmargs=-Xmx3G +org.gradle.parallel=true +org.gradle.caching=true + +# Loom does not support the configuration cache yet. +org.gradle.configuration-cache=false + +mod_id=photosync +mod_name=PhotoSync +mod_version=1.0.0 +mod_group=dev.photosync +mod_description=Sync your Minecraft screenshots to Immich, and browse your albums in game. +mod_license=MIT +mod_sources=https://github.com/example/photosync + +# Single Loom version for every bucket. This works for the deobfuscated 26.x +# jars too, via the generated identity mappings -- see buildSrc. +loom_version=1.17.19 + +# Lowest bytecode level shared code must be readable at (1.20-1.20.4 run on 17). +shared_java=17 + +lombok_version=1.18.46 +junit_version=5.11.4 + +# Compile-time only. Minecraft bundles Gson itself: 2.10 on 1.20.1 through +# 2.14 on 26.2. We compile against the OLDEST of those so we cannot +# accidentally reach for API that is missing on an older target at runtime. +gson_api_version=2.10 + +# Also compile-time only. Fabric Loader puts slf4j-api on the runtime classpath +# on every supported version (2.0.1 on 1.20.1 through 2.0.17 on 26.2). +slf4j_api_version=2.0.1 + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..eddabd2 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..69dd0d0 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.0-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..249efbb --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..8508ef6 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..ecbe98c --- /dev/null +++ b/lombok.config @@ -0,0 +1,7 @@ +# Lombok's generated accessors follow the record style the rest of the codebase +# uses -- `job.state()`, not `job.getState()` -- so that a value being a record +# or a class is an implementation detail rather than something call sites have +# to know about. +config.stopBubbling = true +lombok.accessors.fluent = true +lombok.accessors.chain = false diff --git a/platform/1.20.1/build.gradle b/platform/1.20.1/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.20.1/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.20.1/gradle.properties b/platform/1.20.1/gradle.properties new file mode 100644 index 0000000..58d4649 --- /dev/null +++ b/platform/1.20.1/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.20, 1.20.1 +# +# Baseline. GuiGraphics immediate mode, ToastComponent, synchronous Screenshot.grab. +minecraft_version=1.20.1 +minecraft_range=>=1.20 <1.20.2 +mc_java=17 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.92.11+1.20.1 diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..686f20e --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,107 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + Runnable grab = () -> { + try { + NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget()); + Path file = reserveFile(fileNameSuffix); + Util.ioPool().execute(() -> write(frame, file, written)); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..5fee587 --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,90 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double amount) { + return model.mouseScrolled(mouseX, mouseY, amount) || super.mouseScrolled(mouseX, mouseY, amount); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..8e588cb --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,114 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * This release's {@code blit} takes UVs in texels, not the 0..1 the bridge + * speaks, so the handle's own dimensions do the conversion. Later releases + * grew a normalised overload; see docs/PORTING.md. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + handle.id(), + x, y, width, height, + u0 * textureWidth, v0 * textureHeight, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..1919a15 --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,100 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + pixels.setPixelRGBA(x, y, abgr(argb[row + x])); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // DynamicTexture takes ownership of the image and closes it with itself, + // so the only thing left to free is the registration. + DynamicTexture texture = new DynamicTexture(pixels); + ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet()); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** + * NativeImage stores RGBA in memory order, so the int it wants back is + * 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else + * in this mod speaks. + */ + private static int abgr(int argb) { + return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..2a591ad --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,30 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick))); + } +} diff --git a/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.20.1/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.20.4/build.gradle b/platform/1.20.4/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.20.4/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.20.4/gradle.properties b/platform/1.20.4/gradle.properties new file mode 100644 index 0000000..ce1243f --- /dev/null +++ b/platform/1.20.4/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.20.2, 1.20.3, 1.20.4 +# +# 1.20.2 reworked GuiGraphics (+8/-5 methods) and added the blitSprite family. +minecraft_version=1.20.4 +minecraft_range=>=1.20.2 <1.20.5 +mc_java=17 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.97.3+1.20.4 diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..686f20e --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,107 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + Runnable grab = () -> { + try { + NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget()); + Path file = reserveFile(fileNameSuffix); + Util.ioPool().execute(() -> write(frame, file, written)); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..ca44055 --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,96 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..8e588cb --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,114 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * This release's {@code blit} takes UVs in texels, not the 0..1 the bridge + * speaks, so the handle's own dimensions do the conversion. Later releases + * grew a normalised overload; see docs/PORTING.md. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + handle.id(), + x, y, width, height, + u0 * textureWidth, v0 * textureHeight, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..1919a15 --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,100 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + pixels.setPixelRGBA(x, y, abgr(argb[row + x])); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // DynamicTexture takes ownership of the image and closes it with itself, + // so the only thing left to free is the registration. + DynamicTexture texture = new DynamicTexture(pixels); + ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet()); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** + * NativeImage stores RGBA in memory order, so the int it wants back is + * 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else + * in this mod speaks. + */ + private static int abgr(int argb) { + return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..2a591ad --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,30 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick))); + } +} diff --git a/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.20.4/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.20.6/build.gradle b/platform/1.20.6/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.20.6/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.20.6/gradle.properties b/platform/1.20.6/gradle.properties new file mode 100644 index 0000000..12342eb --- /dev/null +++ b/platform/1.20.6/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.20.5, 1.20.6 +# +# 1.20.5 raised the runtime to Java 21 and changed NativeImage and ResourceLocation. +minecraft_version=1.20.6 +minecraft_range=>=1.20.5 <1.21 +mc_java=21 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.100.8+1.20.6 diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..686f20e --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,107 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + Runnable grab = () -> { + try { + NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget()); + Path file = reserveFile(fileNameSuffix); + Util.ioPool().execute(() -> write(frame, file, written)); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..ca44055 --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,96 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..8e588cb --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,114 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * This release's {@code blit} takes UVs in texels, not the 0..1 the bridge + * speaks, so the handle's own dimensions do the conversion. Later releases + * grew a normalised overload; see docs/PORTING.md. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + handle.id(), + x, y, width, height, + u0 * textureWidth, v0 * textureHeight, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..1919a15 --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,100 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + pixels.setPixelRGBA(x, y, abgr(argb[row + x])); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // DynamicTexture takes ownership of the image and closes it with itself, + // so the only thing left to free is the registration. + DynamicTexture texture = new DynamicTexture(pixels); + ResourceLocation id = new ResourceLocation("photosync", "thumb/" + sequence.incrementAndGet()); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** + * NativeImage stores RGBA in memory order, so the int it wants back is + * 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else + * in this mod speaks. + */ + private static int abgr(int argb) { + return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..2a591ad --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,30 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, float partialTick, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, window.getGuiScaledWidth(), window.getGuiScaledHeight(), partialTick))); + } +} diff --git a/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.20.6/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.21.1/build.gradle b/platform/1.21.1/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.21.1/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.21.1/gradle.properties b/platform/1.21.1/gradle.properties new file mode 100644 index 0000000..a8603ac --- /dev/null +++ b/platform/1.21.1/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.21, 1.21.1 +# +# 1.21 reshuffled ResourceLocation construction (+6/-6) and Screen (+3/-5). +minecraft_version=1.21.1 +minecraft_range=>=1.21 <1.21.2 +mc_java=21 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.116.15+1.21.1 diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..686f20e --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,107 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + Runnable grab = () -> { + try { + NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget()); + Path file = reserveFile(fileNameSuffix); + Util.ioPool().execute(() -> write(frame, file, written)); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..ca44055 --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,96 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..8e588cb --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,114 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * This release's {@code blit} takes UVs in texels, not the 0..1 the bridge + * speaks, so the handle's own dimensions do the conversion. Later releases + * grew a normalised overload; see docs/PORTING.md. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + handle.id(), + x, y, width, height, + u0 * textureWidth, v0 * textureHeight, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..01647be --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,101 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + pixels.setPixelRGBA(x, y, abgr(argb[row + x])); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // DynamicTexture takes ownership of the image and closes it with itself, + // so the only thing left to free is the registration. + DynamicTexture texture = new DynamicTexture(pixels); + ResourceLocation id = ResourceLocation.fromNamespaceAndPath( + "photosync", "thumb/" + sequence.incrementAndGet()); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** + * NativeImage stores RGBA in memory order, so the int it wants back is + * 0xAABBGGRR -- red and blue swapped relative to the ARGB everything else + * in this mod speaks. + */ + private static int abgr(int argb) { + return (argb & 0xFF00FF00) | ((argb >> 16) & 0xFF) | ((argb & 0xFF) << 16); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..cf4539e --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,39 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + * + *

1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The + * bridge still wants the float, and {@code false} asks for the real one rather + * than the frozen-while-paused one, so notifications keep animating while the + * game is paused behind our own screen. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, + window.getGuiScaledWidth(), + window.getGuiScaledHeight(), + delta.getGameTimeDeltaPartialTick(false)))); + } +} diff --git a/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.21.1/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.21.11/build.gradle b/platform/1.21.11/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.21.11/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.21.11/gradle.properties b/platform/1.21.11/gradle.properties new file mode 100644 index 0000000..ccfeb7a --- /dev/null +++ b/platform/1.21.11/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.21.11 +# +# 1.21.11 renamed ResourceLocation to Identifier and churned GuiGraphics +37/-27. +minecraft_version=1.21.11 +minecraft_range=>=1.21.11 <1.22 +mc_java=21 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.141.6+1.21.11 diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..75a4ac6 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,112 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.util.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + // + // 1.21.5 turned the readback asynchronous: takeScreenshot no longer + // returns the frame, it hands it to a callback once the GPU fence + // clears, possibly frames later. The name is still claimed up front so + // that captures land in the order they were asked for. + Runnable grab = () -> { + try { + Path file = reserveFile(fileNameSuffix); + Screenshot.takeScreenshot(game.getMainRenderTarget(), + frame -> Util.ioPool().execute(() -> write(frame, file, written))); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..032baca --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.util.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..22d2e52 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,119 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + /** + * 1.21.11 bundled the loose input arguments into records. The bridge keeps + * the loose form -- it is the shape eight of the nine buckets speak -- so + * this is where they get unpacked. + * + *

{@code doubleClick} is dropped: PhotoSync has no double-click gesture, + * and the second click of a pair arrives here as an ordinary one anyway. + */ + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + return model.mouseClicked(event.x(), event.y(), event.button()) + || super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + return model.mouseReleased(event.x(), event.y(), event.button()) + || super.mouseReleased(event); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + return model.mouseDragged(event.x(), event.y(), event.button(), deltaX, deltaY) + || super.mouseDragged(event, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(KeyEvent event) { + return model.keyPressed(event.key(), event.scancode(), event.modifiers()) + || super.keyPressed(event); + } + + /** + * The event carries a code point rather than a char, so anything outside the + * basic plane arrives as a surrogate pair and reaches the model as two + * chars -- which is what a {@code String} would have held anyway. + */ + @Override + public boolean charTyped(CharacterEvent event) { + boolean handled = false; + for (char character : Character.toChars(event.codepoint())) { + handled |= model.charTyped(character, event.modifiers()); + } + return handled || super.charTyped(event); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..96f0106 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,45 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** + * Registers the binding. Called once, from the client entrypoint. + * + *

1.21.11 turned the category from a loose translation key into a + * registered id, and it derives its own label: {@code photosync:main} + * becomes {@code key.category.photosync.main}, which is why the language + * file carries that key alongside the older {@code key.categories.photosync}. + */ + public static OpenKey register() { + KeyMapping.Category category = + KeyMapping.Category.register(Identifier.fromNamespaceAndPath("photosync", "main")); + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + category))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..a1ac352 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,122 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderPipelines; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * 1.21.2 moved the destination size ahead of the source rectangle, and 1.21.6 + * swapped the render-type lookup in front of it for a baked pipeline. The UVs + * are still texels, so the handle's own dimensions still do the conversion. + * + *

There is a normalised-UV overload as of 1.21.6 that would suit the + * bridge better on paper, but its inner argument order differs from the + * texel form's in ways that are easy to get subtly wrong; staying on the + * explicit form keeps this method identical to the four buckets below. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + RenderPipelines.GUI_TEXTURED, + handle.id(), + x, y, + u0 * textureWidth, v0 * textureHeight, + width, height, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..a67f0bc --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,95 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + // 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB + // rather than memory-order bytes, which is what we already have. + pixels.setPixel(x, y, argb[row + x]); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // 1.21.5 gave every GPU texture a debug label, so the id has to exist + // before the texture does. DynamicTexture still takes ownership of the + // image and closes it with itself; only the registration needs freeing. + Identifier id = Identifier.fromNamespaceAndPath( + "photosync", "thumb/" + sequence.incrementAndGet()); + DynamicTexture texture = new DynamicTexture(id::toString, pixels); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final Identifier id; + + private final int width; + private final int height; + private boolean released; + + private Handle(Identifier id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..cf4539e --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,39 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + * + *

1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The + * bridge still wants the float, and {@code false} asks for the real one rather + * than the frozen-while-paused one, so notifications keep animating while the + * game is paused behind our own screen. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, + window.getGuiScaledWidth(), + window.getGuiScaledHeight(), + delta.getGameTimeDeltaPartialTick(false)))); + } +} diff --git a/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.21.11/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.21.4/build.gradle b/platform/1.21.4/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.21.4/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.21.4/gradle.properties b/platform/1.21.4/gradle.properties new file mode 100644 index 0000000..c5c621a --- /dev/null +++ b/platform/1.21.4/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.21.2, 1.21.3, 1.21.4 +# +# 1.21.2 replaced ToastComponent with ToastManager and reworked TextureManager. +minecraft_version=1.21.4 +minecraft_range=>=1.21.2 <1.21.5 +mc_java=21 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.119.4+1.21.4 diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..686f20e --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,107 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + Runnable grab = () -> { + try { + NativeImage frame = Screenshot.takeScreenshot(game.getMainRenderTarget()); + Path file = reserveFile(fileNameSuffix); + Util.ioPool().execute(() -> write(frame, file, written)); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..ca44055 --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,96 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..c38fad9 --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,117 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderType; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * 1.21.2 moved the destination size ahead of the source rectangle and put a + * render-type lookup in front of everything, but the UVs are still texels, + * so the handle's own dimensions still do the conversion. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + RenderType::guiTextured, + handle.id(), + x, y, + u0 * textureWidth, v0 * textureHeight, + width, height, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..447cf88 --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,94 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + // 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB + // rather than memory-order bytes, which is what we already have. + pixels.setPixel(x, y, argb[row + x]); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // DynamicTexture takes ownership of the image and closes it with itself, + // so the only thing left to free is the registration. + DynamicTexture texture = new DynamicTexture(pixels); + ResourceLocation id = ResourceLocation.fromNamespaceAndPath( + "photosync", "thumb/" + sequence.incrementAndGet()); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..cf4539e --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,39 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + * + *

1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The + * bridge still wants the float, and {@code false} asks for the real one rather + * than the frozen-while-paused one, so notifications keep animating while the + * game is paused behind our own screen. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, + window.getGuiScaledWidth(), + window.getGuiScaledHeight(), + delta.getGameTimeDeltaPartialTick(false)))); + } +} diff --git a/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.21.4/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.21.5/build.gradle b/platform/1.21.5/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.21.5/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.21.5/gradle.properties b/platform/1.21.5/gradle.properties new file mode 100644 index 0000000..c1cf0d2 --- /dev/null +++ b/platform/1.21.5/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.21.5 +# +# 1.21.5 made Screenshot asynchronous (Consumer-based) and cut NativeImage by 9 methods. +minecraft_version=1.21.5 +minecraft_range=>=1.21.5 <1.21.6 +mc_java=21 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.128.2+1.21.5 diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..dad964e --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,112 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + // + // 1.21.5 turned the readback asynchronous: takeScreenshot no longer + // returns the frame, it hands it to a callback once the GPU fence + // clears, possibly frames later. The name is still claimed up front so + // that captures land in the order they were asked for. + Runnable grab = () -> { + try { + Path file = reserveFile(fileNameSuffix); + Screenshot.takeScreenshot(game.getMainRenderTarget(), + frame -> Util.ioPool().execute(() -> write(frame, file, written))); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..ca44055 --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,96 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..c38fad9 --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,117 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderType; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * 1.21.2 moved the destination size ahead of the source rectangle and put a + * render-type lookup in front of everything, but the UVs are still texels, + * so the handle's own dimensions still do the conversion. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + RenderType::guiTextured, + handle.id(), + x, y, + u0 * textureWidth, v0 * textureHeight, + width, height, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..bb77a36 --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,95 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + // 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB + // rather than memory-order bytes, which is what we already have. + pixels.setPixel(x, y, argb[row + x]); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // 1.21.5 gave every GPU texture a debug label, so the id has to exist + // before the texture does. DynamicTexture still takes ownership of the + // image and closes it with itself; only the registration needs freeing. + ResourceLocation id = ResourceLocation.fromNamespaceAndPath( + "photosync", "thumb/" + sequence.incrementAndGet()); + DynamicTexture texture = new DynamicTexture(id::toString, pixels); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..cf4539e --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,39 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + * + *

1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The + * bridge still wants the float, and {@code false} asks for the real one rather + * than the frozen-while-paused one, so notifications keep animating while the + * game is paused behind our own screen. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, + window.getGuiScaledWidth(), + window.getGuiScaledHeight(), + delta.getGameTimeDeltaPartialTick(false)))); + } +} diff --git a/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.21.5/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/1.21.8/build.gradle b/platform/1.21.8/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/1.21.8/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/1.21.8/gradle.properties b/platform/1.21.8/gradle.properties new file mode 100644 index 0000000..a1ea948 --- /dev/null +++ b/platform/1.21.8/gradle.properties @@ -0,0 +1,10 @@ +# Compatibility bucket: 1.21.6 through 1.21.10 +# +# 1.21.6 landed the GPU pipeline rewrite: GuiGraphics churned +57/-50 methods. +minecraft_version=1.21.8 +minecraft_range=>=1.21.6 <1.21.11 +mc_java=21 +deobfuscated=false + +loader_version=0.19.3 +fabric_api_version=0.136.1+1.21.8 diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..dad964e --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,112 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + // + // 1.21.5 turned the readback asynchronous: takeScreenshot no longer + // returns the frame, it hands it to a callback once the GPU fence + // clears, possibly frames later. The name is still claimed up front so + // that captures land in the order they were asked for. + Runnable grab = () -> { + try { + Path file = reserveFile(fileNameSuffix); + Screenshot.takeScreenshot(game.getMainRenderTarget(), + frame -> Util.ioPool().execute(() -> write(frame, file, written))); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..474ed4c --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,78 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + @Override + public boolean screenOpen() { + return Minecraft.getInstance().screen != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..ca44055 --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,96 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's {@code renderBackground} is not called: + * the model draws its own scrim and panel, and vanilla's would paint over the + * world underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + @Override + public void render(GuiGraphics graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + return model.mouseClicked(mouseX, mouseY, button) || super.mouseClicked(mouseX, mouseY, button); + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + return model.mouseReleased(mouseX, mouseY, button) || super.mouseReleased(mouseX, mouseY, button); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) { + return model.mouseDragged(mouseX, mouseY, button, deltaX, deltaY) + || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(int key, int scanCode, int modifiers) { + return model.keyPressed(key, scanCode, modifiers) || super.keyPressed(key, scanCode, modifiers); + } + + @Override + public boolean charTyped(char character, int modifiers) { + return model.charTyped(character, modifiers) || super.charTyped(character, modifiers); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..6a7ad4d --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,35 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.minecraft.client.KeyMapping; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** Registers the binding. Called once, from the client entrypoint. */ + public static OpenKey register() { + return new OpenKey(KeyBindingHelper.registerKeyBinding(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + "key.categories.photosync"))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..a1ac352 --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,122 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphics; +import net.minecraft.client.renderer.RenderPipelines; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphics}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * {@code GuiGraphics}, which is not valid outside the render call that produced + * it. Coordinates are GUI-space, matching vanilla's, so nothing here scales. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphics graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphics graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.drawString(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * 1.21.2 moved the destination size ahead of the source rectangle, and 1.21.6 + * swapped the render-type lookup in front of it for a baked pipeline. The UVs + * are still texels, so the handle's own dimensions still do the conversion. + * + *

There is a normalised-UV overload as of 1.21.6 that would suit the + * bridge better on paper, but its inner argument order differs from the + * texel form's in ways that are easy to get subtly wrong; staying on the + * explicit form keeps this method identical to the four buckets below. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + RenderPipelines.GUI_TEXTURED, + handle.id(), + x, y, + u0 * textureWidth, v0 * textureHeight, + width, height, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..cb6d5d5 --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,34 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** Puts screen models on screen, and reports which one is there. */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().screen; + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..bb77a36 --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,95 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.ResourceLocation; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + // 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB + // rather than memory-order bytes, which is what we already have. + pixels.setPixel(x, y, argb[row + x]); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // 1.21.5 gave every GPU texture a debug label, so the id has to exist + // before the texture does. DynamicTexture still takes ownership of the + // image and closes it with itself; only the registration needs freeing. + ResourceLocation id = ResourceLocation.fromNamespaceAndPath( + "photosync", "thumb/" + sequence.incrementAndGet()); + DynamicTexture texture = new DynamicTexture(id::toString, pixels); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final ResourceLocation id; + + private final int width; + private final int height; + private boolean released; + + private Handle(ResourceLocation id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..cf4539e --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,39 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Gui; +import net.minecraft.client.gui.GuiGraphics; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + * + *

1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The + * bridge still wants the float, and {@code false} asks for the real one rather + * than the frozen-while-paused one, so notifications keep animating while the + * game is paused behind our own screen. + */ +@Mixin(Gui.class) +public class HudMixin { + + @Inject(method = "render", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphics graphics, DeltaTracker delta, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, + window.getGuiScaledWidth(), + window.getGuiScaledHeight(), + delta.getGameTimeDeltaPartialTick(false)))); + } +} diff --git a/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/1.21.8/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/26.2/build.gradle b/platform/26.2/build.gradle new file mode 100644 index 0000000..8a07771 --- /dev/null +++ b/platform/26.2/build.gradle @@ -0,0 +1,2 @@ +// Configured by the root build.gradle (see the platformProjects block). +// Bucket-specific settings belong in this directory's gradle.properties. diff --git a/platform/26.2/gradle.properties b/platform/26.2/gradle.properties new file mode 100644 index 0000000..ef2d9f8 --- /dev/null +++ b/platform/26.2/gradle.properties @@ -0,0 +1,11 @@ +# Compatibility bucket: 26.1, 26.1.1, 26.1.2, 26.2 +# +# 26.x ships deobfuscated, runs on Java 25, and replaced immediate-mode GuiGraphics +# with retained-mode GuiGraphicsExtractor (render -> extractRenderState). +minecraft_version=26.2 +minecraft_range=>=26.1 <26.3 +mc_java=25 +deobfuscated=true + +loader_version=0.19.3 +fabric_api_version=0.156.0+26.2 diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java new file mode 100644 index 0000000..c5f3e6a --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/CaptureAdapter.java @@ -0,0 +1,113 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.mcapi.capture.ScreenshotService; +import lombok.extern.slf4j.Slf4j; +import net.minecraft.util.Util; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileAlreadyExistsException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.concurrent.CompletableFuture; + +/** + * Takes screenshots on the mod's own terms. + * + *

Deliberately not routed through vanilla's {@code Screenshot.grab}: that one + * names the file itself, writes a chat message, and -- since we intercept its + * write to notice the player's own F2 presses -- would make auto-captures + * indistinguishable from manual ones. Grabbing the frame and writing it here + * keeps the two origins apart and gives the caller the path it asked for. + */ +@Slf4j +public final class CaptureAdapter implements ScreenshotService { + + private static final DateTimeFormatter STAMP = + DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss", Locale.ROOT); + + @Override + public Path directory() { + Path directory = Minecraft.getInstance().gameDirectory.toPath().resolve("screenshots"); + try { + Files.createDirectories(directory); + } catch (IOException e) { + throw new UncheckedIOException("Cannot create the screenshot directory " + directory, e); + } + return directory; + } + + @Override + public CompletableFuture capture(String fileNameSuffix) { + CompletableFuture written = new CompletableFuture<>(); + Minecraft game = Minecraft.getInstance(); + // The framebuffer can only be read on the render thread; the PNG encode + // that follows must not happen there, so it hops to the IO pool. + // + // 1.21.5 turned the readback asynchronous: takeScreenshot no longer + // returns the frame, it hands it to a callback once the GPU fence + // clears, possibly frames later. The name is still claimed up front so + // that captures land in the order they were asked for. + Runnable grab = () -> { + try { + Path file = reserveFile(fileNameSuffix); + // 26.1 moved the main render target onto the game renderer. + Screenshot.takeScreenshot(game.gameRenderer.mainRenderTarget(), + frame -> Util.ioPool().execute(() -> write(frame, file, written))); + } catch (IOException | RuntimeException e) { + written.completeExceptionally(e); + } + }; + if (game.isSameThread()) { + grab.run(); + } else { + game.execute(grab); + } + return written; + } + + private void write(NativeImage frame, Path file, CompletableFuture written) { + try (NativeImage owned = frame) { + owned.writeToFile(file); + written.complete(file); + } catch (IOException | RuntimeException e) { + try { + Files.deleteIfExists(file); + } catch (IOException cleanup) { + log.warn("Left an empty screenshot behind at {}", file, cleanup); + } + written.completeExceptionally(e); + } + } + + /** + * Vanilla's naming scheme plus the configured suffix: two captures in the + * same second get {@code _1}, {@code _2}, and so on. + * + *

The name is claimed by creating the file empty rather than by testing + * for absence, because the write happens later on another thread -- two + * captures a moment apart would otherwise agree on a name and one would + * overwrite the other. + */ + private synchronized Path reserveFile(String fileNameSuffix) throws IOException { + Path directory = directory(); + String stamp = LocalDateTime.now().format(STAMP); + String suffix = fileNameSuffix == null ? "" : fileNameSuffix; + for (int attempt = 0; ; attempt++) { + String name = attempt == 0 + ? stamp + suffix + ".png" + : stamp + "_" + attempt + suffix + ".png"; + try { + return Files.createFile(directory.resolve(name)); + } catch (FileAlreadyExistsException taken) { + // Somebody -- us a second ago, or vanilla's own F2 -- got there first. + } + } + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java new file mode 100644 index 0000000..702487c --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/GameAdapter.java @@ -0,0 +1,79 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.GameContext; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.util.Util; +import net.minecraft.client.Minecraft; + +import java.nio.file.Files; +import java.nio.file.Path; + +/** Ambient client state, the render thread, and the way out of the game. */ +@Slf4j +public final class GameAdapter implements GameContext { + + /** + * Both resolved from the loader rather than from Minecraft, because this is + * built while Minecraft is still constructing itself -- and because the + * loader's answers have not moved once in the supported range. + */ + private final Path configDirectory = FabricLoader.getInstance().getConfigDir().resolve("photosync"); + private final String minecraftVersion = FabricLoader.getInstance() + .getModContainer("minecraft") + .map(container -> container.getMetadata().getVersion().getFriendlyString()) + .orElse("unknown"); + + @Override + public boolean inWorld() { + return Minecraft.getInstance().level != null; + } + + /** 26.1 moved the current screen from {@code Minecraft} onto {@code Gui}. */ + @Override + public boolean screenOpen() { + return Minecraft.getInstance().gui.screen() != null; + } + + @Override + public Path configDirectory() { + return configDirectory; + } + + @Override + public void submit(Runnable task) { + Minecraft game = Minecraft.getInstance(); + if (game.isSameThread()) { + task.run(); + } else { + game.execute(task); + } + } + + /** + * Opens the containing folder, not the file. + * + *

There is no cross-platform "reveal and select", and handing a PNG to + * the desktop opens an image viewer -- which the player already has, since + * they are looking at the screenshot in the queue screen. What they cannot + * get to is the folder. + */ + @Override + public void reveal(Path path) { + Path target = Files.isDirectory(path) ? path : path.getParent(); + if (target == null) { + return; + } + Util.getPlatform().openFile(target.toFile()); + } + + @Override + public String minecraftVersion() { + return minecraftVersion; + } + + @Override + public void quit() { + Minecraft.getInstance().stop(); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/ModelScreen.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/ModelScreen.java new file mode 100644 index 0000000..a54029f --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/ModelScreen.java @@ -0,0 +1,128 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenModel; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.input.CharacterEvent; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.client.input.MouseButtonEvent; +import net.minecraft.network.chat.Component; + +/** + * The one vanilla {@link Screen} this mod owns; every PhotoSync screen is a + * {@link ScreenModel} wearing it. + * + *

Nothing is delegated to {@code super} except key handling, which is where + * Escape lives. In particular vanilla's background extraction is not called: the + * model draws its own scrim and panel, and vanilla's would paint over the world + * underneath at the wrong moment. + */ +public final class ModelScreen extends Screen { + + @Getter + @Accessors(fluent = true) + private final ScreenModel model; + + ModelScreen(ScreenModel model) { + super(Component.literal(model.title())); + this.model = model; + } + + @Override + protected void init() { + model.layout(width, height); + } + + @Override + public void tick() { + model.tick(); + } + + /** + * 26.1's replacement for {@code render}: same arguments, same cadence, but + * the calls it makes are recorded into a render state rather than issued to + * the GPU. The model cannot tell the difference. + */ + @Override + public void extractRenderState(GuiGraphicsExtractor graphics, int mouseX, int mouseY, float partialTick) { + model.render(new RenderAdapter(graphics, width, height, partialTick), mouseX, mouseY); + } + + /** + * 1.21.11 bundled the loose input arguments into records. The bridge keeps + * the loose form -- it is the shape eight of the nine buckets speak -- so + * this is where they get unpacked. + * + *

{@code doubleClick} is dropped: PhotoSync has no double-click gesture, + * and the second click of a pair arrives here as an ordinary one anyway. + */ + @Override + public boolean mouseClicked(MouseButtonEvent event, boolean doubleClick) { + return model.mouseClicked(event.x(), event.y(), event.button()) + || super.mouseClicked(event, doubleClick); + } + + @Override + public boolean mouseReleased(MouseButtonEvent event) { + return model.mouseReleased(event.x(), event.y(), event.button()) + || super.mouseReleased(event); + } + + @Override + public boolean mouseDragged(MouseButtonEvent event, double deltaX, double deltaY) { + return model.mouseDragged(event.x(), event.y(), event.button(), deltaX, deltaY) + || super.mouseDragged(event, deltaX, deltaY); + } + + /** + * 1.20.2 split scrolling into two axes. The bridge only has one, because a + * horizontal wheel is not something any PhotoSync screen reacts to, so the + * vertical delta is the one that gets through. + */ + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double scrollX, double scrollY) { + return model.mouseScrolled(mouseX, mouseY, scrollY) + || super.mouseScrolled(mouseX, mouseY, scrollX, scrollY); + } + + @Override + public boolean keyPressed(KeyEvent event) { + return model.keyPressed(event.key(), event.scancode(), event.modifiers()) + || super.keyPressed(event); + } + + /** + * The event carries a code point rather than a char, so anything outside the + * basic plane arrives as a surrogate pair and reaches the model as two + * chars -- which is what a {@code String} would have held anyway. + * + *

26.1 dropped the modifiers from this one event. Nothing typed reads + * them: a character is a character however it was produced, and the shortcuts + * that do care about Ctrl arrive through {@code keyPressed} instead. + */ + @Override + public boolean charTyped(CharacterEvent event) { + boolean handled = false; + for (char character : Character.toChars(event.codepoint())) { + handled |= model.charTyped(character, 0); + } + return handled || super.charTyped(event); + } + + @Override + public boolean isPauseScreen() { + return model.pausesGame(); + } + + @Override + public boolean shouldCloseOnEsc() { + return model.closeOnEscape(); + } + + @Override + public void removed() { + model.closed(); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/OpenKey.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/OpenKey.java new file mode 100644 index 0000000..2a2ccda --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/OpenKey.java @@ -0,0 +1,48 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.InputConstants; +import net.fabricmc.fabric.api.client.keymapping.v1.KeyMappingHelper; +import net.minecraft.client.KeyMapping; +import net.minecraft.resources.Identifier; +import org.lwjgl.glfw.GLFW; + +/** + * The key that opens PhotoSync, bound to F6 until the player says otherwise. + * + *

F6 because it is unbound in vanilla and sits next to F2, which is the other + * key this mod is about. + */ +public final class OpenKey { + + private final KeyMapping mapping; + + private OpenKey(KeyMapping mapping) { + this.mapping = mapping; + } + + /** + * Registers the binding. Called once, from the client entrypoint. + * + *

1.21.11 turned the category from a loose translation key into a + * registered id, and it derives its own label: {@code photosync:main} + * becomes {@code key.category.photosync.main}, which is why the language + * file carries that key alongside the older {@code key.categories.photosync}. + * + *

Fabric API followed the rename in 26.1: {@code fabric-key-binding-api-v1} + * became {@code fabric-key-mapping-api-v1}, and its helper method with it. + */ + public static OpenKey register() { + KeyMapping.Category category = + KeyMapping.Category.register(Identifier.fromNamespaceAndPath("photosync", "main")); + return new OpenKey(KeyMappingHelper.registerKeyMapping(new KeyMapping( + "key.photosync.open", + InputConstants.Type.KEYSYM, + GLFW.GLFW_KEY_F6, + category))); + } + + /** Takes one queued press, or false if there are none left. */ + public boolean wasPressed() { + return mapping.consumeClick(); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/RenderAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/RenderAdapter.java new file mode 100644 index 0000000..d0d50b1 --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/RenderAdapter.java @@ -0,0 +1,128 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.render.RenderBridge; +import dev.photosync.mcapi.render.TextureHandle; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.renderer.RenderPipelines; + +/** + * The eleven drawing primitives, on top of {@link GuiGraphicsExtractor}. + * + *

One of these is built per frame and thrown away; it holds the frame's + * extractor, which is not valid outside the call that produced it. Coordinates + * are GUI-space, matching vanilla's, so nothing here scales. + * + *

26.1 split GUI rendering in two: the old {@code GuiGraphics} calls now + * record draw commands into a render state that the GPU consumes later. The + * class was renamed to match, and {@code drawString} to {@code text}, but every + * primitive the bridge needs survived the split with its arguments intact -- + * which is the whole reason the bridge was drawn at this level. + */ +public final class RenderAdapter implements RenderBridge { + + private final GuiGraphicsExtractor graphics; + private final Font font; + private final int width; + private final int height; + private final float tickDelta; + + public RenderAdapter(GuiGraphicsExtractor graphics, int width, int height, float tickDelta) { + this.graphics = graphics; + this.font = Minecraft.getInstance().font; + this.width = width; + this.height = height; + this.tickDelta = tickDelta; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public float tickDelta() { + return tickDelta; + } + + @Override + public void fill(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + height, argb); + } + + @Override + public void gradient(int x, int y, int width, int height, int topArgb, int bottomArgb) { + graphics.fillGradient(x, y, x + width, y + height, topArgb, bottomArgb); + } + + @Override + public void border(int x, int y, int width, int height, int argb) { + graphics.fill(x, y, x + width, y + 1, argb); + graphics.fill(x, y + height - 1, x + width, y + height, argb); + graphics.fill(x, y + 1, x + 1, y + height - 1, argb); + graphics.fill(x + width - 1, y + 1, x + width, y + height - 1, argb); + } + + @Override + public void text(String text, int x, int y, int argb, boolean shadow) { + graphics.text(font, text, x, y, argb, shadow); + } + + @Override + public int textWidth(String text) { + return font.width(text); + } + + @Override + public int lineHeight() { + return font.lineHeight; + } + + @Override + public void image(TextureHandle texture, int x, int y, int width, int height) { + image(texture, x, y, width, height, 0f, 0f, 1f, 1f); + } + + /** + * 1.21.2 moved the destination size ahead of the source rectangle, and 1.21.6 + * swapped the render-type lookup in front of it for a baked pipeline. The UVs + * are still texels, so the handle's own dimensions still do the conversion. + * + *

There is a normalised-UV overload as of 1.21.6 that would suit the + * bridge better on paper, but its inner argument order differs from the + * texel form's in ways that are easy to get subtly wrong; staying on the + * explicit form keeps this method identical to the four buckets below. + */ + @Override + public void image(TextureHandle texture, int x, int y, int width, int height, + float u0, float v0, float u1, float v1) { + TextureAdapter.Handle handle = (TextureAdapter.Handle) texture; + int textureWidth = handle.width(); + int textureHeight = handle.height(); + graphics.blit( + RenderPipelines.GUI_TEXTURED, + handle.id(), + x, y, + u0 * textureWidth, v0 * textureHeight, + width, height, + Math.max(1, Math.round((u1 - u0) * textureWidth)), + Math.max(1, Math.round((v1 - v0) * textureHeight)), + textureWidth, textureHeight); + } + + @Override + public void pushClip(int x, int y, int width, int height) { + graphics.enableScissor(x, y, x + width, y + height); + } + + @Override + public void popClip() { + graphics.disableScissor(); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java new file mode 100644 index 0000000..ff6d8c1 --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/ScreenAdapter.java @@ -0,0 +1,41 @@ +package dev.photosync.platform.impl; + +import dev.photosync.mcapi.screen.ScreenHost; +import dev.photosync.mcapi.screen.ScreenModel; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; + +import java.util.Optional; + +/** + * Puts screen models on screen, and reports which one is there. + * + *

26.1 moved the current screen out of {@code Minecraft} and onto {@code Gui}, + * which owns it now. {@code Minecraft.setScreenAndShow} exists and would also + * work, but it forces a synchronous frame on top of the switch; {@code Gui} is + * where the plain setter went. + */ +public final class ScreenAdapter implements ScreenHost { + + @Override + public void open(ScreenModel screen) { + Minecraft.getInstance().gui.setScreen(new ModelScreen(screen)); + } + + /** + * Closes only our own screen. If the player has since opened the pause menu + * or a chest, a late close from a finishing upload must not yank it away. + */ + @Override + public void close() { + if (current().isPresent()) { + Minecraft.getInstance().gui.setScreen(null); + } + } + + @Override + public Optional current() { + Screen screen = Minecraft.getInstance().gui.screen(); + return screen instanceof ModelScreen hosted ? Optional.of(hosted.model()) : Optional.empty(); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/TextureAdapter.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/TextureAdapter.java new file mode 100644 index 0000000..a67f0bc --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/TextureAdapter.java @@ -0,0 +1,95 @@ +package dev.photosync.platform.impl; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.thumbnail.ThumbImage; +import dev.photosync.mcapi.render.TextureHandle; +import dev.photosync.mcapi.render.TextureSink; +import lombok.Getter; +import lombok.experimental.Accessors; +import net.minecraft.client.Minecraft; +import net.minecraft.client.renderer.texture.DynamicTexture; +import net.minecraft.resources.Identifier; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Turns decoded pixels into something the GPU will draw. + * + *

Every upload mints its own texture id rather than reusing a slot, because + * the browser holds many thumbnails alive at once and vanilla's texture manager + * is the only thing that knows how to free them. The id is opaque; nothing but + * {@link RenderAdapter} ever looks at it. + */ +public final class TextureAdapter implements TextureSink { + + private final AtomicInteger sequence = new AtomicInteger(); + + @Override + public TextureHandle upload(ThumbImage image) { + NativeImage pixels = new NativeImage(NativeImage.Format.RGBA, image.width(), image.height(), false); + int[] argb = image.argb(); + for (int y = 0; y < image.height(); y++) { + int row = y * image.width(); + for (int x = 0; x < image.width(); x++) { + // 1.21.2 renamed setPixelRGBA to setPixel and made it take ARGB + // rather than memory-order bytes, which is what we already have. + pixels.setPixel(x, y, argb[row + x]); + } + } + return register(pixels); + } + + @Override + public TextureHandle decode(byte[] encoded) throws IOException { + return register(NativeImage.read(encoded)); + } + + private Handle register(NativeImage pixels) { + // 1.21.5 gave every GPU texture a debug label, so the id has to exist + // before the texture does. DynamicTexture still takes ownership of the + // image and closes it with itself; only the registration needs freeing. + Identifier id = Identifier.fromNamespaceAndPath( + "photosync", "thumb/" + sequence.incrementAndGet()); + DynamicTexture texture = new DynamicTexture(id::toString, pixels); + Minecraft.getInstance().getTextureManager().register(id, texture); + return new Handle(id, pixels.getWidth(), pixels.getHeight()); + } + + /** A registered texture, freed when the browser drops it. */ + public static final class Handle implements TextureHandle { + + @Getter + @Accessors(fluent = true) + private final Identifier id; + + private final int width; + private final int height; + private boolean released; + + private Handle(Identifier id, int width, int height) { + this.id = id; + this.width = width; + this.height = height; + } + + @Override + public int width() { + return width; + } + + @Override + public int height() { + return height; + } + + @Override + public void close() { + if (released) { + return; + } + released = true; + Minecraft.getInstance().getTextureManager().release(id); + } + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/impl/package-info.java b/platform/26.2/src/main/java/dev/photosync/platform/impl/package-info.java new file mode 100644 index 0000000..9d4a6f1 --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/impl/package-info.java @@ -0,0 +1,15 @@ +/** + * This bucket's half of the adapter: every class whose body a Minecraft release + * in the supported range has changed. + * + *

Each {@code :platform:*} project supplies its own copy of this package + * under exactly these names, and {@link dev.photosync.platform} -- compiled once + * per bucket from a shared source root -- calls into it. So the duplication + * between buckets is deliberate: it is what lets nine incompatible Minecraft + * APIs be satisfied without a single {@code if (version >= ...)} anywhere. + * + *

Adding a version means copying the nearest bucket's copy of this package + * and fixing what the compiler objects to. {@code docs/PORTING.md} lists what + * that has been, release by release. + */ +package dev.photosync.platform.impl; diff --git a/platform/26.2/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java b/platform/26.2/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java new file mode 100644 index 0000000..248e463 --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/mixin/CaptureMixin.java @@ -0,0 +1,38 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.NativeImage; +import dev.photosync.core.capture.CaptureOrigin; +import dev.photosync.mcapi.capture.ScreenshotBus; +import net.minecraft.client.Screenshot; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Redirect; + +import java.io.File; +import java.io.IOException; + +/** + * Notices the player pressing F2. + * + *

The redirect is on the write rather than on {@code grab}, because the write + * is the only point at which the final path is known -- and because on every + * supported version that call sits inside a synthetic lambda whose name changes + * from release to release. Matching {@code method = "*"} against the invocation + * sidesteps the name entirely; see docs/PORTING.md. + * + *

Announcing after the write, not before, means a listener that reads the + * file back finds it there. + */ +@Mixin(Screenshot.class) +public class CaptureMixin { + + @Redirect( + method = "*", + at = @At( + value = "INVOKE", + target = "Lcom/mojang/blaze3d/platform/NativeImage;writeToFile(Ljava/io/File;)V")) + private static void photosync$announceScreenshot(NativeImage image, File file) throws IOException { + image.writeToFile(file); + ScreenshotBus.get().published(file.toPath(), CaptureOrigin.MANUAL); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/mixin/HudMixin.java b/platform/26.2/src/main/java/dev/photosync/platform/mixin/HudMixin.java new file mode 100644 index 0000000..7ee6082 --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/mixin/HudMixin.java @@ -0,0 +1,44 @@ +package dev.photosync.platform.mixin; + +import com.mojang.blaze3d.platform.Window; +import dev.photosync.platform.PhotoSyncMod; +import dev.photosync.platform.impl.RenderAdapter; +import net.minecraft.client.DeltaTracker; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.GuiGraphicsExtractor; +import net.minecraft.client.gui.Hud; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Draws the corner notifications over the HUD. + * + *

At TAIL so they sit above the hotbar and chat rather than under them. This + * is the in-world HUD, so notifications are invisible on the title screen -- + * acceptable, since the events that raise them all happen in a world. + * + *

1.21 replaced the loose partial-tick float with {@link DeltaTracker}. The + * bridge still wants the float, and {@code false} asks for the real one rather + * than the frozen-while-paused one, so notifications keep animating while the + * game is paused behind our own screen. + * + *

26.1 split {@code Gui} in two and the in-world overlay moved to {@code Hud}, + * where {@code render} became {@code extractRenderState}. {@code Gui} kept a + * method by that name -- it is the outer one that also draws the current screen, + * and injecting there would put notifications underneath our own GUI. + */ +@Mixin(Hud.class) +public class HudMixin { + + @Inject(method = "extractRenderState", at = @At("TAIL")) + private void photosync$renderNotifications(GuiGraphicsExtractor graphics, DeltaTracker delta, CallbackInfo callback) { + Window window = Minecraft.getInstance().getWindow(); + PhotoSyncMod.client().ifPresent(client -> client.renderHud(new RenderAdapter( + graphics, + window.getGuiScaledWidth(), + window.getGuiScaledHeight(), + delta.getGameTimeDeltaPartialTick(false)))); + } +} diff --git a/platform/26.2/src/main/java/dev/photosync/platform/mixin/QuitMixin.java b/platform/26.2/src/main/java/dev/photosync/platform/mixin/QuitMixin.java new file mode 100644 index 0000000..8790aad --- /dev/null +++ b/platform/26.2/src/main/java/dev/photosync/platform/mixin/QuitMixin.java @@ -0,0 +1,31 @@ +package dev.photosync.platform.mixin; + +import dev.photosync.mcapi.lifecycle.QuitGuard; +import net.minecraft.client.Minecraft; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +/** + * Holds the door while uploads finish. + * + *

{@code stop()} is the single funnel for leaving the game -- Quit Game, the + * window close button and Alt+F4 all reach it -- and it is where the guard gets + * asked. Refusing here leaves the client running normally, so the dialog the + * guard puts up is interactive rather than a freeze. + * + *

The window button re-enters this every frame for as long as GLFW's close + * flag stays set, so the guard and the dialog it opens both have to tolerate + * being asked repeatedly. + */ +@Mixin(Minecraft.class) +public class QuitMixin { + + @Inject(method = "stop", at = @At("HEAD"), cancellable = true) + private void photosync$confirmQuit(CallbackInfo callback) { + if (!QuitGuard.get().mayQuit()) { + callback.cancel(); + } + } +} diff --git a/platform/common/src/main/java/dev/photosync/platform/ClipboardAdapter.java b/platform/common/src/main/java/dev/photosync/platform/ClipboardAdapter.java new file mode 100644 index 0000000..46f4c1d --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/ClipboardAdapter.java @@ -0,0 +1,23 @@ +package dev.photosync.platform; + +import dev.photosync.mcapi.Clipboard; +import net.minecraft.client.Minecraft; + +/** + * The system clipboard, via the keyboard handler that already owns GLFW's. + * + *

Going through Minecraft rather than AWT matters on macOS, where touching + * {@code java.awt} from the render thread of a GLFW application is a hang. + */ +public final class ClipboardAdapter implements Clipboard { + + @Override + public String read() { + return Minecraft.getInstance().keyboardHandler.getClipboard(); + } + + @Override + public void write(String text) { + Minecraft.getInstance().keyboardHandler.setClipboard(text); + } +} diff --git a/platform/common/src/main/java/dev/photosync/platform/MinecraftBridge.java b/platform/common/src/main/java/dev/photosync/platform/MinecraftBridge.java new file mode 100644 index 0000000..d61f9e2 --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/MinecraftBridge.java @@ -0,0 +1,28 @@ +package dev.photosync.platform; + +import dev.photosync.mcapi.ClientBridge; +import dev.photosync.platform.impl.CaptureAdapter; +import dev.photosync.platform.impl.GameAdapter; +import dev.photosync.platform.impl.ScreenAdapter; +import dev.photosync.platform.impl.TextureAdapter; +import lombok.Getter; +import lombok.experimental.Accessors; + +/** + * Assembles this bucket's adapters into the one object the rest of the mod sees. + * + *

Built once, at client startup, before Minecraft has finished constructing + * itself -- so nothing here may touch the game. Each adapter resolves what it + * needs when it is first used instead. + */ +@Getter +@Accessors(fluent = true) +public final class MinecraftBridge implements ClientBridge { + + private final GameAdapter game = new GameAdapter(); + private final TextAdapter text = new TextAdapter(); + private final ClipboardAdapter clipboard = new ClipboardAdapter(); + private final TextureAdapter textures = new TextureAdapter(); + private final ScreenAdapter screens = new ScreenAdapter(); + private final CaptureAdapter screenshots = new CaptureAdapter(); +} diff --git a/platform/common/src/main/java/dev/photosync/platform/PhotoSyncMod.java b/platform/common/src/main/java/dev/photosync/platform/PhotoSyncMod.java new file mode 100644 index 0000000..c31da80 --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/PhotoSyncMod.java @@ -0,0 +1,65 @@ +package dev.photosync.platform; + +import dev.photosync.client.PhotoSyncClient; +import dev.photosync.platform.impl.OpenKey; +import lombok.extern.slf4j.Slf4j; +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; + +import java.util.Optional; + +/** + * Fabric's way in, and the mixins' way back. + * + *

Three lines of wiring: build the client against this bucket's bridge, tick + * it, and close it when the game is shutting down. Everything else the mod does + * is reached from those. + * + *

The static field is the same concession {@code ScreenshotBus} and + * {@code QuitGuard} make, for the same reason -- a mixin is woven into a class + * nobody constructs, so it cannot be handed a collaborator. Those two cover the + * capture and quit hooks between them; this covers the third, the HUD, which + * needs the client itself because it draws through it. Keeping the field here + * rather than in each bucket's mixin package means there is exactly one of them. + */ +@Slf4j +public final class PhotoSyncMod implements ClientModInitializer { + + private static volatile PhotoSyncClient client; + + @Override + public void onInitializeClient() { + OpenKey openKey = OpenKey.register(); + PhotoSyncClient started = new PhotoSyncClient(new MinecraftBridge()).start(); + client = started; + + ClientTickEvents.END_CLIENT_TICK.register(game -> { + started.tick(); + // Drained in a loop: holding the key over a lag spike queues several + // presses, and opening the screen three times is not what was meant. + boolean pressed = false; + while (openKey.wasPressed()) { + pressed = true; + } + if (pressed) { + started.openScreen(); + } + }); + + ClientLifecycleEvents.CLIENT_STOPPING.register(game -> { + client = null; + started.close(); + }); + } + + /** + * The running client, for the HUD mixin. + * + *

Empty before {@code onInitializeClient} finishes and again once the + * game is stopping, both of which are frames the HUD can still be drawn in. + */ + public static Optional client() { + return Optional.ofNullable(client); + } +} diff --git a/platform/common/src/main/java/dev/photosync/platform/TextAdapter.java b/platform/common/src/main/java/dev/photosync/platform/TextAdapter.java new file mode 100644 index 0000000..1886c5e --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/TextAdapter.java @@ -0,0 +1,26 @@ +package dev.photosync.platform; + +import dev.photosync.mcapi.Translator; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.locale.Language; + +/** + * Translations, from the language the player actually selected. + * + *

{@code I18n.get} falls back to the key when there is no entry, which is + * what {@link Translator} promises. The existence check goes to {@link Language} + * rather than {@code I18n.exists} because that method was dropped in 26.1 while + * {@code Language.has} has been there throughout. + */ +public final class TextAdapter implements Translator { + + @Override + public String get(String key, Object... arguments) { + return I18n.get(key, arguments); + } + + @Override + public boolean has(String key) { + return Language.getInstance().has(key); + } +} diff --git a/platform/common/src/main/java/dev/photosync/platform/package-info.java b/platform/common/src/main/java/dev/photosync/platform/package-info.java new file mode 100644 index 0000000..b4f204c --- /dev/null +++ b/platform/common/src/main/java/dev/photosync/platform/package-info.java @@ -0,0 +1,15 @@ +/** + * The half of the Minecraft adapter that has not changed since 1.20. + * + *

This is a source root, not a module: it imports {@code net.minecraft}, so + * it is compiled once per compatibility bucket against that bucket's Minecraft + * rather than published as a jar. The root build adds it to every + * {@code :platform:*} project. + * + *

A class earns its place here by being textually identical on all + * nine buckets -- verified by the fact that nine compilations accept it. The + * moment a version needs a different body, the class moves down into + * {@link dev.photosync.platform.impl}, which every bucket supplies for itself. + * That is the whole rule; there is no third category. + */ +package dev.photosync.platform; diff --git a/platform/common/src/main/resources/fabric.mod.json b/platform/common/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..3777ade --- /dev/null +++ b/platform/common/src/main/resources/fabric.mod.json @@ -0,0 +1,29 @@ +{ + "schemaVersion": 1, + "id": "${mod_id}", + "version": "${mod_version}", + "name": "${mod_name}", + "description": "${mod_description}", + "license": "${mod_license}", + "environment": "client", + "contact": { + "sources": "${mod_sources}" + }, + "entrypoints": { + "client": [ + "dev.photosync.platform.PhotoSyncMod" + ] + }, + "mixins": [ + { + "config": "${mod_id}.mixins.json", + "environment": "client" + } + ], + "depends": { + "fabricloader": ">=${loader_version}", + "fabric-api": "*", + "minecraft": "${minecraft_range}", + "java": ">=${java_version}" + } +} diff --git a/platform/common/src/main/resources/photosync.mixins.json b/platform/common/src/main/resources/photosync.mixins.json new file mode 100644 index 0000000..d3edfa7 --- /dev/null +++ b/platform/common/src/main/resources/photosync.mixins.json @@ -0,0 +1,14 @@ +{ + "required": true, + "minVersion": "0.8", + "package": "dev.photosync.platform.mixin", + "compatibilityLevel": "${mixin_compat}", + "client": [ + "CaptureMixin", + "HudMixin", + "QuitMixin" + ], + "injectors": { + "defaultRequire": 1 + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b5b4e71 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,51 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } + // Lets the root build say `id 'fabric-loom'` without repeating the version, + // so loom_version lives in exactly one place (gradle.properties). + resolutionStrategy { + eachPlugin { + if (requested.id.id == 'fabric-loom') { + useModule("net.fabricmc:fabric-loom:${settings.loom_version}") + } + } + } +} + +plugins { + id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0' +} + +rootProject.name = 'photosync' + +// --------------------------------------------------------------------------- +// Shared modules. These carry no Minecraft dependency at all and are compiled +// to the lowest bytecode level we must support (Java 17, for 1.20-1.20.4). +// --------------------------------------------------------------------------- +include 'shared:core' +include 'shared:mc-api' +include 'shared:ui' +include 'shared:client' + +// --------------------------------------------------------------------------- +// Platform modules. One per compatibility bucket -- NOT one per Minecraft +// version. Each bucket's representative version is the one we compile and test +// against; the other versions it covers are declared in fabric.mod.json. +// +// See docs/PORTING.md for the empirical breakpoints behind this grouping. +// --------------------------------------------------------------------------- +include 'platform:1.20.1' +include 'platform:1.20.4' +include 'platform:1.20.6' +include 'platform:1.21.1' +include 'platform:1.21.4' +include 'platform:1.21.5' +include 'platform:1.21.8' +include 'platform:1.21.11' +include 'platform:26.2' diff --git a/shared/client/build.gradle b/shared/client/build.gradle new file mode 100644 index 0000000..1aba4a0 --- /dev/null +++ b/shared/client/build.gradle @@ -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') +} diff --git a/shared/client/src/main/java/dev/photosync/client/AutoCapture.java b/shared/client/src/main/java/dev/photosync/client/AutoCapture.java new file mode 100644 index 0000000..e6bd88e --- /dev/null +++ b/shared/client/src/main/java/dev/photosync/client/AutoCapture.java @@ -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. + * + *

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. + * + *

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 settings; + private final Consumer 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 settings, Consumer 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); + } + } +} diff --git a/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java b/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java new file mode 100644 index 0000000..0e3215d --- /dev/null +++ b/shared/client/src/main/java/dev/photosync/client/PhotoSyncClient.java @@ -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. + * + *

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. + * + *

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(); + } +} diff --git a/shared/client/src/main/java/dev/photosync/client/package-info.java b/shared/client/src/main/java/dev/photosync/client/package-info.java new file mode 100644 index 0000000..8f0e6cc --- /dev/null +++ b/shared/client/src/main/java/dev/photosync/client/package-info.java @@ -0,0 +1,15 @@ +/** + * Where the mod is assembled: the object graph, and the client-side behaviour + * that is not drawing. + * + *

{@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. + * + *

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; diff --git a/shared/client/src/main/resources/assets/photosync/lang/en_us.json b/shared/client/src/main/resources/assets/photosync/lang/en_us.json new file mode 100644 index 0000000..629828f --- /dev/null +++ b/shared/client/src/main/resources/assets/photosync/lang/en_us.json @@ -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" +} diff --git a/shared/core/build.gradle b/shared/core/build.gradle new file mode 100644 index 0000000..f1212ea --- /dev/null +++ b/shared/core/build.gradle @@ -0,0 +1,2 @@ +// Provider abstraction, Immich client, upload pipeline, configuration. +// Nothing in this module may import a Minecraft or Fabric type. diff --git a/shared/core/src/main/java/dev/photosync/core/PhotoSync.java b/shared/core/src/main/java/dev/photosync/core/PhotoSync.java new file mode 100644 index 0000000..4c28174 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/PhotoSync.java @@ -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. + * + *

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. + * + *

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 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}. + * + *

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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/capture/CaptureOrigin.java b/shared/core/src/main/java/dev/photosync/core/capture/CaptureOrigin.java new file mode 100644 index 0000000..ab498b1 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/capture/CaptureOrigin.java @@ -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 +} diff --git a/shared/core/src/main/java/dev/photosync/core/capture/CapturedScreenshot.java b/shared/core/src/main/java/dev/photosync/core/capture/CapturedScreenshot.java new file mode 100644 index 0000000..3ea7108 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/capture/CapturedScreenshot.java @@ -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. + * + *

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)); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/AutoCaptureSettings.java b/shared/core/src/main/java/dev/photosync/core/config/AutoCaptureSettings.java new file mode 100644 index 0000000..32ff9c6 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/AutoCaptureSettings.java @@ -0,0 +1,53 @@ +package dev.photosync.core.config; + +import lombok.Builder; + +/** + * Unattended screenshots on a timer. + * + *

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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/BrowserSettings.java b/shared/core/src/main/java/dev/photosync/core/config/BrowserSettings.java new file mode 100644 index 0000000..748c964 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/BrowserSettings.java @@ -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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java b/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java new file mode 100644 index 0000000..ade3c19 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/ConfigService.java @@ -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. + * + *

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 current; + private final List> 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 listener) { + listeners.add(listener); + } + + public void update(UnaryOperator change) { + PhotoSyncConfig updated = current.updateAndGet(previous -> change.apply(previous).normalized(previous.provider())); + persist(updated); + for (Consumer 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); + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java b/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java new file mode 100644 index 0000000..0d98715 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/ConfigStore.java @@ -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}. + * + *

Loading merges the stored file onto 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 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 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); + } + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/NotificationKind.java b/shared/core/src/main/java/dev/photosync/core/config/NotificationKind.java new file mode 100644 index 0000000..47ea8a6 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/NotificationKind.java @@ -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 +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/NotificationSettings.java b/shared/core/src/main/java/dev/photosync/core/config/NotificationSettings.java new file mode 100644 index 0000000..df2ca35 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/NotificationSettings.java @@ -0,0 +1,46 @@ +package dev.photosync.core.config; + +import lombok.Builder; + +/** + * The one-line messages that appear in the bottom-left corner. + * + *

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; + }; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java b/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java new file mode 100644 index 0000000..e90fed0 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/PhotoSyncConfig.java @@ -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. + * + *

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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/config/UploadSettings.java b/shared/core/src/main/java/dev/photosync/core/config/UploadSettings.java new file mode 100644 index 0000000..9468986 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/config/UploadSettings.java @@ -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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/package-info.java b/shared/core/src/main/java/dev/photosync/core/package-info.java new file mode 100644 index 0000000..d0ed128 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/package-info.java @@ -0,0 +1,12 @@ +/** + * Everything PhotoSync does that is not about Minecraft. + * + *

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. + * + *

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; diff --git a/shared/core/src/main/java/dev/photosync/core/persistence/Adapters.java b/shared/core/src/main/java/dev/photosync/core/persistence/Adapters.java new file mode 100644 index 0000000..e8212f0 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/persistence/Adapters.java @@ -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. + * + *

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 { + + @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 { + + @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; + } + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/persistence/JsonFile.java b/shared/core/src/main/java/dev/photosync/core/persistence/JsonFile.java new file mode 100644 index 0000000..abf43d4 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/persistence/JsonFile.java @@ -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. + * + *

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 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); + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/Album.java b/shared/core/src/main/java/dev/photosync/core/provider/Album.java new file mode 100644 index 0000000..e569914 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/Album.java @@ -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 coverAssetId) { + + public Album { + coverAssetId = coverAssetId == null ? Optional.empty() : coverAssetId; + } + + public AlbumRef ref() { + return AlbumRef.of(id); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/AlbumRef.java b/shared/core/src/main/java/dev/photosync/core/provider/AlbumRef.java new file mode 100644 index 0000000..25ecf51 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/AlbumRef.java @@ -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. + * + *

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 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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/AssetKind.java b/shared/core/src/main/java/dev/photosync/core/provider/AssetKind.java new file mode 100644 index 0000000..2ab3918 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/AssetKind.java @@ -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 +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/BucketPage.java b/shared/core/src/main/java/dev/photosync/core/provider/BucketPage.java new file mode 100644 index 0000000..4b11e0b --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/BucketPage.java @@ -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 assets) { + + public BucketPage { + assets = assets == null ? List.of() : List.copyOf(assets); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/PhotoProvider.java b/shared/core/src/main/java/dev/photosync/core/provider/PhotoProvider.java new file mode 100644 index 0000000..3131b59 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/PhotoProvider.java @@ -0,0 +1,70 @@ +package dev.photosync.core.provider; + +import java.util.List; + +/** + * The seam between PhotoSync and whatever is storing the photos. + * + *

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. + * + *

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 albums() throws ProviderException; + + Album createAlbum(String name) throws ProviderException; + + /** + * Uploads one file and, when {@link UploadRequest#album()} names an album, + * puts it there. + * + *

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. + * + *

One cheap call that gives the browser enough to size its scrollbar for + * the entire album without fetching any asset. + */ + List 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. + * + *

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(); +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderCatalog.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderCatalog.java new file mode 100644 index 0000000..468b38b --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderCatalog.java @@ -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. + * + *

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 factories; + private final ProviderId preferred; + + public ProviderCatalog(List factories) { + if (factories.isEmpty()) { + throw new IllegalArgumentException("A catalog needs at least one provider"); + } + Map 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 descriptors() { + return factories.values().stream().map(ProviderFactory::descriptor).toList(); + } + + public Optional 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); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderConnection.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderConnection.java new file mode 100644 index 0000000..8641f83 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderConnection.java @@ -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. + * + *

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() ? "" : "") + "]"; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderDescriptor.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderDescriptor.java new file mode 100644 index 0000000..1799d41 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderDescriptor.java @@ -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. + * + *

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()}: + * + *

    + *
  • {@code .name} -- provider name in the picker
  • + *
  • {@code .endpoint} / {@code .endpoint.hint} -- first credential field
  • + *
  • {@code .secret} / {@code .secret.hint} -- second credential field
  • + *
+ */ +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"; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderException.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderException.java new file mode 100644 index 0000000..a754c07 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderException.java @@ -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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderFactory.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderFactory.java new file mode 100644 index 0000000..287de43 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderFactory.java @@ -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); +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderId.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderId.java new file mode 100644 index 0000000..d2ce7da --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderId.java @@ -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}. + * + *

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 { + + 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; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderIdentity.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderIdentity.java new file mode 100644 index 0000000..c170624 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderIdentity.java @@ -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) { +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ProviderSession.java b/shared/core/src/main/java/dev/photosync/core/provider/ProviderSession.java new file mode 100644 index 0000000..8989170 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ProviderSession.java @@ -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. + * + *

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 told 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 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); + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/RemoteAsset.java b/shared/core/src/main/java/dev/photosync/core/provider/RemoteAsset.java new file mode 100644 index 0000000..4cb2360 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/RemoteAsset.java @@ -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. + * + *

{@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. + * + *

{@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 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; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/ThumbnailSize.java b/shared/core/src/main/java/dev/photosync/core/provider/ThumbnailSize.java new file mode 100644 index 0000000..6b19193 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/ThumbnailSize.java @@ -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 +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/TimelineBucket.java b/shared/core/src/main/java/dev/photosync/core/provider/TimelineBucket.java new file mode 100644 index 0000000..95d04f2 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/TimelineBucket.java @@ -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. + * + *

This is the whole trick behind the virtual list: the backend hands us every + * bucket's count 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. + * + *

{@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 { + + /** Newest first, matching how the timeline is displayed. */ + @Override + public int compareTo(TimelineBucket other) { + return other.month.compareTo(month); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/TransferCancelledException.java b/shared/core/src/main/java/dev/photosync/core/provider/TransferCancelledException.java new file mode 100644 index 0000000..8907460 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/TransferCancelledException.java @@ -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. + * + *

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. + * + *

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); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/TransferProgress.java b/shared/core/src/main/java/dev/photosync/core/provider/TransferProgress.java new file mode 100644 index 0000000..e336000 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/TransferProgress.java @@ -0,0 +1,20 @@ +package dev.photosync.core.provider; + +/** + * Byte-level progress callback for an in-flight upload. + * + *

Called from the upload worker thread, possibly very often. Implementations + * must not block and must not touch the render thread directly. + * + *

Throwing {@link TransferCancelledException} from here aborts the transfer. + */ +@FunctionalInterface +public interface TransferProgress { + + void onProgress(long bytesTransferred, long totalBytes); + + static TransferProgress ignored() { + return (transferred, total) -> { + }; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/UploadReceipt.java b/shared/core/src/main/java/dev/photosync/core/provider/UploadReceipt.java new file mode 100644 index 0000000..f698344 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/UploadReceipt.java @@ -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 + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java b/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java new file mode 100644 index 0000000..38a24a9 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/UploadRequest.java @@ -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. + * + *

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; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java new file mode 100644 index 0000000..1da9404 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichApi.java @@ -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. + * + *

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. + * + *

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 get(String path, Map query, Type type) throws ProviderException { + return decode(send(request(path, query, REQUEST_TIMEOUT).GET().build()), type); + } + + byte[] getBytes(String path, Map query) throws ProviderException { + return send(request(path, query, REQUEST_TIMEOUT).GET().build()); + } + + 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 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 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 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 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 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 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 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) + "..."; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java new file mode 100644 index 0000000..a6e4dca --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichDtos.java @@ -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. + * + *

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. + * + *

Two absences are deliberate rather than incidental: + * + *

    + *
  • {@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}.
  • + *
  • 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.
  • + *
+ */ +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 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 id, + List fileCreatedAt, + List localOffsetHours, + List ratio, + List thumbhash, + List 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) { + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java new file mode 100644 index 0000000..6efd4e9 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProvider.java @@ -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. + * + *

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>() { + }.getType(); + private static final Type BUCKET_LIST = new TypeToken>() { + }.getType(); + private static final Type BULK_RESULT = new TypeToken>() { + }.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 albums() throws ProviderException { + List 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 album = request.album().id(); + if (album.isPresent()) { + addToAlbum(album.get(), response.id()); + } + return new UploadReceipt(response.id(), outcome); + } + + @Override + public List timeline(AlbumRef album) throws ProviderException { + List 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 query = scope(album); + query.put("timeBucket", bucket.key()); + ImmichDtos.BucketAssets columns = api.get("/timeline/bucket", query, ImmichDtos.BucketAssets.class); + + int count = columns.size(); + List assets = new ArrayList<>(count); + for (int index = 0; index < count; index++) { + String id = columns.id().get(index); + if (id == null) { + continue; + } + Optional 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 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 scope(AlbumRef album) { + Map 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 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 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 column(List values, int index) { + return values == null || index >= values.size() ? null : values.get(index); + } + + private static T column(List 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); + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProviderFactory.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProviderFactory.java new file mode 100644 index 0000000..001f0ff --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/ImmichProviderFactory.java @@ -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}. + * + *

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); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/provider/immich/MultipartBody.java b/shared/core/src/main/java/dev/photosync/core/provider/immich/MultipartBody.java new file mode 100644 index 0000000..6706a62 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/provider/immich/MultipartBody.java @@ -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. + * + *

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. + * + *

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.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. + * + *

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 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", ""); + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbHash.java b/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbHash.java new file mode 100644 index 0000000..1717401 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbHash.java @@ -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. + * + *

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. + * + *

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 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. + * + *

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 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; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbImage.java b/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbImage.java new file mode 100644 index 0000000..0a1b313 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbImage.java @@ -0,0 +1,25 @@ +package dev.photosync.core.thumbnail; + +/** + * A tiny decoded bitmap -- at most 32x32 -- in 0xAARRGGBB order. + * + *

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(); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbnailLoader.java b/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbnailLoader.java new file mode 100644 index 0000000..0b9a630 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/thumbnail/ThumbnailLoader.java @@ -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. + * + *

Decoding those bytes into a texture is deliberately not 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. + * + *

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> inFlight = new ConcurrentHashMap<>(); + + public ThumbnailLoader(ProviderSession session) { + this.session = session; + this.workers = Executors.newFixedThreadPool(WORKERS, daemonFactory()); + } + + /** + * Asks for one thumbnail. + * + *

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> request(String assetId, ThumbnailSize size) { + String key = assetId + '@' + size; + CompletableFuture existing = inFlight.get(key); + if (existing != null) { + return Optional.of(existing); + } + if (inFlight.size() >= MAX_PENDING) { + return Optional.empty(); + } + CompletableFuture future = new CompletableFuture<>(); + CompletableFuture 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; + }; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/timeline/TimelineBrowser.java b/shared/core/src/main/java/dev/photosync/core/timeline/TimelineBrowser.java new file mode 100644 index 0000000..9856e18 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/timeline/TimelineBrowser.java @@ -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. + * + *

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. + * + *

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 inFlight = ConcurrentHashMap.newKeySet(); + private final AtomicInteger revision = new AtomicInteger(); + + private final Map> pages = new HashMap<>(); + private final Map pageErrors = new HashMap<>(); + + private AlbumRef album = AlbumRef.library(); + private List buckets = List.of(); + /** Bumped on every {@link #open} so results for the previous album are discarded. */ + private long generation; + + private volatile List 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 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 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 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 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 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 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 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. + * + *

Grouping is by the asset's local 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 groupByDay(List assets) { + Map> byDay = new LinkedHashMap<>(); + for (RemoteAsset asset : assets) { + byDay.computeIfAbsent(asset.localDay(), day -> new ArrayList<>()).add(asset); + } + List 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 built = new ArrayList<>(); + for (TimelineBucket bucket : buckets) { + List 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; + }; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/timeline/TimelineSection.java b/shared/core/src/main/java/dev/photosync/core/timeline/TimelineSection.java new file mode 100644 index 0000000..c5b0433 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/timeline/TimelineSection.java @@ -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. + * + *

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. + * + *

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 assets(); + + record PendingMonth(TimelineBucket bucket) implements TimelineSection { + + @Override + public LocalDate date() { + return bucket.month(); + } + + @Override + public int assetCount() { + return bucket.assetCount(); + } + + @Override + public List assets() { + return List.of(); + } + } + + record Day(LocalDate date, List assets) implements TimelineSection { + + public Day { + assets = List.copyOf(assets); + } + + @Override + public int assetCount() { + return assets.size(); + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/timeline/TimelineState.java b/shared/core/src/main/java/dev/photosync/core/timeline/TimelineState.java new file mode 100644 index 0000000..2bd80a9 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/timeline/TimelineState.java @@ -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 +} diff --git a/shared/core/src/main/java/dev/photosync/core/upload/QueuedUpload.java b/shared/core/src/main/java/dev/photosync/core/upload/QueuedUpload.java new file mode 100644 index 0000000..1f0c395 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/upload/QueuedUpload.java @@ -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); + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadCoordinator.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadCoordinator.java new file mode 100644 index 0000000..2e8d97a --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadCoordinator.java @@ -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}. + * + *

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. + * + *

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 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 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 provider = session.provider(); + if (provider.isEmpty()) { + return; + } + UploadSettings current = settings.get(); + Instant now = Instant.now(); + while (running && inFlight.get() < current.concurrency()) { + Optional 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; + }; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadEvent.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadEvent.java new file mode 100644 index 0000000..91e0981 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadEvent.java @@ -0,0 +1,36 @@ +package dev.photosync.core.upload; + +import dev.photosync.core.provider.UploadReceipt; + +/** + * The transitions worth telling someone about. + * + *

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 { + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java new file mode 100644 index 0000000..e2a54b5 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadJob.java @@ -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. + * + *

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. + * + *

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 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; + }; + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java new file mode 100644 index 0000000..51aa169 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadQueue.java @@ -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. + * + *

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. + * + *

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 jobs = new LinkedHashMap<>(); + + /** jobId to {sent, total}. Not persisted: meaningless across a restart. */ + private final Map transfers = new ConcurrentHashMap<>(); + private final Set cancelRequests = ConcurrentHashMap.newKeySet(); + private final List> 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 listener) { + listeners.add(listener); + } + + // ----------------------------------------------------------------------- + // Reading + // ----------------------------------------------------------------------- + + /** Oldest first, which is also the order workers pick jobs up in. */ + public List snapshot() { + List current; + synchronized (lock) { + current = List.copyOf(jobs.values()); + } + List 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 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. + * + *

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 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 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 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 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 change, + java.util.function.Function 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 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 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> listType = new TypeToken<>() { + }; + List stored = file.readTree() + .>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 listener : listeners) { + try { + listener.accept(event); + } catch (RuntimeException e) { + log.error("An upload listener failed on {}", event.getClass().getSimpleName(), e); + } + } + } +} diff --git a/shared/core/src/main/java/dev/photosync/core/upload/UploadState.java b/shared/core/src/main/java/dev/photosync/core/upload/UploadState.java new file mode 100644 index 0000000..5943d89 --- /dev/null +++ b/shared/core/src/main/java/dev/photosync/core/upload/UploadState.java @@ -0,0 +1,40 @@ +package dev.photosync.core.upload; + +/** + * Where a job is in its life. + * + *

{@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; + } +} diff --git a/shared/core/src/test/java/dev/photosync/core/thumbnail/ThumbHashTest.java b/shared/core/src/test/java/dev/photosync/core/thumbnail/ThumbHashTest.java new file mode 100644 index 0000000..bfa78c4 --- /dev/null +++ b/shared/core/src/test/java/dev/photosync/core/thumbnail/ThumbHashTest.java @@ -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. + * + *

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; + } +} diff --git a/shared/mc-api/build.gradle b/shared/mc-api/build.gradle new file mode 100644 index 0000000..ecf9df9 --- /dev/null +++ b/shared/mc-api/build.gradle @@ -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') +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/ClientBridge.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/ClientBridge.java new file mode 100644 index 0000000..9ce2704 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/ClientBridge.java @@ -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. + * + *

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(); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/Clipboard.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/Clipboard.java new file mode 100644 index 0000000..6da6ec9 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/Clipboard.java @@ -0,0 +1,15 @@ +package dev.photosync.mcapi; + +/** + * The system clipboard. + * + *

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); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java new file mode 100644 index 0000000..8bc181f --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/GameContext.java @@ -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. + * + *

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. + * + *

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. + * + *

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(); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/Keys.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/Keys.java new file mode 100644 index 0000000..3dc2ce0 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/Keys.java @@ -0,0 +1,56 @@ +package dev.photosync.mcapi; + +/** + * The GLFW key and modifier codes the UI reacts to. + * + *

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; + } +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/Translator.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/Translator.java new file mode 100644 index 0000000..25a2936 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/Translator.java @@ -0,0 +1,16 @@ +package dev.photosync.mcapi; + +/** + * Looks up translated strings. + * + *

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); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/capture/ScreenshotBus.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/capture/ScreenshotBus.java new file mode 100644 index 0000000..404680f --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/capture/ScreenshotBus.java @@ -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. + * + *

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. + * + *

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. + * + *

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> listeners = new CopyOnWriteArrayList<>(); + + private ScreenshotBus() { + } + + public static ScreenshotBus get() { + return INSTANCE; + } + + public void subscribe(Consumer listener) { + listeners.add(listener); + } + + /** + * Announces a saved screenshot. Called from the IO thread that wrote it, so + * subscribers must be thread-safe. + * + *

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 listener : listeners) { + try { + listener.accept(shot); + } catch (RuntimeException e) { + log.error("A screenshot listener failed for {}", file, e); + } + } + } +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/capture/ScreenshotService.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/capture/ScreenshotService.java new file mode 100644 index 0000000..17f579c --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/capture/ScreenshotService.java @@ -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. + * + *

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. + * + *

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 capture(String fileNameSuffix); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/lifecycle/QuitGuard.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/lifecycle/QuitGuard.java new file mode 100644 index 0000000..e118191 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/lifecycle/QuitGuard.java @@ -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. + * + *

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. + * + *

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. + * + *

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); + } +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/package-info.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/package-info.java new file mode 100644 index 0000000..2244dc6 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/package-info.java @@ -0,0 +1,20 @@ +/** + * The seam between PhotoSync and Minecraft. + * + *

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. + * + *

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. + * + *

See {@code docs/PORTING.md} for the routine when a new version lands. + */ +package dev.photosync.mcapi; diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/render/RenderBridge.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/render/RenderBridge.java new file mode 100644 index 0000000..2dffcd9 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/render/RenderBridge.java @@ -0,0 +1,64 @@ +package dev.photosync.mcapi.render; + +/** + * The drawing primitives every PhotoSync screen is built from. + * + *

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. + * + *

Colours are packed 0xAARRGGBB. Coordinates are in GUI space, already + * divided by the GUI scale, with the origin at the top-left. + * + *

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. + * + *

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(); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/render/TextureHandle.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/render/TextureHandle.java new file mode 100644 index 0000000..9f7df84 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/render/TextureHandle.java @@ -0,0 +1,20 @@ +package dev.photosync.mcapi.render; + +/** + * A texture living on the GPU, owned by whoever asked {@link TextureSink} for it. + * + *

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(); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/render/TextureSink.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/render/TextureSink.java new file mode 100644 index 0000000..5617a66 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/render/TextureSink.java @@ -0,0 +1,31 @@ +package dev.photosync.mcapi.render; + +import dev.photosync.core.thumbnail.ThumbImage; + +import java.io.IOException; + +/** + * Turns pixels into something drawable. + * + *

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. + * + *

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; +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/screen/ScreenHost.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/screen/ScreenHost.java new file mode 100644 index 0000000..e2324cf --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/screen/ScreenHost.java @@ -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 current(); +} diff --git a/shared/mc-api/src/main/java/dev/photosync/mcapi/screen/ScreenModel.java b/shared/mc-api/src/main/java/dev/photosync/mcapi/screen/ScreenModel.java new file mode 100644 index 0000000..2597dd2 --- /dev/null +++ b/shared/mc-api/src/main/java/dev/photosync/mcapi/screen/ScreenModel.java @@ -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. + * + *

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. + * + *

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; + } +} diff --git a/shared/ui/build.gradle b/shared/ui/build.gradle new file mode 100644 index 0000000..303405a --- /dev/null +++ b/shared/ui/build.gradle @@ -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') +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/Chrome.java b/shared/ui/src/main/java/dev/photosync/ui/Chrome.java new file mode 100644 index 0000000..7e1609c --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/Chrome.java @@ -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. + * + *

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. + * + *

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 wrap(RenderBridge render, String value, int maxWidth, int maxLines) { + java.util.List 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. + * + *

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. + * + *

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()); + } + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/PhotoSyncUi.java b/shared/ui/src/main/java/dev/photosync/ui/PhotoSyncUi.java new file mode 100644 index 0000000..bd7e391 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/PhotoSyncUi.java @@ -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. + * + *

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. + * + *

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 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. + * + *

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 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. + * + *

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(); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/Rect.java b/shared/ui/src/main/java/dev/photosync/ui/Rect.java new file mode 100644 index 0000000..0300ba6 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/Rect.java @@ -0,0 +1,101 @@ +package dev.photosync.ui; + +/** + * An axis-aligned rectangle in GUI space. + * + *

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); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/Theme.java b/shared/ui/src/main/java/dev/photosync/ui/Theme.java new file mode 100644 index 0000000..95d60d8 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/Theme.java @@ -0,0 +1,102 @@ +package dev.photosync.ui; + +import lombok.Builder; + +/** + * Every colour and metric the interface uses, in one value. + * + *

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. + * + *

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. + * + *

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; + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/image/ThumbnailCache.java b/shared/ui/src/main/java/dev/photosync/ui/image/ThumbnailCache.java new file mode 100644 index 0000000..83b1a09 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/image/ThumbnailCache.java @@ -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. + * + *

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. + * + *

Everything here runs on the render thread. The fetching does not: this + * class only ever polls {@link ThumbnailLoader}'s futures, so a slow + * server stalls a tile rather than a frame. + * + *

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 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. + * + *

Empty means there is genuinely nothing yet -- no ThumbHash and no + * thumbnail -- which the grid draws as a plain tile. + */ + public Optional 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> 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 pending; + private long touched; + + private Entry(RemoteAsset asset) { + this.asset = asset; + this.texture = decodeHash(asset); + } + + private Optional 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 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); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/notify/Notifications.java b/shared/ui/src/main/java/dev/photosync/ui/notify/Notifications.java new file mode 100644 index 0000000..29ed51a --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/notify/Notifications.java @@ -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. + * + *

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. + * + *

{@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 settings; + private final List live = new CopyOnWriteArrayList<>(); + + public Notifications(Chrome chrome, Supplier 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. + * + *

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(); + }; + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/package-info.java b/shared/ui/src/main/java/dev/photosync/ui/package-info.java new file mode 100644 index 0000000..2d59b22 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/package-info.java @@ -0,0 +1,16 @@ +/** + * Every screen, widget and pixel PhotoSync draws. + * + *

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. + * + *

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; diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/AlbumPickerScreen.java b/shared/ui/src/main/java/dev/photosync/ui/screen/AlbumPickerScreen.java new file mode 100644 index 0000000..0cdd469 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/AlbumPickerScreen.java @@ -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. + * + *

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. + * + *

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 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 provider = ui.core().session().provider(); + if (provider.isEmpty()) { + error = chrome.translate("photosync.album.not_configured"); + return; + } + loading = true; + error = ""; + CompletableFuture.runAsync(() -> { + try { + List 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 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 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(); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/PhotoSyncScreen.java b/shared/ui/src/main/java/dev/photosync/ui/screen/PhotoSyncScreen.java new file mode 100644 index 0000000..9c70353 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/PhotoSyncScreen.java @@ -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. + * + *

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. + * + *

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 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); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/QueueScreen.java b/shared/ui/src/main/java/dev/photosync/ui/screen/QueueScreen.java new file mode 100644 index 0000000..dab7db6 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/QueueScreen.java @@ -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. + * + *

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. + * + *

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 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 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 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 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 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 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 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. + * + *

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 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 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 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); + } + } + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/QuitDialog.java b/shared/ui/src/main/java/dev/photosync/ui/screen/QuitDialog.java new file mode 100644 index 0000000..f742c1b --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/QuitDialog.java @@ -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. + * + *

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. + * + *

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 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; + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java b/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java new file mode 100644 index 0000000..4d5a82a --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/SettingsScreen.java @@ -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. + * + *

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. + * + *

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 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 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 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 change) { + ui.draft(change.apply(draft())); + } + + private void upload(UnaryOperator change) { + edit(config -> config.toBuilder().upload(change.apply(config.upload())).build()); + } + + private void autoCapture(UnaryOperator change) { + edit(config -> config.toBuilder().autoCapture(change.apply(config.autoCapture())).build()); + } + + private void notifications(UnaryOperator change) { + edit(config -> config.toBuilder().notifications(change.apply(config.notifications())).build()); + } + + private void browser(UnaryOperator change) { + edit(config -> config.toBuilder().browser(change.apply(config.browser())).build()); + } + + private void cycleProvider() { + List 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(); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/screen/TimelineScreen.java b/shared/ui/src/main/java/dev/photosync/ui/screen/TimelineScreen.java new file mode 100644 index 0000000..503fed3 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/screen/TimelineScreen.java @@ -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. + * + *

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. + * + *

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 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 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 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 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 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 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 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(); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/Button.java b/shared/ui/src/main/java/dev/photosync/ui/widget/Button.java new file mode 100644 index 0000000..d4d1588 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/Button.java @@ -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. + * + *

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(); + }; + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/ScrollModel.java b/shared/ui/src/main/java/dev/photosync/ui/widget/ScrollModel.java new file mode 100644 index 0000000..0759bf6 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/ScrollModel.java @@ -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. + * + *

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. + * + *

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. + * + *

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()); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/Slider.java b/shared/ui/src/main/java/dev/photosync/ui/widget/Slider.java new file mode 100644 index 0000000..dff3274 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/Slider.java @@ -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. + * + *

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)); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/TextField.java b/shared/ui/src/main/java/dev/photosync/ui/widget/TextField.java new file mode 100644 index 0000000..ebe0e5f --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/TextField.java @@ -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. + * + *

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. + * + *

{@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 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 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)); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/Toggle.java b/shared/ui/src/main/java/dev/photosync/ui/widget/Toggle.java new file mode 100644 index 0000000..c25750d --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/Toggle.java @@ -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. + * + *

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 writer; + + @Setter + private String description; + + public Toggle(Chrome chrome, String label, BooleanSupplier reader, Consumer 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; + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/Widget.java b/shared/ui/src/main/java/dev/photosync/ui/widget/Widget.java new file mode 100644 index 0000000..8d4f6c8 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/Widget.java @@ -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. + * + *

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); + } +} diff --git a/shared/ui/src/main/java/dev/photosync/ui/widget/WidgetList.java b/shared/ui/src/main/java/dev/photosync/ui/widget/WidgetList.java new file mode 100644 index 0000000..0c3bd18 --- /dev/null +++ b/shared/ui/src/main/java/dev/photosync/ui/widget/WidgetList.java @@ -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. + * + *

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 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 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 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; + } +} diff --git a/tools/probe-api.py b/tools/probe-api.py new file mode 100644 index 0000000..048d2e3 --- /dev/null +++ b/tools/probe-api.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Dumps the Minecraft API surface PhotoSync depends on, for every bucket. + +The adapters in :platform are written against exactly these signatures, and +docs/PORTING.md's breakpoint table is generated from this output. Run it after +adding a bucket to see, in one place, what that version changed: + + ./gradlew :platform::build # once, so Loom caches the jar + python3 tools/probe-api.py > /tmp/api.txt +""" + +import glob +import os +import re +import subprocess +import sys + +LOOM = os.path.expanduser("~/.gradle/caches/fabric-loom/minecraftMaven/net/minecraft") + +# Class, then the members worth watching. A name missing from the output is +# itself the finding -- that is how the 26.x GuiGraphics removal showed up. +TARGETS = [ + (["net.minecraft.client.gui.GuiGraphics", + "net.minecraft.client.gui.GuiGraphicsExtractor"], + r"\b(fill|fillGradient|enableScissor|disableScissor|drawString|text|blit)\("), + (["net.minecraft.client.gui.Font"], r"\b(width|lineHeight)\b"), + (["net.minecraft.client.gui.components.events.GuiEventListener"], + r"\b(mouseClicked|mouseReleased|mouseDragged|mouseScrolled|keyPressed|charTyped)\("), + (["net.minecraft.client.gui.screens.Screen"], + r"\b(render|extractRenderState|renderBackground|extractBackground|init|resize|removed" + r"|isPauseScreen|shouldCloseOnEsc|tick)\("), + (["net.minecraft.client.renderer.texture.DynamicTexture"], r"DynamicTexture\(|close\(|upload\(|getPixels\("), + (["net.minecraft.client.renderer.texture.TextureManager"], r"\b(register|release|getTexture)\("), + (["com.mojang.blaze3d.platform.NativeImage"], r"\b(read|writeToFile|setPixelRGBA|setPixel|close)\("), + (["net.minecraft.client.Screenshot"], r"\b(grab|takeScreenshot|_grab)\("), + # setScreen picks up setScreenAndShow too, which is the 26.x replacement. + (["net.minecraft.client.Minecraft"], r"\b(stop|close|getInstance|setScreen|getWindow)\w*\("), + (["net.minecraft.util.thread.BlockableEventLoop"], r"\b(execute|isSameThread)\("), + (["net.minecraft.Util", "net.minecraft.util.Util"], r"\b(getPlatform|ioPool)\("), +] + +VERSION_KEY = re.compile(r"minecraft-merged-(\d+(?:\.\d+)*)-") + + +def jars(): + found = {} + for jar in glob.glob(os.path.join(LOOM, "minecraft-merged", "*", "*.jar")): + if "sources" in jar: + continue + match = VERSION_KEY.search(os.path.basename(jar)) + if match: + found.setdefault(match.group(1), jar) + return sorted(found.items(), key=lambda item: [int(p) for p in item[0].split(".")]) + + +def dump(jar, class_name, pattern): + try: + out = subprocess.run(["javap", "-cp", jar, "-p", class_name], + capture_output=True, text=True, timeout=120) + except FileNotFoundError: + sys.exit("javap not on PATH; run this with a JDK available") + if out.returncode != 0: + return None + return [line.strip() for line in out.stdout.splitlines() if re.search(pattern, line)] + + +def main(): + for version, jar in jars(): + print("=" * 70) + print(version) + print("=" * 70) + for class_names, pattern in TARGETS: + for class_name in class_names: + members = dump(jar, class_name, pattern) + if members is None: + continue + print(f"\n-- {class_name}") + for member in members: + print(f" {member}") + print() + + +if __name__ == "__main__": + main() diff --git a/tools/probe-class.py b/tools/probe-class.py new file mode 100644 index 0000000..d6f3af1 --- /dev/null +++ b/tools/probe-class.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +"""javaps one class across every cached bucket jar, filtered by a regex. + +The companion to probe-api.py: that one watches a fixed list of members and +feeds docs/PORTING.md's tables, this one answers a one-off question while +writing an adapter. + + python3 tools/probe-class.py net.minecraft.client.gui.Gui 'render\\(' +""" + +import re +import sys + +from importlib.machinery import SourceFileLoader +from pathlib import Path + +probe = SourceFileLoader("probe", str(Path(__file__).with_name("probe-api.py"))).load_module() + + +def main(): + if len(sys.argv) < 2: + sys.exit("usage: probe-class.py [regex]") + class_names = sys.argv[1].split(",") + pattern = sys.argv[2] if len(sys.argv) > 2 else r"." + for version, jar in probe.jars(): + print(f"===== {version}") + for class_name in class_names: + members = probe.dump(jar, class_name, pattern) + if members is None: + print(f" -- {class_name}: ABSENT") + continue + print(f" -- {class_name}") + for member in members: + print(f" {member}") + + +if __name__ == "__main__": + main()