[READ-ONLY] Mirror of https://github.com/flo-bit/bskycam.
0

Configure Feed

Select the types of activity you want to include in your feed.

Initial commit: Bluesky Camera mod for Fabric (MC 26.1.2)

Core features:
- Camera item (spyglass-like hold-to-aim with gentle FOV zoom via mixin,
clean-HUD screenshot on release, JPEG compression + auto-downscale to
stay under Bluesky's 1 MB blob limit)
- Photo sign item that stores the captured JPEG + rich metadata
(player, position, biome, dimension, weather, seed, etc.) in NBT
- Text sign item (craftable) for text-only posts with the same metadata
- In-game config screen (handle + app password + PDS override) with
auto-PDS resolution via did:plc and did:web lookup
- Click-to-post-and-place flow: right-click a wall with an unposted sign
to open the edit screen; on successful Bluesky post the sign is
automatically placed as a world entity on that wall
- PhotoSign / TextSign hanging entities that render the captured photo
(via DynamicTexture) plus wrapped caption text in the world, break
to drop the item back, survive world reloads
- `/bskycam` client command + sneak+right-click to open config

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

author
Florian
co-author
Claude Opus 4.7 (1M context)
date (Apr 17, 2026, 9:17 AM +0200) commit 2b9e9298
+3092
+31
.gitignore
··· 1 + # Gradle 2 + .gradle/ 3 + build/ 4 + out/ 5 + 6 + # IntelliJ IDEA 7 + .idea/ 8 + *.iml 9 + *.ipr 10 + *.iws 11 + 12 + # Eclipse 13 + .classpath 14 + .project 15 + .settings/ 16 + bin/ 17 + 18 + # VS Code 19 + .vscode/ 20 + 21 + # Fabric runtime — contains dev world saves, logs, crash reports, and the 22 + # config file with your app password. 23 + run/ 24 + 25 + # macOS 26 + .DS_Store 27 + 28 + # Emacs / Vim swap files 29 + *~ 30 + *.swp 31 + *.swo
+121
LICENSE
··· 1 + Creative Commons Legal Code 2 + 3 + CC0 1.0 Universal 4 + 5 + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 + HEREUNDER. 13 + 14 + Statement of Purpose 15 + 16 + The laws of most jurisdictions throughout the world automatically confer 17 + exclusive Copyright and Related Rights (defined below) upon the creator 18 + and subsequent owner(s) (each and all, an "owner") of an original work of 19 + authorship and/or a database (each, a "Work"). 20 + 21 + Certain owners wish to permanently relinquish those rights to a Work for 22 + the purpose of contributing to a commons of creative, cultural and 23 + scientific works ("Commons") that the public can reliably and without fear 24 + of later claims of infringement build upon, modify, incorporate in other 25 + works, reuse and redistribute as freely as possible in any form whatsoever 26 + and for any purposes, including without limitation commercial purposes. 27 + These owners may contribute to the Commons to promote the ideal of a free 28 + culture and the further production of creative, cultural and scientific 29 + works, or to gain reputation or greater distribution for their Work in 30 + part through the use and efforts of others. 31 + 32 + For these and/or other purposes and motivations, and without any 33 + expectation of additional consideration or compensation, the person 34 + associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 + is an owner of Copyright and Related Rights in the Work, voluntarily 36 + elects to apply CC0 to the Work and publicly distribute the Work under its 37 + terms, with knowledge of his or her Copyright and Related Rights in the 38 + Work and the meaning and intended legal effect of CC0 on those rights. 39 + 40 + 1. Copyright and Related Rights. A Work made available under CC0 may be 41 + protected by copyright and related or neighboring rights ("Copyright and 42 + Related Rights"). Copyright and Related Rights include, but are not 43 + limited to, the following: 44 + 45 + i. the right to reproduce, adapt, distribute, perform, display, 46 + communicate, and translate a Work; 47 + ii. moral rights retained by the original author(s) and/or performer(s); 48 + iii. publicity and privacy rights pertaining to a person's image or 49 + likeness depicted in a Work; 50 + iv. rights protecting against unfair competition in regards to a Work, 51 + subject to the limitations in paragraph 4(a), below; 52 + v. rights protecting the extraction, dissemination, use and reuse of data 53 + in a Work; 54 + vi. database rights (such as those arising under Directive 96/9/EC of the 55 + European Parliament and of the Council of 11 March 1996 on the legal 56 + protection of databases, and under any national implementation 57 + thereof, including any amended or successor version of such 58 + directive); and 59 + vii. other similar, equivalent or corresponding rights throughout the 60 + world based on applicable law or treaty, and any national 61 + implementations thereof. 62 + 63 + 2. Waiver. To the greatest extent permitted by, but not in contravention 64 + of, applicable law, Affirmer hereby overtly, fully, permanently, 65 + irrevocably and unconditionally waives, abandons, and surrenders all of 66 + Affirmer's Copyright and Related Rights and associated claims and causes 67 + of action, whether now known or unknown (including existing as well as 68 + future claims and causes of action), in the Work (i) in all territories 69 + worldwide, (ii) for the maximum duration provided by applicable law or 70 + treaty (including future time extensions), (iii) in any current or future 71 + medium and for any number of copies, and (iv) for any purpose whatsoever, 72 + including without limitation commercial, advertising or promotional 73 + purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 + member of the public at large and to the detriment of Affirmer's heirs and 75 + successors, fully intending that such Waiver shall not be subject to 76 + revocation, rescission, cancellation, termination, or any other legal or 77 + equitable action to disrupt the quiet enjoyment of the Work by the public 78 + as contemplated by Affirmer's express Statement of Purpose. 79 + 80 + 3. Public License Fallback. Should any part of the Waiver for any reason 81 + be judged legally invalid or ineffective under applicable law, then the 82 + Waiver shall be preserved to the maximum extent permitted taking into 83 + account Affirmer's express Statement of Purpose. In addition, to the 84 + extent the Waiver is so judged Affirmer hereby grants to each affected 85 + person a royalty-free, non transferable, non sublicensable, non exclusive, 86 + irrevocable and unconditional license to exercise Affirmer's Copyright and 87 + Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 + maximum duration provided by applicable law or treaty (including future 89 + time extensions), (iii) in any current or future medium and for any number 90 + of copies, and (iv) for any purpose whatsoever, including without 91 + limitation commercial, advertising or promotional purposes (the 92 + "License"). The License shall be deemed effective as of the date CC0 was 93 + applied by Affirmer to the Work. Should any part of the License for any 94 + reason be judged legally invalid or ineffective under applicable law, such 95 + partial invalidity or ineffectiveness shall not invalidate the remainder 96 + of the License, and in such case Affirmer hereby affirms that he or she 97 + will not (i) exercise any of his or her remaining Copyright and Related 98 + Rights in the Work or (ii) assert any associated claims and causes of 99 + action with respect to the Work, in either case contrary to Affirmer's 100 + express Statement of Purpose. 101 + 102 + 4. Limitations and Disclaimers. 103 + 104 + a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 + surrendered, licensed or otherwise affected by this document. 106 + b. Affirmer offers the Work as-is and makes no representations or 107 + warranties of any kind concerning the Work, express, implied, 108 + statutory or otherwise, including without limitation warranties of 109 + title, merchantability, fitness for a particular purpose, non 110 + infringement, or the absence of latent or other defects, accuracy, or 111 + the present or absence of errors, whether or not discoverable, all to 112 + the greatest extent permissible under applicable law. 113 + c. Affirmer disclaims responsibility for clearing rights of other persons 114 + that may apply to the Work or any use thereof, including without 115 + limitation any person's Copyright and Related Rights in the Work. 116 + Further, Affirmer disclaims responsibility for obtaining any necessary 117 + consents, permissions or other rights required for any use of the 118 + Work. 119 + d. Affirmer understands and acknowledges that Creative Commons is not a 120 + party to this document and has no duty or obligation with respect to 121 + this CC0 or use of the Work.
+64
build.gradle
··· 1 + plugins { 2 + id 'net.fabricmc.fabric-loom' version "${loom_version}" 3 + id 'maven-publish' 4 + } 5 + 6 + version = project.mod_version 7 + group = project.maven_group 8 + 9 + repositories { 10 + } 11 + 12 + loom { 13 + splitEnvironmentSourceSets() 14 + 15 + mods { 16 + "bskycam" { 17 + sourceSet sourceSets.main 18 + sourceSet sourceSets.client 19 + } 20 + } 21 + } 22 + 23 + dependencies { 24 + minecraft "com.mojang:minecraft:${project.minecraft_version}" 25 + implementation "net.fabricmc:fabric-loader:${project.loader_version}" 26 + implementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" 27 + } 28 + 29 + processResources { 30 + inputs.property "version", project.version 31 + 32 + filesMatching("fabric.mod.json") { 33 + expand "version": inputs.properties.version 34 + } 35 + } 36 + 37 + tasks.withType(JavaCompile).configureEach { 38 + it.options.release = 25 39 + } 40 + 41 + java { 42 + withSourcesJar() 43 + sourceCompatibility = JavaVersion.VERSION_25 44 + targetCompatibility = JavaVersion.VERSION_25 45 + } 46 + 47 + jar { 48 + inputs.property "projectName", project.name 49 + 50 + from("LICENSE") { 51 + rename { "${it}_${project.name}"} 52 + } 53 + } 54 + 55 + publishing { 56 + publications { 57 + create("mavenJava", MavenPublication) { 58 + from components.java 59 + } 60 + } 61 + 62 + repositories { 63 + } 64 + }
+12
gradle.properties
··· 1 + org.gradle.jvmargs=-Xmx1G 2 + org.gradle.parallel=true 3 + org.gradle.configuration-cache=false 4 + 5 + minecraft_version=26.1.2 6 + loader_version=0.19.2 7 + loom_version=1.16-SNAPSHOT 8 + 9 + mod_version=1.0.0 10 + maven_group=com.florian.bskycam 11 + 12 + fabric_api_version=0.146.0+26.1.2
gradle/wrapper/gradle-wrapper.jar

This is a binary file and will not be displayed.

+7
gradle/wrapper/gradle-wrapper.properties
··· 1 + distributionBase=GRADLE_USER_HOME 2 + distributionPath=wrapper/dists 3 + distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip 4 + networkTimeout=10000 5 + validateDistributionUrl=true 6 + zipStoreBase=GRADLE_USER_HOME 7 + zipStorePath=wrapper/dists
+248
gradlew
··· 1 + #!/bin/sh 2 + 3 + # 4 + # Copyright © 2015 the original authors. 5 + # 6 + # Licensed under the Apache License, Version 2.0 (the "License"); 7 + # you may not use this file except in compliance with the License. 8 + # You may obtain a copy of the License at 9 + # 10 + # https://www.apache.org/licenses/LICENSE-2.0 11 + # 12 + # Unless required by applicable law or agreed to in writing, software 13 + # distributed under the License is distributed on an "AS IS" BASIS, 14 + # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 + # See the License for the specific language governing permissions and 16 + # limitations under the License. 17 + # 18 + # SPDX-License-Identifier: Apache-2.0 19 + # 20 + 21 + ############################################################################## 22 + # 23 + # Gradle start up script for POSIX generated by Gradle. 24 + # 25 + # Important for running: 26 + # 27 + # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is 28 + # noncompliant, but you have some other compliant shell such as ksh or 29 + # bash, then to run this script, type that shell name before the whole 30 + # command line, like: 31 + # 32 + # ksh Gradle 33 + # 34 + # Busybox and similar reduced shells will NOT work, because this script 35 + # requires all of these POSIX shell features: 36 + # * functions; 37 + # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», 38 + # «${var#prefix}», «${var%suffix}», and «$( cmd )»; 39 + # * compound commands having a testable exit status, especially «case»; 40 + # * various built-in commands including «command», «set», and «ulimit». 41 + # 42 + # Important for patching: 43 + # 44 + # (2) This script targets any POSIX shell, so it avoids extensions provided 45 + # by Bash, Ksh, etc; in particular arrays are avoided. 46 + # 47 + # The "traditional" practice of packing multiple parameters into a 48 + # space-separated string is a well documented source of bugs and security 49 + # problems, so this is (mostly) avoided, by progressively accumulating 50 + # options in "$@", and eventually passing that to Java. 51 + # 52 + # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, 53 + # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; 54 + # see the in-line comments for details. 55 + # 56 + # There are tweaks for specific operating systems such as AIX, CygWin, 57 + # Darwin, MinGW, and NonStop. 58 + # 59 + # (3) This script is generated from the Groovy template 60 + # https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt 61 + # within the Gradle project. 62 + # 63 + # You can find Gradle at https://github.com/gradle/gradle/. 64 + # 65 + ############################################################################## 66 + 67 + # Attempt to set APP_HOME 68 + 69 + # Resolve links: $0 may be a link 70 + app_path=$0 71 + 72 + # Need this for daisy-chained symlinks. 73 + while 74 + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path 75 + [ -h "$app_path" ] 76 + do 77 + ls=$( ls -ld "$app_path" ) 78 + link=${ls#*' -> '} 79 + case $link in #( 80 + /*) app_path=$link ;; #( 81 + *) app_path=$APP_HOME$link ;; 82 + esac 83 + done 84 + 85 + # This is normally unused 86 + # shellcheck disable=SC2034 87 + APP_BASE_NAME=${0##*/} 88 + # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) 89 + APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit 90 + 91 + # Use the maximum available, or set MAX_FD != -1 to use that value. 92 + MAX_FD=maximum 93 + 94 + warn () { 95 + echo "$*" 96 + } >&2 97 + 98 + die () { 99 + echo 100 + echo "$*" 101 + echo 102 + exit 1 103 + } >&2 104 + 105 + # OS specific support (must be 'true' or 'false'). 106 + cygwin=false 107 + msys=false 108 + darwin=false 109 + nonstop=false 110 + case "$( uname )" in #( 111 + CYGWIN* ) cygwin=true ;; #( 112 + Darwin* ) darwin=true ;; #( 113 + MSYS* | MINGW* ) msys=true ;; #( 114 + NONSTOP* ) nonstop=true ;; 115 + esac 116 + 117 + 118 + 119 + # Determine the Java command to use to start the JVM. 120 + if [ -n "$JAVA_HOME" ] ; then 121 + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 122 + # IBM's JDK on AIX uses strange locations for the executables 123 + JAVACMD=$JAVA_HOME/jre/sh/java 124 + else 125 + JAVACMD=$JAVA_HOME/bin/java 126 + fi 127 + if [ ! -x "$JAVACMD" ] ; then 128 + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 129 + 130 + Please set the JAVA_HOME variable in your environment to match the 131 + location of your Java installation." 132 + fi 133 + else 134 + JAVACMD=java 135 + if ! command -v java >/dev/null 2>&1 136 + then 137 + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 138 + 139 + Please set the JAVA_HOME variable in your environment to match the 140 + location of your Java installation." 141 + fi 142 + fi 143 + 144 + # Increase the maximum file descriptors if we can. 145 + if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 146 + case $MAX_FD in #( 147 + max*) 148 + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 149 + # shellcheck disable=SC2039,SC3045 150 + MAX_FD=$( ulimit -H -n ) || 151 + warn "Could not query maximum file descriptor limit" 152 + esac 153 + case $MAX_FD in #( 154 + '' | soft) :;; #( 155 + *) 156 + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 157 + # shellcheck disable=SC2039,SC3045 158 + ulimit -n "$MAX_FD" || 159 + warn "Could not set maximum file descriptor limit to $MAX_FD" 160 + esac 161 + fi 162 + 163 + # Collect all arguments for the java command, stacking in reverse order: 164 + # * args from the command line 165 + # * the main class name 166 + # * -classpath 167 + # * -D...appname settings 168 + # * --module-path (only if needed) 169 + # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 170 + 171 + # For Cygwin or MSYS, switch paths to Windows format before running java 172 + if "$cygwin" || "$msys" ; then 173 + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 174 + 175 + JAVACMD=$( cygpath --unix "$JAVACMD" ) 176 + 177 + # Now convert the arguments - kludge to limit ourselves to /bin/sh 178 + for arg do 179 + if 180 + case $arg in #( 181 + -*) false ;; # don't mess with options #( 182 + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 183 + [ -e "$t" ] ;; #( 184 + *) false ;; 185 + esac 186 + then 187 + arg=$( cygpath --path --ignore --mixed "$arg" ) 188 + fi 189 + # Roll the args list around exactly as many times as the number of 190 + # args, so each arg winds up back in the position where it started, but 191 + # possibly modified. 192 + # 193 + # NB: a `for` loop captures its iteration list before it begins, so 194 + # changing the positional parameters here affects neither the number of 195 + # iterations, nor the values presented in `arg`. 196 + shift # remove old arg 197 + set -- "$@" "$arg" # push replacement arg 198 + done 199 + fi 200 + 201 + 202 + # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 203 + DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 204 + 205 + # Collect all arguments for the java command: 206 + # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 207 + # and any embedded shellness will be escaped. 208 + # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 209 + # treated as '${Hostname}' itself on the command line. 210 + 211 + set -- \ 212 + "-Dorg.gradle.appname=$APP_BASE_NAME" \ 213 + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ 214 + "$@" 215 + 216 + # Stop when "xargs" is not available. 217 + if ! command -v xargs >/dev/null 2>&1 218 + then 219 + die "xargs is not available" 220 + fi 221 + 222 + # Use "xargs" to parse quoted args. 223 + # 224 + # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 225 + # 226 + # In Bash we could simply go: 227 + # 228 + # readarray ARGS < <( xargs -n1 <<<"$var" ) && 229 + # set -- "${ARGS[@]}" "$@" 230 + # 231 + # but POSIX shell has neither arrays nor command substitution, so instead we 232 + # post-process each arg (as a line of input to sed) to backslash-escape any 233 + # character that might be a shell metacharacter, then use eval to reverse 234 + # that process (while maintaining the separation between arguments), and wrap 235 + # the whole thing up as a single "set" statement. 236 + # 237 + # This will of course break if any of these variables contains a newline or 238 + # an unmatched quote. 239 + # 240 + 241 + eval "set -- $( 242 + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 243 + xargs -n1 | 244 + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 245 + tr '\n' ' ' 246 + )" '"$@"' 247 + 248 + exec "$JAVACMD" "$@"
+93
gradlew.bat
··· 1 + @rem 2 + @rem Copyright 2015 the original author or authors. 3 + @rem 4 + @rem Licensed under the Apache License, Version 2.0 (the "License"); 5 + @rem you may not use this file except in compliance with the License. 6 + @rem You may obtain a copy of the License at 7 + @rem 8 + @rem https://www.apache.org/licenses/LICENSE-2.0 9 + @rem 10 + @rem Unless required by applicable law or agreed to in writing, software 11 + @rem distributed under the License is distributed on an "AS IS" BASIS, 12 + @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 + @rem See the License for the specific language governing permissions and 14 + @rem limitations under the License. 15 + @rem 16 + @rem SPDX-License-Identifier: Apache-2.0 17 + @rem 18 + 19 + @if "%DEBUG%"=="" @echo off 20 + @rem ########################################################################## 21 + @rem 22 + @rem Gradle startup script for Windows 23 + @rem 24 + @rem ########################################################################## 25 + 26 + @rem Set local scope for the variables with windows NT shell 27 + if "%OS%"=="Windows_NT" setlocal 28 + 29 + set DIRNAME=%~dp0 30 + if "%DIRNAME%"=="" set DIRNAME=. 31 + @rem This is normally unused 32 + set APP_BASE_NAME=%~n0 33 + set APP_HOME=%DIRNAME% 34 + 35 + @rem Resolve any "." and ".." in APP_HOME to make it shorter. 36 + for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi 37 + 38 + @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 39 + set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" 40 + 41 + @rem Find java.exe 42 + if defined JAVA_HOME goto findJavaFromJavaHome 43 + 44 + set JAVA_EXE=java.exe 45 + %JAVA_EXE% -version >NUL 2>&1 46 + if %ERRORLEVEL% equ 0 goto execute 47 + 48 + echo. 1>&2 49 + echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 50 + echo. 1>&2 51 + echo Please set the JAVA_HOME variable in your environment to match the 1>&2 52 + echo location of your Java installation. 1>&2 53 + 54 + goto fail 55 + 56 + :findJavaFromJavaHome 57 + set JAVA_HOME=%JAVA_HOME:"=% 58 + set JAVA_EXE=%JAVA_HOME%/bin/java.exe 59 + 60 + if exist "%JAVA_EXE%" goto execute 61 + 62 + echo. 1>&2 63 + echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 64 + echo. 1>&2 65 + echo Please set the JAVA_HOME variable in your environment to match the 1>&2 66 + echo location of your Java installation. 1>&2 67 + 68 + goto fail 69 + 70 + :execute 71 + @rem Setup the command line 72 + 73 + 74 + 75 + @rem Execute Gradle 76 + "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* 77 + 78 + :end 79 + @rem End local scope for the variables with windows NT shell 80 + if %ERRORLEVEL% equ 0 goto mainEnd 81 + 82 + :fail 83 + rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 84 + rem the _cmd.exe /c_ return code! 85 + set EXIT_CODE=%ERRORLEVEL% 86 + if %EXIT_CODE% equ 0 set EXIT_CODE=1 87 + if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 88 + exit /b %EXIT_CODE% 89 + 90 + :mainEnd 91 + if "%OS%"=="Windows_NT" endlocal 92 + 93 + :omega
+91
make_sign_textures.py
··· 1 + #!/usr/bin/env python3 2 + """Generate photo_sign.png and text_sign.png textures.""" 3 + import struct, zlib, os 4 + 5 + W = H = 16 6 + 7 + def chunk(ctype, data): 8 + return struct.pack(">I", len(data)) + ctype + data + struct.pack(">I", zlib.crc32(ctype + data) & 0xffffffff) 9 + 10 + def make_png(pixels, path): 11 + raw = b"" 12 + for y in range(H): 13 + raw += b"\x00" 14 + for x in range(W): 15 + r, g, b, a = pixels[y * W + x] 16 + raw += struct.pack("BBBB", r, g, b, a) 17 + sig = b"\x89PNG\r\n\x1a\n" 18 + ihdr = struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0) 19 + with open(path, "wb") as f: 20 + f.write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) 21 + 22 + CLEAR = (0, 0, 0, 0) 23 + 24 + def put(px, x, y, c): 25 + if 0 <= x < W and 0 <= y < H: 26 + px[y * W + x] = c 27 + 28 + def rect(px, x0, y0, x1, y1, c): 29 + for y in range(y0, y1 + 1): 30 + for x in range(x0, x1 + 1): 31 + put(px, x, y, c) 32 + 33 + # Photo sign: wooden frame + mini photo 34 + def photo_sign(): 35 + px = [CLEAR] * (W * H) 36 + FRAME = (140, 90, 50, 255) 37 + FRAME_EDGE = (90, 55, 30, 255) 38 + CORNER = (120, 72, 40, 255) 39 + PHOTO_BG = (230, 230, 235, 255) 40 + SKY = (110, 180, 230, 255) 41 + GROUND = (100, 150, 80, 255) 42 + SUN = (255, 220, 140, 255) 43 + # outer frame 44 + rect(px, 1, 1, 14, 14, FRAME) 45 + rect(px, 1, 1, 14, 1, FRAME_EDGE) 46 + rect(px, 1, 14, 14, 14, FRAME_EDGE) 47 + rect(px, 1, 1, 1, 14, FRAME_EDGE) 48 + rect(px, 14, 1, 14, 14, FRAME_EDGE) 49 + # corners 50 + put(px, 1, 1, CORNER); put(px, 14, 1, CORNER) 51 + put(px, 1, 14, CORNER); put(px, 14, 14, CORNER) 52 + # photo window (white border + image) 53 + rect(px, 3, 3, 12, 12, PHOTO_BG) 54 + # sky 55 + rect(px, 4, 4, 11, 8, SKY) 56 + # sun 57 + put(px, 9, 5, SUN); put(px, 10, 5, SUN); put(px, 9, 6, SUN); put(px, 10, 6, SUN) 58 + # ground 59 + rect(px, 4, 9, 11, 11, GROUND) 60 + return px 61 + 62 + # Text sign: paper with lines of text 63 + def text_sign(): 64 + px = [CLEAR] * (W * H) 65 + PAPER = (245, 240, 220, 255) 66 + PAPER_EDGE = (200, 190, 160, 255) 67 + INK = (55, 40, 30, 255) 68 + STICK = (120, 75, 40, 255) 69 + STICK_EDGE = (85, 50, 25, 255) 70 + # paper body 71 + rect(px, 2, 1, 13, 11, PAPER) 72 + rect(px, 2, 1, 13, 1, PAPER_EDGE) 73 + rect(px, 2, 11, 13, 11, PAPER_EDGE) 74 + rect(px, 2, 1, 2, 11, PAPER_EDGE) 75 + rect(px, 13, 1, 13, 11, PAPER_EDGE) 76 + # text lines 77 + rect(px, 4, 3, 11, 3, INK) 78 + rect(px, 4, 5, 10, 5, INK) 79 + rect(px, 4, 7, 11, 7, INK) 80 + rect(px, 4, 9, 8, 9, INK) 81 + # stick handle 82 + rect(px, 7, 12, 8, 15, STICK) 83 + put(px, 7, 12, STICK_EDGE); put(px, 8, 12, STICK_EDGE) 84 + put(px, 7, 15, STICK_EDGE); put(px, 8, 15, STICK_EDGE) 85 + return px 86 + 87 + out_dir = os.path.join(os.path.dirname(__file__), "src", "main", "resources", "assets", "bskycam", "textures", "item") 88 + os.makedirs(out_dir, exist_ok=True) 89 + make_png(photo_sign(), os.path.join(out_dir, "photo_sign.png")) 90 + make_png(text_sign(), os.path.join(out_dir, "text_sign.png")) 91 + print("wrote photo_sign.png + text_sign.png")
+100
make_texture.py
··· 1 + #!/usr/bin/env python3 2 + """Generate a 16x16 camera PNG for the bskycam mod.""" 3 + import struct, zlib 4 + 5 + W = H = 16 6 + 7 + def chunk(ctype, data): 8 + return struct.pack(">I", len(data)) + ctype + data + struct.pack(">I", zlib.crc32(ctype + data) & 0xffffffff) 9 + 10 + def make_png(pixels, path): 11 + raw = b"" 12 + for y in range(H): 13 + raw += b"\x00" 14 + for x in range(W): 15 + r, g, b, a = pixels[y * W + x] 16 + raw += struct.pack("BBBB", r, g, b, a) 17 + sig = b"\x89PNG\r\n\x1a\n" 18 + ihdr = struct.pack(">IIBBBBB", W, H, 8, 6, 0, 0, 0) 19 + with open(path, "wb") as f: 20 + f.write(sig + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")) 21 + 22 + CLEAR = (0, 0, 0, 0) 23 + BODY = (30, 30, 34, 255) # dark charcoal 24 + EDGE = (12, 12, 14, 255) # near-black outline 25 + GRIP = (60, 60, 68, 255) # lighter top strip 26 + LENS_O = (70, 70, 80, 255) # lens outer ring 27 + LENS_M = (110, 120, 150, 255) # lens mid glass 28 + LENS_I = (180, 220, 255, 255) # lens bright highlight 29 + SHUTR = (200, 40, 40, 255) # red shutter button 30 + SHUTR_E= (120, 20, 20, 255) # shutter edge 31 + FLASH = (255, 245, 200, 255) # flash square 32 + FLASH_E= (150, 140, 90, 255) # flash edge 33 + SKY = (90, 170, 230, 255) # a subtle "sky" accent on lens 34 + 35 + def put(px, x, y, c): 36 + if 0 <= x < W and 0 <= y < H: 37 + px[y * W + x] = c 38 + 39 + def rect(px, x0, y0, x1, y1, c): 40 + for y in range(y0, y1 + 1): 41 + for x in range(x0, x1 + 1): 42 + put(px, x, y, c) 43 + 44 + def circle_filled(px, cx, cy, r, c): 45 + for y in range(cy - r, cy + r + 1): 46 + for x in range(cx - r, cx + r + 1): 47 + dx, dy = x - cx + 0.5, y - cy + 0.5 48 + if dx * dx + dy * dy <= r * r: 49 + put(px, x, y, c) 50 + 51 + def ring(px, cx, cy, r_out, r_in, c): 52 + for y in range(cy - r_out, cy + r_out + 1): 53 + for x in range(cx - r_out, cx + r_out + 1): 54 + dx, dy = x - cx + 0.5, y - cy + 0.5 55 + d2 = dx * dx + dy * dy 56 + if r_in * r_in < d2 <= r_out * r_out: 57 + put(px, x, y, c) 58 + 59 + px = [CLEAR] * (W * H) 60 + 61 + # body: rows 4..13, cols 1..14 (10 high, 14 wide) 62 + rect(px, 1, 4, 14, 13, BODY) 63 + # body outline 64 + rect(px, 1, 4, 14, 4, EDGE) # top 65 + rect(px, 1, 13, 14, 13, EDGE) # bottom 66 + rect(px, 1, 4, 1, 13, EDGE) # left 67 + rect(px, 14, 4, 14, 13, EDGE) # right 68 + 69 + # top grip strip rows 2..3, cols 4..11 (viewfinder/pentaprism hump) 70 + rect(px, 4, 2, 11, 3, GRIP) 71 + rect(px, 4, 2, 11, 2, EDGE) # top outline 72 + rect(px, 3, 3, 3, 3, EDGE) 73 + rect(px, 12, 3, 12, 3, EDGE) 74 + 75 + # shutter button (top right): rows 2..3 col 12..13 76 + rect(px, 12, 2, 13, 3, SHUTR) 77 + put(px, 12, 2, SHUTR_E) 78 + put(px, 13, 2, SHUTR_E) 79 + put(px, 13, 3, SHUTR_E) 80 + 81 + # flash square (top left): rows 2..3 col 2..3 82 + rect(px, 2, 2, 3, 3, FLASH) 83 + put(px, 2, 2, FLASH_E) 84 + put(px, 3, 2, FLASH_E) 85 + put(px, 2, 3, FLASH_E) 86 + 87 + # lens: centered around (7.5, 8.5), radius 4 88 + cx, cy = 8, 9 89 + ring(px, cx, cy, 4, 3, LENS_O) 90 + circle_filled(px, cx, cy, 3, LENS_M) 91 + circle_filled(px, cx, cy, 2, SKY) 92 + # highlight dot 93 + put(px, cx - 1, cy - 1, LENS_I) 94 + put(px, cx - 2, cy - 1, LENS_I) 95 + 96 + import os 97 + out_dir = os.path.join(os.path.dirname(__file__), "src", "main", "resources", "assets", "bskycam", "textures", "item") 98 + os.makedirs(out_dir, exist_ok=True) 99 + make_png(px, os.path.join(out_dir, "camera.png")) 100 + print("wrote", os.path.join(out_dir, "camera.png"))
+12
settings.gradle
··· 1 + pluginManagement { 2 + repositories { 3 + maven { 4 + name = 'Fabric' 5 + url = 'https://maven.fabricmc.net/' 6 + } 7 + mavenCentral() 8 + gradlePluginPortal() 9 + } 10 + } 11 + 12 + rootProject.name = 'bskycam'
+207
src/client/java/com/florian/bskycam/client/BlueskyClient.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.google.gson.Gson; 4 + import com.google.gson.JsonObject; 5 + import java.net.URI; 6 + import java.net.URLEncoder; 7 + import java.net.http.HttpClient; 8 + import java.net.http.HttpRequest; 9 + import java.net.http.HttpRequest.BodyPublisher; 10 + import java.net.http.HttpRequest.BodyPublishers; 11 + import java.net.http.HttpResponse.BodyHandlers; 12 + import java.nio.charset.StandardCharsets; 13 + import java.time.Duration; 14 + import java.time.Instant; 15 + import java.time.format.DateTimeFormatter; 16 + 17 + public class BlueskyClient { 18 + private static final Gson GSON = new Gson(); 19 + private static final HttpClient HTTP = HttpClient.newBuilder() 20 + .connectTimeout(Duration.ofSeconds(15)) 21 + .build(); 22 + 23 + private final String serviceUrl; 24 + 25 + public BlueskyClient(String serviceUrl) { 26 + this.serviceUrl = serviceUrl.endsWith("/") ? serviceUrl.substring(0, serviceUrl.length() - 1) : serviceUrl; 27 + } 28 + 29 + private static final String DISCOVERY_ENTRY = "https://bsky.social"; 30 + 31 + /** 32 + * Resolve the PDS that hosts the given identifier (handle or DID). 33 + * For emails, returns the discovery entry — the entryway handles email logins. 34 + */ 35 + public static String resolvePds(String identifier) throws Exception { 36 + if (identifier.contains("@")) return DISCOVERY_ENTRY; 37 + 38 + String did = identifier.startsWith("did:") 39 + ? identifier 40 + : resolveHandle(identifier); 41 + return pdsFromDidDoc(did); 42 + } 43 + 44 + private static String resolveHandle(String handle) throws Exception { 45 + URI uri = URI.create(DISCOVERY_ENTRY + "/xrpc/com.atproto.identity.resolveHandle?handle=" 46 + + URLEncoder.encode(handle, StandardCharsets.UTF_8)); 47 + HttpRequest req = HttpRequest.newBuilder(uri).GET().timeout(Duration.ofSeconds(15)).build(); 48 + var resp = HTTP.send(req, BodyHandlers.ofString()); 49 + if (resp.statusCode() / 100 != 2) { 50 + throw new RuntimeException("resolveHandle failed " + resp.statusCode() + ": " + resp.body()); 51 + } 52 + return GSON.fromJson(resp.body(), JsonObject.class).get("did").getAsString(); 53 + } 54 + 55 + private static String pdsFromDidDoc(String did) throws Exception { 56 + URI uri; 57 + if (did.startsWith("did:plc:")) { 58 + uri = URI.create("https://plc.directory/" + did); 59 + } else if (did.startsWith("did:web:")) { 60 + String host = did.substring("did:web:".length()).replace(":", "/"); 61 + uri = URI.create("https://" + host + "/.well-known/did.json"); 62 + } else { 63 + throw new RuntimeException("Unsupported DID method: " + did); 64 + } 65 + HttpRequest req = HttpRequest.newBuilder(uri).GET().timeout(Duration.ofSeconds(15)).build(); 66 + var resp = HTTP.send(req, BodyHandlers.ofString()); 67 + if (resp.statusCode() / 100 != 2) { 68 + throw new RuntimeException("DID doc fetch failed " + resp.statusCode() + ": " + resp.body()); 69 + } 70 + JsonObject doc = GSON.fromJson(resp.body(), JsonObject.class); 71 + var services = doc.getAsJsonArray("service"); 72 + if (services != null) { 73 + for (var el : services) { 74 + JsonObject svc = el.getAsJsonObject(); 75 + String id = svc.get("id").getAsString(); 76 + if ("#atproto_pds".equals(id) || id.endsWith("#atproto_pds")) { 77 + return svc.get("serviceEndpoint").getAsString(); 78 + } 79 + } 80 + } 81 + throw new RuntimeException("No atproto_pds service in DID doc for " + did); 82 + } 83 + 84 + public record Session(String accessJwt, String did, String handle) {} 85 + 86 + public record PostResult(String uri, String cid, String handle) { 87 + public String webUrl() { 88 + String rkey = uri.substring(uri.lastIndexOf('/') + 1); 89 + return "https://bsky.app/profile/" + handle + "/post/" + rkey; 90 + } 91 + } 92 + 93 + public Session createSession(String identifier, String appPassword) throws Exception { 94 + JsonObject body = new JsonObject(); 95 + body.addProperty("identifier", identifier); 96 + body.addProperty("password", appPassword); 97 + 98 + JsonObject resp = postJson("/xrpc/com.atproto.server.createSession", null, body); 99 + return new Session( 100 + resp.get("accessJwt").getAsString(), 101 + resp.get("did").getAsString(), 102 + resp.get("handle").getAsString() 103 + ); 104 + } 105 + 106 + public JsonObject uploadBlob(Session session, byte[] imageBytes, String mimeType) throws Exception { 107 + HttpRequest req = HttpRequest.newBuilder() 108 + .uri(URI.create(serviceUrl + "/xrpc/com.atproto.repo.uploadBlob")) 109 + .timeout(Duration.ofSeconds(60)) 110 + .header("Authorization", "Bearer " + session.accessJwt()) 111 + .header("Content-Type", mimeType) 112 + .POST(BodyPublishers.ofByteArray(imageBytes)) 113 + .build(); 114 + 115 + var resp = HTTP.send(req, BodyHandlers.ofString()); 116 + if (resp.statusCode() / 100 != 2) { 117 + throw new RuntimeException("uploadBlob failed " + resp.statusCode() + ": " + resp.body()); 118 + } 119 + JsonObject respJson = GSON.fromJson(resp.body(), JsonObject.class); 120 + return respJson.getAsJsonObject("blob"); 121 + } 122 + 123 + public PostResult createTextPost(Session session, String text, JsonObject extraRecordFields) throws Exception { 124 + JsonObject record = new JsonObject(); 125 + record.addProperty("$type", "app.bsky.feed.post"); 126 + record.addProperty("text", text == null ? "" : text); 127 + record.addProperty("createdAt", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); 128 + if (extraRecordFields != null) { 129 + for (var entry : extraRecordFields.entrySet()) { 130 + record.add(entry.getKey(), entry.getValue()); 131 + } 132 + } 133 + JsonObject body = new JsonObject(); 134 + body.addProperty("repo", session.did()); 135 + body.addProperty("collection", "app.bsky.feed.post"); 136 + body.add("record", record); 137 + JsonObject resp = postJson("/xrpc/com.atproto.repo.createRecord", session.accessJwt(), body); 138 + return new PostResult( 139 + resp.get("uri").getAsString(), 140 + resp.get("cid").getAsString(), 141 + session.handle() 142 + ); 143 + } 144 + 145 + public PostResult createPost(Session session, JsonObject imageBlob, String text, String alt, int width, int height, JsonObject extraRecordFields) throws Exception { 146 + JsonObject image = new JsonObject(); 147 + image.addProperty("alt", alt == null ? "" : alt); 148 + image.add("image", imageBlob); 149 + 150 + if (width > 0 && height > 0) { 151 + JsonObject ar = new JsonObject(); 152 + ar.addProperty("width", width); 153 + ar.addProperty("height", height); 154 + image.add("aspectRatio", ar); 155 + } 156 + 157 + var imagesArr = new com.google.gson.JsonArray(); 158 + imagesArr.add(image); 159 + 160 + JsonObject embed = new JsonObject(); 161 + embed.addProperty("$type", "app.bsky.embed.images"); 162 + embed.add("images", imagesArr); 163 + 164 + JsonObject record = new JsonObject(); 165 + record.addProperty("$type", "app.bsky.feed.post"); 166 + record.addProperty("text", text == null ? "" : text); 167 + record.addProperty("createdAt", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); 168 + record.add("embed", embed); 169 + 170 + if (extraRecordFields != null) { 171 + for (var entry : extraRecordFields.entrySet()) { 172 + record.add(entry.getKey(), entry.getValue()); 173 + } 174 + } 175 + 176 + JsonObject body = new JsonObject(); 177 + body.addProperty("repo", session.did()); 178 + body.addProperty("collection", "app.bsky.feed.post"); 179 + body.add("record", record); 180 + 181 + JsonObject resp = postJson("/xrpc/com.atproto.repo.createRecord", session.accessJwt(), body); 182 + return new PostResult( 183 + resp.get("uri").getAsString(), 184 + resp.get("cid").getAsString(), 185 + session.handle() 186 + ); 187 + } 188 + 189 + private JsonObject postJson(String path, String bearer, JsonObject body) throws Exception { 190 + String jsonBody = GSON.toJson(body); 191 + BodyPublisher publisher = BodyPublishers.ofString(jsonBody); 192 + HttpRequest.Builder b = HttpRequest.newBuilder() 193 + .uri(URI.create(serviceUrl + path)) 194 + .timeout(Duration.ofSeconds(30)) 195 + .header("Content-Type", "application/json") 196 + .POST(publisher); 197 + if (bearer != null) { 198 + b.header("Authorization", "Bearer " + bearer); 199 + } 200 + var resp = HTTP.send(b.build(), BodyHandlers.ofString()); 201 + if (resp.statusCode() / 100 != 2) { 202 + com.florian.bskycam.BskyCam.LOGGER.error("POST {} body was: {}", path, jsonBody); 203 + throw new RuntimeException("POST " + path + " failed " + resp.statusCode() + ": " + resp.body()); 204 + } 205 + return GSON.fromJson(resp.body(), JsonObject.class); 206 + } 207 + }
+67
src/client/java/com/florian/bskycam/client/BskyCamClient.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.BskyCam; 4 + import com.florian.bskycam.ModEntities; 5 + import com.florian.bskycam.ModItems; 6 + import com.florian.bskycam.PhotoSignItem; 7 + import com.florian.bskycam.SignData; 8 + import com.florian.bskycam.TextSignItem; 9 + import com.florian.bskycam.client.entity.BskySignRenderer; 10 + import net.fabricmc.api.ClientModInitializer; 11 + import net.fabricmc.fabric.api.client.command.v2.ClientCommandRegistrationCallback; 12 + import net.fabricmc.fabric.api.client.command.v2.ClientCommands; 13 + import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; 14 + import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElementRegistry; 15 + import net.fabricmc.fabric.api.event.player.UseBlockCallback; 16 + import net.minecraft.client.Minecraft; 17 + import net.minecraft.core.Direction; 18 + import net.minecraft.resources.Identifier; 19 + import net.minecraft.world.InteractionResult; 20 + import net.minecraft.world.item.ItemStack; 21 + 22 + public class BskyCamClient implements ClientModInitializer { 23 + @Override 24 + public void onInitializeClient() { 25 + BskyConfig.load(); 26 + new CameraCapture().register(); 27 + HudElementRegistry.addLast( 28 + Identifier.fromNamespaceAndPath(BskyCam.MOD_ID, "overlay"), 29 + new BskyHudElement() 30 + ); 31 + 32 + EntityRendererRegistry.register(ModEntities.PHOTO_SIGN, BskySignRenderer::new); 33 + EntityRendererRegistry.register(ModEntities.TEXT_SIGN, BskySignRenderer::new); 34 + 35 + // Intercept block clicks (client-side) so unposted signs open the edit screen 36 + // instead of placing immediately. Posted signs fall through to Item.useOn. 37 + UseBlockCallback.EVENT.register((player, world, hand, hit) -> { 38 + if (!world.isClientSide()) return InteractionResult.PASS; 39 + ItemStack stack = player.getItemInHand(hand); 40 + boolean isSign = stack.getItem() instanceof PhotoSignItem || stack.getItem() instanceof TextSignItem; 41 + if (!isSign) return InteractionResult.PASS; 42 + BskyCam.LOGGER.info("UseBlockCallback: sign click, posted={} face={}", 43 + SignData.isPosted(stack), hit.getDirection()); 44 + Direction face = hit.getDirection(); 45 + if (face.getAxis() == Direction.Axis.Y) return InteractionResult.PASS; 46 + if (SignData.isPosted(stack)) return InteractionResult.PASS; 47 + Minecraft mc = Minecraft.getInstance(); 48 + if (stack.getItem() instanceof PhotoSignItem) { 49 + mc.setScreen(new PhotoSignEditScreen(stack, hand, hit.getBlockPos(), face)); 50 + return InteractionResult.SUCCESS; 51 + } 52 + if (stack.getItem() instanceof TextSignItem) { 53 + mc.setScreen(new TextSignEditScreen(stack, hand, hit.getBlockPos(), face)); 54 + return InteractionResult.SUCCESS; 55 + } 56 + return InteractionResult.PASS; 57 + }); 58 + 59 + ClientCommandRegistrationCallback.EVENT.register((dispatcher, ctx) -> { 60 + dispatcher.register(ClientCommands.literal("bskycam").executes(c -> { 61 + Minecraft mc = Minecraft.getInstance(); 62 + mc.execute(() -> mc.setScreen(new BskyConfigScreen(mc.screen))); 63 + return 1; 64 + })); 65 + }); 66 + } 67 + }
+54
src/client/java/com/florian/bskycam/client/BskyConfig.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.google.gson.Gson; 4 + import com.google.gson.GsonBuilder; 5 + import java.io.IOException; 6 + import java.nio.file.Files; 7 + import java.nio.file.Path; 8 + import net.fabricmc.loader.api.FabricLoader; 9 + 10 + public class BskyConfig { 11 + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); 12 + private static final String FILENAME = "bskycam.json"; 13 + 14 + public String identifier = ""; 15 + public String appPassword = ""; 16 + public String defaultText = "Sent from Minecraft"; 17 + public String defaultAlt = "A Minecraft screenshot"; 18 + public String pdsOverride = ""; 19 + 20 + public boolean isConfigured() { 21 + return identifier != null && !identifier.isBlank() 22 + && appPassword != null && !appPassword.isBlank(); 23 + } 24 + 25 + public static Path path() { 26 + return FabricLoader.getInstance().getConfigDir().resolve(FILENAME); 27 + } 28 + 29 + public static BskyConfig load() { 30 + Path path = path(); 31 + if (!Files.exists(path)) { 32 + BskyConfig fresh = new BskyConfig(); 33 + try { 34 + save(fresh); 35 + } catch (IOException ignored) { 36 + } 37 + return fresh; 38 + } 39 + try (var reader = Files.newBufferedReader(path)) { 40 + BskyConfig cfg = GSON.fromJson(reader, BskyConfig.class); 41 + return cfg != null ? cfg : new BskyConfig(); 42 + } catch (IOException e) { 43 + return new BskyConfig(); 44 + } 45 + } 46 + 47 + public static void save(BskyConfig cfg) throws IOException { 48 + Path path = path(); 49 + Files.createDirectories(path.getParent()); 50 + try (var writer = Files.newBufferedWriter(path)) { 51 + GSON.toJson(cfg, writer); 52 + } 53 + } 54 + }
+117
src/client/java/com/florian/bskycam/client/BskyConfigScreen.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.BskyCam; 4 + import java.io.IOException; 5 + import net.minecraft.ChatFormatting; 6 + import net.minecraft.client.gui.components.Button; 7 + import net.minecraft.client.gui.components.EditBox; 8 + import net.minecraft.client.gui.components.StringWidget; 9 + import net.minecraft.client.gui.screens.Screen; 10 + import net.minecraft.network.chat.Component; 11 + 12 + public class BskyConfigScreen extends Screen { 13 + private static final Component TITLE = Component.literal("Bluesky Camera"); 14 + private static final int BOX_W = 300; 15 + 16 + private final Screen parent; 17 + private final BskyConfig config; 18 + 19 + private EditBox identifierBox; 20 + private EditBox appPasswordBox; 21 + private EditBox pdsOverrideBox; 22 + private EditBox defaultTextBox; 23 + private EditBox defaultAltBox; 24 + 25 + public BskyConfigScreen(Screen parent) { 26 + super(TITLE); 27 + this.parent = parent; 28 + this.config = BskyConfig.load(); 29 + } 30 + 31 + @Override 32 + protected void init() { 33 + int cx = this.width / 2; 34 + int boxX = cx - BOX_W / 2; 35 + int y = 14; 36 + 37 + addRenderableWidget(new StringWidget(cx - BOX_W / 2, y, BOX_W, 12, 38 + Component.literal("Bluesky Camera"), this.font)); 39 + y += 24; 40 + 41 + addRenderableWidget(new StringWidget(boxX, y, BOX_W, 10, 42 + Component.literal("Handle, DID or email").withStyle(ChatFormatting.GRAY), this.font)); 43 + y += 12; 44 + identifierBox = new EditBox(this.font, boxX, y, BOX_W, 20, Component.literal("handle")); 45 + identifierBox.setMaxLength(253); 46 + identifierBox.setValue(config.identifier); 47 + addRenderableWidget(identifierBox); 48 + y += 30; 49 + 50 + addRenderableWidget(new StringWidget(boxX, y, BOX_W, 10, 51 + Component.literal("App Password (xxxx-xxxx-xxxx-xxxx)").withStyle(ChatFormatting.GRAY), this.font)); 52 + y += 12; 53 + appPasswordBox = new EditBox(this.font, boxX, y, BOX_W, 20, Component.literal("app password")); 54 + appPasswordBox.setMaxLength(64); 55 + appPasswordBox.setValue(config.appPassword); 56 + addRenderableWidget(appPasswordBox); 57 + y += 30; 58 + 59 + addRenderableWidget(new StringWidget(boxX, y, BOX_W, 10, 60 + Component.literal("Default post text").withStyle(ChatFormatting.GRAY), this.font)); 61 + y += 12; 62 + defaultTextBox = new EditBox(this.font, boxX, y, BOX_W, 20, Component.literal("caption")); 63 + defaultTextBox.setMaxLength(300); 64 + defaultTextBox.setValue(config.defaultText); 65 + addRenderableWidget(defaultTextBox); 66 + y += 30; 67 + 68 + addRenderableWidget(new StringWidget(boxX, y, BOX_W, 10, 69 + Component.literal("Default alt text").withStyle(ChatFormatting.GRAY), this.font)); 70 + y += 12; 71 + defaultAltBox = new EditBox(this.font, boxX, y, BOX_W, 20, Component.literal("alt")); 72 + defaultAltBox.setMaxLength(500); 73 + defaultAltBox.setValue(config.defaultAlt); 74 + addRenderableWidget(defaultAltBox); 75 + y += 30; 76 + 77 + addRenderableWidget(new StringWidget(boxX, y, BOX_W, 10, 78 + Component.literal("PDS override (optional, leave blank to auto-resolve)").withStyle(ChatFormatting.DARK_GRAY), this.font)); 79 + y += 12; 80 + pdsOverrideBox = new EditBox(this.font, boxX, y, BOX_W, 20, Component.literal("https://...")); 81 + pdsOverrideBox.setMaxLength(200); 82 + pdsOverrideBox.setValue(config.pdsOverride == null ? "" : config.pdsOverride); 83 + addRenderableWidget(pdsOverrideBox); 84 + y += 40; 85 + 86 + addRenderableWidget(new StringWidget(cx - 150, y, 300, 10, 87 + Component.literal("Stored in plain text at config/bskycam.json. App passwords are revocable at bsky.app.") 88 + .withStyle(ChatFormatting.DARK_GRAY), this.font)); 89 + y += 20; 90 + 91 + addRenderableWidget(Button.builder(Component.literal("Save"), b -> save()) 92 + .bounds(cx - 155, y, 150, 20).build()); 93 + addRenderableWidget(Button.builder(Component.literal("Cancel"), b -> onClose()) 94 + .bounds(cx + 5, y, 150, 20).build()); 95 + 96 + setInitialFocus(identifierBox); 97 + } 98 + 99 + private void save() { 100 + config.identifier = identifierBox.getValue().trim(); 101 + config.appPassword = appPasswordBox.getValue().trim(); 102 + config.defaultText = defaultTextBox.getValue(); 103 + config.defaultAlt = defaultAltBox.getValue(); 104 + config.pdsOverride = pdsOverrideBox.getValue().trim(); 105 + try { 106 + BskyConfig.save(config); 107 + } catch (IOException e) { 108 + BskyCam.LOGGER.error("Failed to save bskycam config", e); 109 + } 110 + onClose(); 111 + } 112 + 113 + @Override 114 + public void onClose() { 115 + this.minecraft.setScreen(parent); 116 + } 117 + }
+37
src/client/java/com/florian/bskycam/client/BskyHudElement.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import net.fabricmc.fabric.api.client.rendering.v1.hud.HudElement; 4 + import net.minecraft.ChatFormatting; 5 + import net.minecraft.client.DeltaTracker; 6 + import net.minecraft.client.Minecraft; 7 + import net.minecraft.client.gui.GuiGraphicsExtractor; 8 + import net.minecraft.network.chat.Component; 9 + 10 + public class BskyHudElement implements HudElement { 11 + public static final int FLASH_DURATION = 6; 12 + 13 + public static volatile boolean showHelper = false; 14 + public static volatile int flashTicks = 0; 15 + 16 + @Override 17 + public void extractRenderState(GuiGraphicsExtractor extractor, DeltaTracker tracker) { 18 + Minecraft mc = Minecraft.getInstance(); 19 + int w = extractor.guiWidth(); 20 + int h = extractor.guiHeight(); 21 + 22 + if (showHelper) { 23 + Component label = Component.literal("Left-click to take picture") 24 + .withStyle(ChatFormatting.WHITE); 25 + extractor.centeredText(mc.font, label, w / 2, h - 50, 0xFFFFFFFF); 26 + } 27 + 28 + int ft = flashTicks; 29 + if (ft > 0) { 30 + int alpha = (int) (230.0 * ft / FLASH_DURATION); 31 + if (alpha > 0) { 32 + int color = (alpha << 24) | 0x00FFFFFF; 33 + extractor.fill(0, 0, w, h, color); 34 + } 35 + } 36 + } 37 + }
+82
src/client/java/com/florian/bskycam/client/BskyPoster.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.BskyCam; 4 + import com.google.gson.JsonObject; 5 + import java.time.Instant; 6 + import java.time.format.DateTimeFormatter; 7 + import java.util.concurrent.CompletableFuture; 8 + import java.util.function.Consumer; 9 + import net.minecraft.ChatFormatting; 10 + import net.minecraft.client.Minecraft; 11 + import net.minecraft.network.chat.ClickEvent; 12 + import net.minecraft.network.chat.Component; 13 + import net.minecraft.network.chat.MutableComponent; 14 + import net.minecraft.network.chat.Style; 15 + 16 + public final class BskyPoster { 17 + private BskyPoster() {} 18 + 19 + public record Payload(byte[] imageBytes, int width, int height, String text, String alt, JsonObject minecraftData, String capturedAt) {} 20 + 21 + public static CompletableFuture<String> post(Payload payload, Consumer<String> onStatus) { 22 + return CompletableFuture.supplyAsync(() -> { 23 + BskyConfig cfg = BskyConfig.load(); 24 + if (!cfg.isConfigured()) { 25 + throw new RuntimeException("Not signed in. Run /bskycam to configure."); 26 + } 27 + try { 28 + String pds = (cfg.pdsOverride != null && !cfg.pdsOverride.isBlank()) 29 + ? cfg.pdsOverride 30 + : BlueskyClient.resolvePds(cfg.identifier); 31 + BlueskyClient client = new BlueskyClient(pds); 32 + BlueskyClient.Session session = client.createSession(cfg.identifier, cfg.appPassword); 33 + 34 + JsonObject extras = new JsonObject(); 35 + JsonObject meta = payload.minecraftData != null ? payload.minecraftData : new JsonObject(); 36 + if (payload.capturedAt != null && !payload.capturedAt.isBlank()) { 37 + meta.addProperty("capturedAt", payload.capturedAt); 38 + } 39 + meta.addProperty("postedAt", DateTimeFormatter.ISO_INSTANT.format(Instant.now())); 40 + extras.add("minecraftData", meta); 41 + 42 + String text = payload.text == null ? "" : payload.text; 43 + BlueskyClient.PostResult post; 44 + if (payload.imageBytes != null && payload.imageBytes.length > 0) { 45 + JsonObject blob = client.uploadBlob(session, payload.imageBytes, "image/jpeg"); 46 + String alt = payload.alt == null ? cfg.defaultAlt : payload.alt; 47 + post = client.createPost(session, blob, text, alt, payload.width, payload.height, extras); 48 + } else { 49 + post = client.createTextPost(session, text, extras); 50 + } 51 + return post.webUrl(); 52 + } catch (Exception e) { 53 + BskyCam.LOGGER.error("Bluesky post failed", e); 54 + throw new RuntimeException(e.getMessage(), e); 55 + } 56 + }); 57 + } 58 + 59 + public static void postWithChatFeedback(Minecraft mc, Payload payload, Runnable onSuccess) { 60 + sendSystem(mc, Component.literal("[BskyCam] Posting…").withStyle(ChatFormatting.GRAY)); 61 + post(payload, null).whenComplete((url, err) -> mc.execute(() -> { 62 + if (err != null) { 63 + Throwable cause = err.getCause() != null ? err.getCause() : err; 64 + sendSystem(mc, Component.literal("[BskyCam] Post failed: " + cause.getMessage()) 65 + .withStyle(s -> s.withColor(ChatFormatting.RED))); 66 + } else { 67 + MutableComponent link = Component.literal(url) 68 + .withStyle(Style.EMPTY 69 + .withColor(ChatFormatting.AQUA) 70 + .withUnderlined(true) 71 + .withClickEvent(new ClickEvent.OpenUrl(java.net.URI.create(url)))); 72 + sendSystem(mc, Component.literal("[BskyCam] Posted! ").append(link) 73 + .withStyle(s -> s.withColor(ChatFormatting.GREEN))); 74 + if (onSuccess != null) onSuccess.run(); 75 + } 76 + })); 77 + } 78 + 79 + private static void sendSystem(Minecraft mc, Component msg) { 80 + if (mc.player != null) mc.player.sendSystemMessage(msg); 81 + } 82 + }
+344
src/client/java/com/florian/bskycam/client/CameraCapture.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.BskyCam; 4 + import com.florian.bskycam.ModItems; 5 + import com.florian.bskycam.SignData; 6 + import com.google.gson.Gson; 7 + import com.google.gson.JsonArray; 8 + import com.google.gson.JsonObject; 9 + import com.mojang.blaze3d.platform.NativeImage; 10 + import java.awt.Graphics2D; 11 + import java.awt.RenderingHints; 12 + import java.awt.image.BufferedImage; 13 + import java.io.ByteArrayOutputStream; 14 + import java.io.IOException; 15 + import java.nio.file.Files; 16 + import java.nio.file.Path; 17 + import java.time.Instant; 18 + import java.time.LocalDateTime; 19 + import java.time.format.DateTimeFormatter; 20 + import javax.imageio.IIOImage; 21 + import javax.imageio.ImageIO; 22 + import javax.imageio.ImageWriteParam; 23 + import javax.imageio.ImageWriter; 24 + import javax.imageio.stream.ImageOutputStream; 25 + import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; 26 + import net.minecraft.ChatFormatting; 27 + import net.minecraft.client.Minecraft; 28 + import net.minecraft.client.Screenshot; 29 + import net.minecraft.client.multiplayer.ClientLevel; 30 + import net.minecraft.client.player.LocalPlayer; 31 + import net.minecraft.nbt.CompoundTag; 32 + import net.minecraft.network.chat.Component; 33 + import net.minecraft.sounds.SoundEvents; 34 + import net.minecraft.world.InteractionHand; 35 + import net.minecraft.world.item.ItemStack; 36 + 37 + public class CameraCapture { 38 + private static final DateTimeFormatter TS = DateTimeFormatter.ofPattern("yyyy-MM-dd_HH.mm.ss"); 39 + private static final int MAX_UPLOAD_BYTES = 950_000; 40 + private static final float[] JPEG_QUALITIES = {0.85f, 0.70f, 0.55f, 0.40f}; 41 + private static final Gson GSON = new Gson(); 42 + 43 + private int captureDelayTicks = 0; 44 + private boolean savedHideGui = false; 45 + 46 + public void register() { 47 + ClientTickEvents.START_CLIENT_TICK.register(this::onTick); 48 + } 49 + 50 + private void onTick(Minecraft mc) { 51 + LocalPlayer player = mc.player; 52 + if (player == null) { 53 + BskyHudElement.showHelper = false; 54 + return; 55 + } 56 + 57 + boolean usingCamera = player.isUsingItem() && player.getUseItem().is(ModItems.CAMERA); 58 + 59 + // Sneak + right-click while holding camera opens the config screen. 60 + if (!usingCamera && player.isCrouching() && player.getMainHandItem().is(ModItems.CAMERA)) { 61 + boolean usePressed = false; 62 + while (mc.options.keyUse.consumeClick()) usePressed = true; 63 + if (usePressed) { 64 + mc.setScreen(new BskyConfigScreen(null)); 65 + return; 66 + } 67 + } 68 + 69 + // Right-click in AIR while holding an unposted photo/text sign opens the edit screen 70 + // (for posting without placing). Clicks on block faces are handled by UseBlockCallback. 71 + if (mc.screen == null && !usingCamera && mc.hitResult != null 72 + && mc.hitResult.getType() == net.minecraft.world.phys.HitResult.Type.MISS) { 73 + ItemStack main = player.getMainHandItem(); 74 + if (main.is(ModItems.PHOTO_SIGN) && !com.florian.bskycam.SignData.isPosted(main)) { 75 + boolean usePressed = false; 76 + while (mc.options.keyUse.consumeClick()) usePressed = true; 77 + if (usePressed) { 78 + mc.setScreen(new PhotoSignEditScreen(main, InteractionHand.MAIN_HAND)); 79 + return; 80 + } 81 + } else if (main.is(ModItems.TEXT_SIGN) && !com.florian.bskycam.SignData.isPosted(main)) { 82 + boolean usePressed = false; 83 + while (mc.options.keyUse.consumeClick()) usePressed = true; 84 + if (usePressed) { 85 + mc.setScreen(new TextSignEditScreen(main, InteractionHand.MAIN_HAND)); 86 + return; 87 + } 88 + } 89 + } 90 + 91 + // Finish a pending capture after waiting for a HUD-free render frame. 92 + if (captureDelayTicks > 0) { 93 + captureDelayTicks--; 94 + if (captureDelayTicks == 0) { 95 + performCapture(mc); 96 + } 97 + } 98 + 99 + // Fade the flash one tick at a time. 100 + if (BskyHudElement.flashTicks > 0) { 101 + BskyHudElement.flashTicks--; 102 + } 103 + 104 + BskyHudElement.showHelper = usingCamera && captureDelayTicks == 0; 105 + 106 + if (usingCamera) { 107 + boolean clicked = false; 108 + while (mc.options.keyAttack.consumeClick()) { 109 + clicked = true; 110 + } 111 + if (clicked && captureDelayTicks == 0) { 112 + beginCapture(mc); 113 + } 114 + } 115 + } 116 + 117 + private void beginCapture(Minecraft mc) { 118 + savedHideGui = mc.options.hideGui; 119 + mc.options.hideGui = true; 120 + captureDelayTicks = 2; 121 + } 122 + 123 + private void performCapture(Minecraft mc) { 124 + Screenshot.takeScreenshot(mc.getMainRenderTarget(), image -> handleImage(mc, image)); 125 + 126 + mc.options.hideGui = savedHideGui; 127 + 128 + if (mc.player != null) { 129 + mc.player.playSound(SoundEvents.TRIPWIRE_CLICK_ON, 1.0f, 1.3f); 130 + } 131 + BskyHudElement.flashTicks = BskyHudElement.FLASH_DURATION; 132 + } 133 + 134 + private void handleImage(Minecraft mc, NativeImage image) { 135 + Path screenshotsDir = mc.gameDirectory.toPath().resolve("screenshots"); 136 + String stamp = LocalDateTime.now().format(TS); 137 + Path pngPath = screenshotsDir.resolve("bskycam-" + stamp + ".png"); 138 + 139 + BufferedImage buffered; 140 + byte[] jpegBytes; 141 + int width; 142 + int height; 143 + try { 144 + Files.createDirectories(screenshotsDir); 145 + image.writeToFile(pngPath); 146 + buffered = toBufferedImage(image); 147 + } catch (Exception e) { 148 + BskyCam.LOGGER.error("Failed to save screenshot", e); 149 + sendMessage(mc, Component.literal("[BskyCam] Failed to save screenshot: " + e.getMessage()) 150 + .withStyle(s -> s.withColor(ChatFormatting.RED))); 151 + return; 152 + } finally { 153 + image.close(); 154 + } 155 + 156 + try { 157 + Compressed c = compressForUpload(buffered); 158 + jpegBytes = c.bytes; 159 + width = c.width; 160 + height = c.height; 161 + } catch (IOException e) { 162 + BskyCam.LOGGER.error("Failed to compress image", e); 163 + sendMessage(mc, Component.literal("[BskyCam] Compression failed: " + e.getMessage()) 164 + .withStyle(s -> s.withColor(ChatFormatting.RED))); 165 + return; 166 + } 167 + 168 + String capturedAt = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); 169 + JsonObject meta = collectMetadataPublic(mc); 170 + 171 + ItemStack signStack = new ItemStack(ModItems.PHOTO_SIGN); 172 + CompoundTag tag = new CompoundTag(); 173 + tag.putByteArray(SignData.TAG_PHOTO, jpegBytes); 174 + tag.putInt(SignData.TAG_WIDTH, width); 175 + tag.putInt(SignData.TAG_HEIGHT, height); 176 + tag.putString(SignData.TAG_CAPTURED_AT, capturedAt); 177 + tag.putString(SignData.TAG_MINECRAFT_DATA, GSON.toJson(meta)); 178 + tag.putBoolean(SignData.TAG_POSTED, false); 179 + SignData.write(signStack, tag); 180 + 181 + if (mc.player != null) { 182 + net.minecraft.server.MinecraftServer server = mc.getSingleplayerServer(); 183 + if (server != null) { 184 + java.util.UUID uuid = mc.player.getUUID(); 185 + final net.minecraft.world.item.ItemStack toGive = signStack.copy(); 186 + server.execute(() -> { 187 + net.minecraft.server.level.ServerPlayer sp = server.getPlayerList().getPlayer(uuid); 188 + if (sp != null) { 189 + if (!sp.getInventory().add(toGive)) { 190 + sp.drop(toGive, false); 191 + } 192 + sp.inventoryMenu.broadcastChanges(); 193 + } 194 + }); 195 + } else { 196 + boolean added = mc.player.getInventory().add(signStack); 197 + if (!added) mc.player.drop(signStack, false); 198 + } 199 + } 200 + 201 + sendMessage(mc, Component.literal("[BskyCam] Photo sign added to inventory (" + (jpegBytes.length / 1024) + " KB). Right-click a wall to post + place.") 202 + .withStyle(s -> s.withColor(ChatFormatting.GREEN))); 203 + } 204 + 205 + private static void sendMessage(Minecraft mc, Component msg) { 206 + if (mc.player != null) { 207 + mc.player.sendSystemMessage(msg); 208 + } 209 + } 210 + 211 + public static JsonObject collectMetadataPublic(Minecraft mc) { 212 + return collectMetadata(mc); 213 + } 214 + 215 + private static JsonObject collectMetadata(Minecraft mc) { 216 + JsonObject m = new JsonObject(); 217 + LocalPlayer p = mc.player; 218 + ClientLevel lvl = mc.level; 219 + if (p == null || lvl == null) return m; 220 + 221 + m.addProperty("playerName", p.getName().getString()); 222 + if (mc.gameMode != null) { 223 + m.addProperty("gameMode", mc.gameMode.getPlayerMode().getSerializedName()); 224 + } 225 + // atproto records disallow floats; store them as strings. 226 + m.addProperty("health", Float.toString(p.getHealth())); 227 + m.addProperty("maxHealth", Float.toString(p.getMaxHealth())); 228 + m.addProperty("food", p.getFoodData().getFoodLevel()); 229 + m.addProperty("saturation", Float.toString(p.getFoodData().getSaturationLevel())); 230 + m.addProperty("xpLevel", p.experienceLevel); 231 + m.addProperty("xpProgress", Float.toString(p.experienceProgress)); 232 + 233 + JsonObject pos = new JsonObject(); 234 + pos.addProperty("x", Double.toString(p.getX())); 235 + pos.addProperty("y", Double.toString(p.getY())); 236 + pos.addProperty("z", Double.toString(p.getZ())); 237 + m.add("position", pos); 238 + JsonArray facing = new JsonArray(); 239 + facing.add(Float.toString(p.getYRot())); 240 + facing.add(Float.toString(p.getXRot())); 241 + m.add("facing", facing); 242 + 243 + m.addProperty("dimension", lvl.dimension().identifier().toString()); 244 + long gameTime = lvl.getLevelData().getGameTime(); 245 + m.addProperty("gameTime", gameTime); 246 + m.addProperty("dayTime", gameTime % 24000L); 247 + m.addProperty("difficulty", lvl.getLevelData().getDifficulty().getSerializedName()); 248 + m.addProperty("hardcore", lvl.getLevelData().isHardcore()); 249 + m.addProperty("raining", lvl.isRaining()); 250 + m.addProperty("thundering", lvl.isThundering()); 251 + 252 + try { 253 + lvl.getBiome(p.blockPosition()).unwrapKey() 254 + .ifPresent(k -> m.addProperty("biome", k.identifier().toString())); 255 + } catch (Exception ignored) {} 256 + 257 + try { 258 + var server = mc.getSingleplayerServer(); 259 + if (server != null) { 260 + var sl = server.getLevel(lvl.dimension()); 261 + // Seed is a 64-bit long; JS-based servers (like Bluesky's PDS) lose 262 + // precision past 2^53, so serialize as a string. 263 + if (sl != null) m.addProperty("seed", Long.toString(sl.getSeed())); 264 + } 265 + } catch (Exception ignored) {} 266 + 267 + try { 268 + var remote = mc.getCurrentServer(); 269 + if (remote != null) { 270 + m.addProperty("serverName", remote.name); 271 + m.addProperty("serverAddress", remote.ip); 272 + } 273 + } catch (Exception ignored) {} 274 + 275 + m.addProperty("minecraftVersion", mc.getLaunchedVersion()); 276 + return m; 277 + } 278 + 279 + private record Compressed(byte[] bytes, int width, int height) {} 280 + 281 + private static BufferedImage toBufferedImage(NativeImage img) { 282 + int w = img.getWidth(); 283 + int h = img.getHeight(); 284 + int[] abgr = img.getPixelsABGR(); 285 + int[] argb = new int[abgr.length]; 286 + for (int i = 0; i < abgr.length; i++) { 287 + int p = abgr[i]; 288 + int r = p & 0xFF; 289 + int g = (p >>> 8) & 0xFF; 290 + int b = (p >>> 16) & 0xFF; 291 + argb[i] = 0xFF000000 | (r << 16) | (g << 8) | b; 292 + } 293 + BufferedImage bi = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); 294 + bi.setRGB(0, 0, w, h, argb, 0, w); 295 + return bi; 296 + } 297 + 298 + private static Compressed compressForUpload(BufferedImage src) throws IOException { 299 + BufferedImage current = src; 300 + while (true) { 301 + for (float q : JPEG_QUALITIES) { 302 + byte[] bytes = encodeJpeg(current, q); 303 + if (bytes.length <= MAX_UPLOAD_BYTES) { 304 + return new Compressed(bytes, current.getWidth(), current.getHeight()); 305 + } 306 + } 307 + int w = current.getWidth(); 308 + int h = current.getHeight(); 309 + if (w <= 640 || h <= 360) { 310 + throw new IOException("Image still exceeds 1 MB at minimum resolution"); 311 + } 312 + int nw = Math.max(640, w * 3 / 4); 313 + int nh = Math.max(360, h * 3 / 4); 314 + current = downscale(current, nw, nh); 315 + } 316 + } 317 + 318 + private static BufferedImage downscale(BufferedImage src, int w, int h) { 319 + BufferedImage dst = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); 320 + Graphics2D g = dst.createGraphics(); 321 + g.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); 322 + g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); 323 + g.drawImage(src, 0, 0, w, h, null); 324 + g.dispose(); 325 + return dst; 326 + } 327 + 328 + private static byte[] encodeJpeg(BufferedImage img, float quality) throws IOException { 329 + ImageWriter writer = ImageIO.getImageWritersByFormatName("jpeg").next(); 330 + try { 331 + ImageWriteParam param = writer.getDefaultWriteParam(); 332 + param.setCompressionMode(ImageWriteParam.MODE_EXPLICIT); 333 + param.setCompressionQuality(quality); 334 + ByteArrayOutputStream baos = new ByteArrayOutputStream(); 335 + try (ImageOutputStream ios = ImageIO.createImageOutputStream(baos)) { 336 + writer.setOutput(ios); 337 + writer.write(null, new IIOImage(img, null, null), param); 338 + } 339 + return baos.toByteArray(); 340 + } finally { 341 + writer.dispose(); 342 + } 343 + } 344 + }
+238
src/client/java/com/florian/bskycam/client/PhotoSignEditScreen.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.BskyCam; 4 + import com.florian.bskycam.ModItems; 5 + import com.florian.bskycam.SignData; 6 + import com.google.gson.Gson; 7 + import com.google.gson.JsonObject; 8 + import com.mojang.blaze3d.platform.NativeImage; 9 + import net.minecraft.ChatFormatting; 10 + import net.minecraft.client.Minecraft; 11 + import net.minecraft.client.gui.GuiGraphicsExtractor; 12 + import net.minecraft.client.gui.components.Button; 13 + import net.minecraft.client.gui.components.EditBox; 14 + import net.minecraft.client.gui.components.StringWidget; 15 + import net.minecraft.client.gui.screens.Screen; 16 + import net.minecraft.client.renderer.RenderPipelines; 17 + import net.minecraft.client.renderer.texture.DynamicTexture; 18 + import net.minecraft.network.chat.Component; 19 + import net.minecraft.resources.Identifier; 20 + import net.minecraft.core.BlockPos; 21 + import net.minecraft.core.Direction; 22 + import net.minecraft.world.InteractionHand; 23 + import net.minecraft.world.item.ItemStack; 24 + 25 + public class PhotoSignEditScreen extends Screen { 26 + private static final int TEXT_MAX = 300; 27 + private static final Gson GSON = new Gson(); 28 + 29 + private final ItemStack stack; 30 + private final InteractionHand hand; 31 + private final byte[] jpegBytes; 32 + private final int imgWidth; 33 + private final int imgHeight; 34 + private final BlockPos targetPos; 35 + private final Direction targetFace; 36 + 37 + private EditBox textBox; 38 + private EditBox altBox; 39 + private Identifier previewTexture; 40 + private int texturePixelW; 41 + private int texturePixelH; 42 + 43 + public PhotoSignEditScreen(ItemStack stack, InteractionHand hand) { 44 + this(stack, hand, null, null); 45 + } 46 + 47 + public PhotoSignEditScreen(ItemStack stack, InteractionHand hand, BlockPos targetPos, Direction targetFace) { 48 + super(Component.literal("Post Photo")); 49 + this.stack = stack; 50 + this.hand = hand; 51 + this.jpegBytes = SignData.getPhoto(stack); 52 + this.imgWidth = SignData.getWidth(stack); 53 + this.imgHeight = SignData.getHeight(stack); 54 + this.targetPos = targetPos; 55 + this.targetFace = targetFace; 56 + } 57 + 58 + @Override 59 + protected void init() { 60 + int cx = this.width / 2; 61 + 62 + int previewW = 260; 63 + int previewH = Math.max(80, Math.min(180, imgHeight > 0 ? previewW * imgHeight / imgWidth : 130)); 64 + int previewX = cx - previewW / 2; 65 + int previewY = 20; 66 + 67 + if (previewTexture == null && jpegBytes != null && jpegBytes.length > 0) { 68 + int previewMaxW = 260; 69 + int previewMaxH = 180; 70 + NativeImage img = decodeJpegScaled(jpegBytes, previewMaxW, previewMaxH); 71 + if (img != null) { 72 + try { 73 + texturePixelW = img.getWidth(); 74 + texturePixelH = img.getHeight(); 75 + DynamicTexture tex = new DynamicTexture(() -> "bskycam-preview", img); 76 + tex.upload(); 77 + previewTexture = Identifier.fromNamespaceAndPath("bskycam", 78 + "preview_" + Long.toHexString(System.nanoTime())); 79 + this.minecraft.getTextureManager().register(previewTexture, tex); 80 + BskyCam.LOGGER.info("Preview texture registered: {}x{}", texturePixelW, texturePixelH); 81 + } catch (Exception e) { 82 + BskyCam.LOGGER.error("Preview texture register failed", e); 83 + previewTexture = null; 84 + } 85 + } else { 86 + BskyCam.LOGGER.warn("Could not decode JPEG preview ({} bytes)", jpegBytes.length); 87 + } 88 + } 89 + 90 + int y = previewY + previewH + 12; 91 + 92 + int boxW = 360; 93 + addRenderableWidget(new StringWidget(cx - boxW / 2, y, boxW, 10, 94 + Component.literal("Caption").withStyle(ChatFormatting.GRAY), this.font)); 95 + y += 12; 96 + textBox = new EditBox(this.font, cx - boxW / 2, y, boxW, 20, Component.literal("caption")); 97 + textBox.setMaxLength(TEXT_MAX); 98 + textBox.setValue(SignData.getText(stack)); 99 + addRenderableWidget(textBox); 100 + y += 30; 101 + 102 + addRenderableWidget(new StringWidget(cx - boxW / 2, y, boxW, 10, 103 + Component.literal("Alt text (accessibility)").withStyle(ChatFormatting.GRAY), this.font)); 104 + y += 12; 105 + BskyConfig cfg = BskyConfig.load(); 106 + altBox = new EditBox(this.font, cx - boxW / 2, y, boxW, 20, Component.literal("alt")); 107 + altBox.setMaxLength(500); 108 + altBox.setValue(cfg.defaultAlt == null ? "" : cfg.defaultAlt); 109 + addRenderableWidget(altBox); 110 + y += 30; 111 + 112 + boolean posted = SignData.isPosted(stack); 113 + addRenderableWidget(Button.builder(Component.literal(posted ? "Already posted" : "Post to Bluesky"), b -> post()) 114 + .bounds(cx - 155, y, 150, 20).build()).active = !posted; 115 + addRenderableWidget(Button.builder(Component.literal("Cancel"), b -> onClose()) 116 + .bounds(cx + 5, y, 150, 20).build()); 117 + 118 + setInitialFocus(textBox); 119 + } 120 + 121 + private record PreviewInfo(int x, int y, int w, int h) {} 122 + 123 + private PreviewInfo previewInfo() { 124 + int cx = this.width / 2; 125 + // Use the actual scaled texture dimensions so draw size == texture size (no UV scaling). 126 + int w = texturePixelW > 0 ? texturePixelW : 260; 127 + int h = texturePixelH > 0 ? texturePixelH : 130; 128 + return new PreviewInfo(cx - w / 2, 20, w, h); 129 + } 130 + 131 + @Override 132 + public void extractRenderState(GuiGraphicsExtractor e, int mouseX, int mouseY, float partial) { 133 + super.extractRenderState(e, mouseX, mouseY, partial); 134 + PreviewInfo p = previewInfo(); 135 + e.fill(p.x - 1, p.y - 1, p.x + p.w + 1, p.y + p.h + 1, 0xFF222222); 136 + if (previewTexture != null && texturePixelW > 0 && texturePixelH > 0) { 137 + // 12-arg blit: (pipeline, id, x, y, u, v, uWidth, vHeight, drawW, drawH, texW, texH) 138 + e.blit( 139 + RenderPipelines.GUI_TEXTURED, 140 + previewTexture, 141 + p.x, p.y, 142 + 0f, 0f, 143 + texturePixelW, texturePixelH, 144 + p.w, p.h, 145 + texturePixelW, texturePixelH 146 + ); 147 + } 148 + } 149 + 150 + private void post() { 151 + String text = textBox.getValue(); 152 + String alt = altBox.getValue(); 153 + 154 + JsonObject minecraftData = parseJson(SignData.getMinecraftDataJson(stack)); 155 + 156 + BskyPoster.Payload payload = new BskyPoster.Payload( 157 + jpegBytes, 158 + imgWidth, 159 + imgHeight, 160 + text, 161 + alt, 162 + minecraftData, 163 + SignData.getCapturedAt(stack) 164 + ); 165 + 166 + Minecraft mc = this.minecraft; 167 + final String finalText = text; 168 + BskyPoster.post(payload, null).whenComplete((url, err) -> mc.execute(() -> { 169 + if (err != null) { 170 + Throwable cause = err.getCause() != null ? err.getCause() : err; 171 + if (mc.player != null) { 172 + mc.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[BskyCam] Post failed: " + cause.getMessage()) 173 + .withStyle(s -> s.withColor(net.minecraft.ChatFormatting.RED))); 174 + } 175 + } else if (mc.player != null) { 176 + SignPostHelper.markPostedAndMaybePlace(mc, hand, ModItems.PHOTO_SIGN, finalText, url, targetPos, targetFace, true); 177 + net.minecraft.network.chat.MutableComponent link = net.minecraft.network.chat.Component.literal(url) 178 + .withStyle(net.minecraft.network.chat.Style.EMPTY 179 + .withColor(net.minecraft.ChatFormatting.AQUA) 180 + .withUnderlined(true) 181 + .withClickEvent(new net.minecraft.network.chat.ClickEvent.OpenUrl(java.net.URI.create(url)))); 182 + mc.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[BskyCam] Posted! ").append(link) 183 + .withStyle(s -> s.withColor(net.minecraft.ChatFormatting.GREEN))); 184 + } 185 + })); 186 + if (mc.player != null) mc.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[BskyCam] Posting…") 187 + .withStyle(s -> s.withColor(net.minecraft.ChatFormatting.GRAY))); 188 + onClose(); 189 + } 190 + 191 + @Override 192 + public void onClose() { 193 + releasePreview(); 194 + this.minecraft.setScreen(null); 195 + } 196 + 197 + private void releasePreview() { 198 + if (previewTexture != null) { 199 + try { 200 + this.minecraft.getTextureManager().release(previewTexture); 201 + } catch (Exception ignored) {} 202 + previewTexture = null; 203 + } 204 + } 205 + 206 + private static NativeImage decodeJpegScaled(byte[] jpeg, int maxW, int maxH) { 207 + try { 208 + java.awt.image.BufferedImage src = javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(jpeg)); 209 + if (src == null) return null; 210 + int srcW = src.getWidth(); 211 + int srcH = src.getHeight(); 212 + double scale = Math.min((double) maxW / srcW, (double) maxH / srcH); 213 + int dstW = Math.max(1, (int) Math.round(srcW * scale)); 214 + int dstH = Math.max(1, (int) Math.round(srcH * scale)); 215 + java.awt.image.BufferedImage dst = new java.awt.image.BufferedImage(dstW, dstH, java.awt.image.BufferedImage.TYPE_INT_ARGB); 216 + java.awt.Graphics2D g = dst.createGraphics(); 217 + g.setRenderingHint(java.awt.RenderingHints.KEY_INTERPOLATION, java.awt.RenderingHints.VALUE_INTERPOLATION_BILINEAR); 218 + g.drawImage(src, 0, 0, dstW, dstH, null); 219 + g.dispose(); 220 + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); 221 + javax.imageio.ImageIO.write(dst, "png", baos); 222 + return NativeImage.read(new java.io.ByteArrayInputStream(baos.toByteArray())); 223 + } catch (Exception e) { 224 + BskyCam.LOGGER.error("JPEG→NativeImage decode failed", e); 225 + return null; 226 + } 227 + } 228 + 229 + private static JsonObject parseJson(String s) { 230 + if (s == null || s.isBlank()) return new JsonObject(); 231 + try { 232 + JsonObject o = GSON.fromJson(s, JsonObject.class); 233 + return o != null ? o : new JsonObject(); 234 + } catch (Exception e) { 235 + return new JsonObject(); 236 + } 237 + } 238 + }
+130
src/client/java/com/florian/bskycam/client/SignPostHelper.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.ModItems; 4 + import com.florian.bskycam.PhotoSignEntity; 5 + import com.florian.bskycam.SignData; 6 + import com.florian.bskycam.TextSignEntity; 7 + import java.util.UUID; 8 + import net.minecraft.client.Minecraft; 9 + import net.minecraft.core.BlockPos; 10 + import net.minecraft.core.Direction; 11 + import net.minecraft.resources.ResourceKey; 12 + import net.minecraft.server.MinecraftServer; 13 + import net.minecraft.server.level.ServerLevel; 14 + import net.minecraft.server.level.ServerPlayer; 15 + import net.minecraft.world.InteractionHand; 16 + import net.minecraft.world.item.Item; 17 + import net.minecraft.world.item.ItemStack; 18 + import net.minecraft.world.level.Level; 19 + 20 + public final class SignPostHelper { 21 + private SignPostHelper() {} 22 + 23 + /** Updates the held stack's NBT to posted state, and optionally places a sign entity at target. */ 24 + public static void markPostedAndMaybePlace(Minecraft mc, InteractionHand hand, Item expectedItem, 25 + String text, String url, BlockPos targetPos, Direction targetFace, 26 + boolean isPhoto) { 27 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: entering, target={} face={} isPhoto={}", 28 + targetPos, targetFace, isPhoto); 29 + MinecraftServer server = mc.getSingleplayerServer(); 30 + if (server == null) { 31 + com.florian.bskycam.BskyCam.LOGGER.warn("SignPostHelper: no singleplayer server — cannot place"); 32 + // Multiplayer not supported — best-effort client-side mutation. 33 + if (mc.player != null) { 34 + ItemStack held = mc.player.getItemInHand(hand); 35 + if (held.getItem() == expectedItem) { 36 + SignData.mutate(held, t -> { 37 + t.putBoolean(SignData.TAG_POSTED, true); 38 + t.putString(SignData.TAG_POST_URL, url); 39 + t.putString(SignData.TAG_TEXT, text); 40 + }); 41 + } 42 + } 43 + return; 44 + } 45 + 46 + UUID uuid = mc.player.getUUID(); 47 + ResourceKey<Level> dim = mc.level.dimension(); 48 + 49 + server.execute(() -> { 50 + ServerPlayer sp = server.getPlayerList().getPlayer(uuid); 51 + if (sp == null) { 52 + com.florian.bskycam.BskyCam.LOGGER.warn("SignPostHelper: ServerPlayer not found for uuid {}", uuid); 53 + return; 54 + } 55 + ItemStack serverStack = sp.getItemInHand(hand); 56 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: server stack = {} x{} (expected {})", 57 + serverStack.getItem(), serverStack.getCount(), expectedItem); 58 + if (serverStack.getItem() != expectedItem) { 59 + com.florian.bskycam.BskyCam.LOGGER.warn("SignPostHelper: item mismatch, aborting"); 60 + return; 61 + } 62 + 63 + // Split one off if stacked, so we don't mark the whole pile as posted. 64 + ItemStack postedOne; 65 + boolean split = serverStack.getCount() > 1; 66 + if (split) { 67 + postedOne = serverStack.split(1); 68 + } else { 69 + postedOne = serverStack; 70 + } 71 + 72 + SignData.mutate(postedOne, t -> { 73 + t.putBoolean(SignData.TAG_POSTED, true); 74 + t.putString(SignData.TAG_POST_URL, url); 75 + t.putString(SignData.TAG_TEXT, text); 76 + }); 77 + 78 + boolean placed = false; 79 + if (targetPos != null && targetFace != null) { 80 + ServerLevel lvl = server.getLevel(dim); 81 + if (lvl != null) { 82 + BlockPos signPos = targetPos.relative(targetFace); 83 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: signPos={} isEmpty={} blockState={}", 84 + signPos, lvl.isEmptyBlock(signPos), lvl.getBlockState(signPos)); 85 + if (lvl.isEmptyBlock(signPos)) { 86 + if (isPhoto) { 87 + PhotoSignEntity e = new PhotoSignEntity(lvl, signPos, targetFace, postedOne); 88 + boolean survives = e.survives(); 89 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: photo entity survives={}", survives); 90 + if (survives) { 91 + lvl.addFreshEntity(e); 92 + placed = true; 93 + if (sp.getAbilities().instabuild) { 94 + giveOrDrop(sp, postedOne); 95 + } 96 + } 97 + } else { 98 + TextSignEntity e = new TextSignEntity(lvl, signPos, targetFace, postedOne); 99 + boolean survives = e.survives(); 100 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: text entity survives={}", survives); 101 + if (survives) { 102 + lvl.addFreshEntity(e); 103 + placed = true; 104 + if (sp.getAbilities().instabuild) { 105 + giveOrDrop(sp, postedOne); 106 + } 107 + } 108 + } 109 + } 110 + } 111 + } 112 + 113 + if (!placed) { 114 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: not placed, giving item back (split={})", split); 115 + if (split) { 116 + giveOrDrop(sp, postedOne); 117 + } 118 + } 119 + 120 + sp.inventoryMenu.broadcastChanges(); 121 + com.florian.bskycam.BskyCam.LOGGER.info("SignPostHelper: done, placed={}", placed); 122 + }); 123 + } 124 + 125 + private static void giveOrDrop(ServerPlayer sp, ItemStack stack) { 126 + if (!sp.getInventory().add(stack)) { 127 + sp.drop(stack, false); 128 + } 129 + } 130 + }
+121
src/client/java/com/florian/bskycam/client/TextSignEditScreen.java
··· 1 + package com.florian.bskycam.client; 2 + 3 + import com.florian.bskycam.ModItems; 4 + import com.google.gson.JsonObject; 5 + import java.time.Instant; 6 + import java.time.format.DateTimeFormatter; 7 + import net.minecraft.ChatFormatting; 8 + import net.minecraft.client.Minecraft; 9 + import net.minecraft.client.gui.components.Button; 10 + import net.minecraft.client.gui.components.EditBox; 11 + import net.minecraft.client.gui.components.StringWidget; 12 + import net.minecraft.client.gui.screens.Screen; 13 + import net.minecraft.network.chat.Component; 14 + import net.minecraft.world.InteractionHand; 15 + import net.minecraft.world.item.ItemStack; 16 + 17 + public class TextSignEditScreen extends Screen { 18 + private static final int TEXT_MAX = 300; 19 + 20 + private final ItemStack stack; 21 + private final InteractionHand hand; 22 + private final net.minecraft.core.BlockPos targetPos; 23 + private final net.minecraft.core.Direction targetFace; 24 + private EditBox textBox; 25 + 26 + public TextSignEditScreen(ItemStack stack, InteractionHand hand) { 27 + this(stack, hand, null, null); 28 + } 29 + 30 + public TextSignEditScreen(ItemStack stack, InteractionHand hand, net.minecraft.core.BlockPos targetPos, net.minecraft.core.Direction targetFace) { 31 + super(Component.literal("Post Text")); 32 + this.stack = stack; 33 + this.hand = hand; 34 + this.targetPos = targetPos; 35 + this.targetFace = targetFace; 36 + } 37 + 38 + @Override 39 + protected void init() { 40 + int cx = this.width / 2; 41 + int boxW = 360; 42 + int y = 50; 43 + 44 + addRenderableWidget(new StringWidget(cx - boxW / 2, y, boxW, 10, 45 + Component.literal("Bluesky Text Post").withStyle(ChatFormatting.WHITE), this.font)); 46 + y += 20; 47 + 48 + addRenderableWidget(new StringWidget(cx - boxW / 2, y, boxW, 10, 49 + Component.literal("Text (up to 300 chars)").withStyle(ChatFormatting.GRAY), this.font)); 50 + y += 12; 51 + textBox = new EditBox(this.font, cx - boxW / 2, y, boxW, 20, Component.literal("text")); 52 + textBox.setMaxLength(TEXT_MAX); 53 + addRenderableWidget(textBox); 54 + y += 40; 55 + 56 + addRenderableWidget(new StringWidget(cx - boxW / 2, y, boxW, 10, 57 + Component.literal("Current player + world data will be attached.").withStyle(ChatFormatting.DARK_GRAY), this.font)); 58 + y += 20; 59 + 60 + addRenderableWidget(Button.builder(Component.literal("Post to Bluesky"), b -> post()) 61 + .bounds(cx - 155, y, 150, 20).build()); 62 + addRenderableWidget(Button.builder(Component.literal("Cancel"), b -> onClose()) 63 + .bounds(cx + 5, y, 150, 20).build()); 64 + 65 + setInitialFocus(textBox); 66 + } 67 + 68 + private void post() { 69 + Minecraft mc = this.minecraft; 70 + JsonObject meta = CameraCapture.collectMetadataPublic(mc); 71 + String capturedAt = DateTimeFormatter.ISO_INSTANT.format(Instant.now()); 72 + final String text = textBox.getValue(); 73 + 74 + BskyPoster.Payload payload = new BskyPoster.Payload( 75 + null, 0, 0, 76 + text, 77 + null, 78 + meta, 79 + capturedAt 80 + ); 81 + 82 + BskyPoster.post(payload, null).whenComplete((url, err) -> mc.execute(() -> { 83 + if (err != null) { 84 + Throwable cause = err.getCause() != null ? err.getCause() : err; 85 + if (mc.player != null) { 86 + mc.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[BskyCam] Post failed: " + cause.getMessage()) 87 + .withStyle(s -> s.withColor(net.minecraft.ChatFormatting.RED))); 88 + } 89 + } else if (mc.player != null) { 90 + // For text signs, always mint a new "posted" stack (so we don't post an entire stack at once). 91 + // Splitting one off before posting would have been cleaner — for now mutate server stack. 92 + SignPostHelper.markPostedAndMaybePlace(mc, hand, ModItems.TEXT_SIGN, text, url, targetPos, targetFace, false); 93 + // Also store capturedAt + minecraftData so the sign remembers its context. 94 + mc.execute(() -> { 95 + ItemStack held = mc.player.getItemInHand(hand); 96 + if (held.getItem() == ModItems.TEXT_SIGN) { 97 + com.florian.bskycam.SignData.mutate(held, t -> { 98 + t.putString(com.florian.bskycam.SignData.TAG_CAPTURED_AT, capturedAt); 99 + t.putString(com.florian.bskycam.SignData.TAG_MINECRAFT_DATA, new com.google.gson.Gson().toJson(meta)); 100 + }); 101 + } 102 + }); 103 + net.minecraft.network.chat.MutableComponent link = net.minecraft.network.chat.Component.literal(url) 104 + .withStyle(net.minecraft.network.chat.Style.EMPTY 105 + .withColor(net.minecraft.ChatFormatting.AQUA) 106 + .withUnderlined(true) 107 + .withClickEvent(new net.minecraft.network.chat.ClickEvent.OpenUrl(java.net.URI.create(url)))); 108 + mc.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[BskyCam] Posted! ").append(link) 109 + .withStyle(s -> s.withColor(net.minecraft.ChatFormatting.GREEN))); 110 + } 111 + })); 112 + if (mc.player != null) mc.player.sendSystemMessage(net.minecraft.network.chat.Component.literal("[BskyCam] Posting…") 113 + .withStyle(s -> s.withColor(net.minecraft.ChatFormatting.GRAY))); 114 + onClose(); 115 + } 116 + 117 + @Override 118 + public void onClose() { 119 + this.minecraft.setScreen(null); 120 + } 121 + }
+16
src/client/java/com/florian/bskycam/client/entity/BskySignRenderState.java
··· 1 + package com.florian.bskycam.client.entity; 2 + 3 + import java.util.UUID; 4 + import net.minecraft.client.renderer.entity.state.EntityRenderState; 5 + import net.minecraft.core.Direction; 6 + 7 + public class BskySignRenderState extends EntityRenderState { 8 + public Direction direction; 9 + public boolean isPhoto; 10 + public boolean posted; 11 + public UUID entityId; 12 + public String text; 13 + public byte[] photoBytes; 14 + public int photoWidth; 15 + public int photoHeight; 16 + }
+193
src/client/java/com/florian/bskycam/client/entity/BskySignRenderer.java
··· 1 + package com.florian.bskycam.client.entity; 2 + 3 + import com.florian.bskycam.BskyCam; 4 + import com.florian.bskycam.BskySignEntity; 5 + import com.florian.bskycam.SignData; 6 + import com.mojang.blaze3d.platform.NativeImage; 7 + import com.mojang.blaze3d.vertex.PoseStack; 8 + import com.mojang.blaze3d.vertex.VertexConsumer; 9 + import com.mojang.math.Axis; 10 + import java.util.List; 11 + import java.util.Map; 12 + import java.util.UUID; 13 + import java.util.concurrent.ConcurrentHashMap; 14 + import net.minecraft.client.Minecraft; 15 + import net.minecraft.client.gui.Font; 16 + import net.minecraft.client.renderer.SubmitNodeCollector; 17 + import net.minecraft.client.renderer.entity.EntityRenderer; 18 + import net.minecraft.client.renderer.entity.EntityRendererProvider; 19 + import net.minecraft.client.renderer.rendertype.RenderType; 20 + import net.minecraft.client.renderer.rendertype.RenderTypes; 21 + import net.minecraft.client.renderer.state.level.CameraRenderState; 22 + import net.minecraft.client.renderer.texture.DynamicTexture; 23 + import net.minecraft.client.renderer.texture.OverlayTexture; 24 + import net.minecraft.network.chat.Component; 25 + import net.minecraft.resources.Identifier; 26 + import net.minecraft.util.FormattedCharSequence; 27 + import net.minecraft.world.item.ItemStack; 28 + 29 + public class BskySignRenderer extends EntityRenderer<BskySignEntity, BskySignRenderState> { 30 + private static final Map<UUID, Identifier> TEXTURE_CACHE = new ConcurrentHashMap<>(); 31 + private static final Identifier PAPER_BG = Identifier.fromNamespaceAndPath("bskycam", "item/text_sign"); 32 + private final Font font; 33 + 34 + public BskySignRenderer(EntityRendererProvider.Context ctx) { 35 + super(ctx); 36 + this.font = ctx.getFont(); 37 + } 38 + 39 + @Override 40 + public BskySignRenderState createRenderState() { return new BskySignRenderState(); } 41 + 42 + private static int extractLogCount = 0; 43 + 44 + @Override 45 + public void extractRenderState(BskySignEntity entity, BskySignRenderState state, float partial) { 46 + super.extractRenderState(entity, state, partial); 47 + state.direction = entity.getDirection(); 48 + ItemStack stack = entity.getSignStack(); 49 + state.isPhoto = entity.isPhoto(); 50 + state.entityId = entity.getUUID(); 51 + state.text = SignData.getText(stack); 52 + state.posted = SignData.isPosted(stack); 53 + if (state.isPhoto) { 54 + state.photoBytes = SignData.getPhoto(stack); 55 + state.photoWidth = SignData.getWidth(stack); 56 + state.photoHeight = SignData.getHeight(stack); 57 + } else { 58 + state.photoBytes = null; 59 + state.photoWidth = 0; 60 + state.photoHeight = 0; 61 + } 62 + if (extractLogCount < 3) { 63 + extractLogCount++; 64 + BskyCam.LOGGER.info("BskySignRenderer.extract: isPhoto={} dir={} stack={} photoBytes={} w={} h={} pos=({},{},{})", 65 + state.isPhoto, state.direction, stack.getItem(), 66 + state.photoBytes != null ? state.photoBytes.length : "null", 67 + state.photoWidth, state.photoHeight, 68 + state.x, state.y, state.z); 69 + } 70 + } 71 + 72 + private static int submitLogCount = 0; 73 + 74 + @Override 75 + public void submit(BskySignRenderState state, PoseStack pose, SubmitNodeCollector collector, CameraRenderState camera) { 76 + if (submitLogCount < 3) { 77 + submitLogCount++; 78 + BskyCam.LOGGER.info("BskySignRenderer.submit: dir={} isPhoto={} text='{}'", state.direction, state.isPhoto, state.text); 79 + } 80 + if (state.direction == null) { 81 + super.submit(state, pose, collector, camera); 82 + return; 83 + } 84 + 85 + pose.pushPose(); 86 + pose.mulPose(Axis.YP.rotationDegrees(180f - state.direction.get2DDataValue() * 90f)); 87 + 88 + float photoHalfW = 0.48f; 89 + float photoHalfH = 0.48f; 90 + float photoYOffset = 0f; 91 + boolean hasText = state.text != null && !state.text.isEmpty(); 92 + 93 + if (hasText) { 94 + photoHalfH = 0.36f; 95 + photoYOffset = 0.10f; 96 + } 97 + 98 + if (state.isPhoto) { 99 + Identifier tex = getOrCreateTexture(state); 100 + if (tex != null && state.photoWidth > 0 && state.photoHeight > 0) { 101 + float aspect = (float) state.photoWidth / state.photoHeight; 102 + float w = photoHalfW; 103 + float h = photoHalfH; 104 + if (aspect >= w / h) { 105 + h = w / aspect; 106 + } else { 107 + w = h * aspect; 108 + } 109 + final float fw = w; 110 + final float fh = h; 111 + final float fyOff = photoYOffset; 112 + RenderType rt = RenderTypes.entitySolid(tex); 113 + collector.submitCustomGeometry(pose, rt, (p, vc) -> { 114 + writeQuad(p, vc, -fw, -fh + fyOff, fw, fh + fyOff, state.lightCoords); 115 + }); 116 + } 117 + } else { 118 + // Text sign: draw paper-colored background (use the item texture as a simple bg). 119 + float w = 0.48f, h = 0.48f; 120 + RenderType rt = RenderTypes.entitySolid(PAPER_BG); 121 + collector.submitCustomGeometry(pose, rt, (p, vc) -> { 122 + writeQuad(p, vc, -w, -h, w, h, state.lightCoords); 123 + }); 124 + } 125 + 126 + if (hasText) { 127 + pose.pushPose(); 128 + float textY = -photoHalfH - 0.02f + photoYOffset; 129 + if (!state.isPhoto) textY = 0.20f; 130 + pose.translate(0f, textY, 0.03f); 131 + float textScale = 0.008f; 132 + pose.scale(textScale, -textScale, textScale); 133 + 134 + int wrapPx = 100; 135 + List<FormattedCharSequence> lines = font.split(Component.literal(state.text), wrapPx); 136 + int yy = 0; 137 + int maxLines = state.isPhoto ? 3 : 8; 138 + int shown = 0; 139 + for (FormattedCharSequence line : lines) { 140 + if (shown >= maxLines) break; 141 + float lineWidth = font.width(line); 142 + float x = -lineWidth / 2f; 143 + collector.submitText(pose, x, yy, line, false, Font.DisplayMode.NORMAL, 144 + 0x00000000, 0xFFFFFFFF, 0, state.lightCoords); 145 + yy += 10; 146 + shown++; 147 + } 148 + pose.popPose(); 149 + } 150 + 151 + pose.popPose(); 152 + super.submit(state, pose, collector, camera); 153 + } 154 + 155 + private static void writeQuad(PoseStack.Pose pose, VertexConsumer vc, float x0, float y0, float x1, float y1, int light) { 156 + int color = 0xFFFFFFFF; 157 + int overlay = OverlayTexture.NO_OVERLAY; 158 + // Front face (normal +Z, CCW from +Z): shown when viewer is in front of the sign. 159 + vc.addVertex(pose, x0, y0, 0f).setColor(color).setUv(0f, 1f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, 1f); 160 + vc.addVertex(pose, x1, y0, 0f).setColor(color).setUv(1f, 1f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, 1f); 161 + vc.addVertex(pose, x1, y1, 0f).setColor(color).setUv(1f, 0f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, 1f); 162 + vc.addVertex(pose, x0, y1, 0f).setColor(color).setUv(0f, 0f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, 1f); 163 + // Back face (normal -Z, CCW from -Z = reversed winding): ensures visibility regardless of 164 + // which side of the wall the player is on and any cull convention. 165 + vc.addVertex(pose, x0, y0, 0f).setColor(color).setUv(0f, 1f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, -1f); 166 + vc.addVertex(pose, x0, y1, 0f).setColor(color).setUv(0f, 0f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, -1f); 167 + vc.addVertex(pose, x1, y1, 0f).setColor(color).setUv(1f, 0f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, -1f); 168 + vc.addVertex(pose, x1, y0, 0f).setColor(color).setUv(1f, 1f).setOverlay(overlay).setLight(light).setNormal(pose, 0f, 0f, -1f); 169 + } 170 + 171 + private static Identifier getOrCreateTexture(BskySignRenderState state) { 172 + Identifier cached = TEXTURE_CACHE.get(state.entityId); 173 + if (cached != null) return cached; 174 + if (state.photoBytes == null || state.photoBytes.length == 0) return null; 175 + try { 176 + java.awt.image.BufferedImage src = javax.imageio.ImageIO.read(new java.io.ByteArrayInputStream(state.photoBytes)); 177 + if (src == null) return null; 178 + java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream(); 179 + javax.imageio.ImageIO.write(src, "png", baos); 180 + NativeImage img = NativeImage.read(new java.io.ByteArrayInputStream(baos.toByteArray())); 181 + DynamicTexture tex = new DynamicTexture(() -> "bskycam-sign-" + state.entityId, img); 182 + tex.upload(); 183 + Identifier id = Identifier.fromNamespaceAndPath("bskycam", 184 + "sign_" + state.entityId.toString().replace("-", "")); 185 + Minecraft.getInstance().getTextureManager().register(id, tex); 186 + TEXTURE_CACHE.put(state.entityId, id); 187 + return id; 188 + } catch (Exception e) { 189 + BskyCam.LOGGER.error("Sign texture decode failed", e); 190 + return null; 191 + } 192 + } 193 + }
+22
src/client/java/com/florian/bskycam/client/mixin/CameraFovMixin.java
··· 1 + package com.florian.bskycam.client.mixin; 2 + 3 + import com.florian.bskycam.ModItems; 4 + import net.minecraft.client.player.AbstractClientPlayer; 5 + import net.minecraft.world.item.ItemStack; 6 + import org.spongepowered.asm.mixin.Mixin; 7 + import org.spongepowered.asm.mixin.injection.At; 8 + import org.spongepowered.asm.mixin.injection.Inject; 9 + import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 10 + 11 + @Mixin(AbstractClientPlayer.class) 12 + public abstract class CameraFovMixin { 13 + @Inject(method = "getFieldOfViewModifier", at = @At("HEAD"), cancellable = true) 14 + private void bskycam$cameraZoom(boolean isZoomScrollAllowed, float partialTick, CallbackInfoReturnable<Float> cir) { 15 + AbstractClientPlayer self = (AbstractClientPlayer) (Object) this; 16 + if (!isZoomScrollAllowed || !self.isUsingItem()) return; 17 + ItemStack used = self.getUseItem(); 18 + if (used.is(ModItems.CAMERA)) { 19 + cir.setReturnValue(0.5f); 20 + } 21 + } 22 + }
+21
src/client/java/com/florian/bskycam/client/mixin/ItemInHandRendererMixin.java
··· 1 + package com.florian.bskycam.client.mixin; 2 + 3 + import com.florian.bskycam.ModItems; 4 + import com.mojang.blaze3d.vertex.PoseStack; 5 + import net.minecraft.client.player.LocalPlayer; 6 + import net.minecraft.client.renderer.ItemInHandRenderer; 7 + import net.minecraft.client.renderer.SubmitNodeCollector; 8 + import org.spongepowered.asm.mixin.Mixin; 9 + import org.spongepowered.asm.mixin.injection.At; 10 + import org.spongepowered.asm.mixin.injection.Inject; 11 + import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 12 + 13 + @Mixin(ItemInHandRenderer.class) 14 + public abstract class ItemInHandRendererMixin { 15 + @Inject(method = "renderHandsWithItems", at = @At("HEAD"), cancellable = true) 16 + private void bskycam$hideWhenUsingCamera(float partialTick, PoseStack pose, SubmitNodeCollector col, LocalPlayer player, int light, CallbackInfo ci) { 17 + if (player.isUsingItem() && player.getUseItem().is(ModItems.CAMERA)) { 18 + ci.cancel(); 19 + } 20 + } 21 + }
+13
src/client/resources/bskycam.client.mixins.json
··· 1 + { 2 + "required": true, 3 + "minVersion": "0.8", 4 + "package": "com.florian.bskycam.client.mixin", 5 + "compatibilityLevel": "JAVA_21", 6 + "client": [ 7 + "CameraFovMixin", 8 + "ItemInHandRendererMixin" 9 + ], 10 + "injectors": { 11 + "defaultRequire": 1 12 + } 13 + }
+33
src/main/java/com/florian/bskycam/BskyCam.java
··· 1 + package com.florian.bskycam; 2 + 3 + import net.fabricmc.api.ModInitializer; 4 + import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents; 5 + import net.minecraft.core.registries.Registries; 6 + import net.minecraft.resources.Identifier; 7 + import net.minecraft.resources.ResourceKey; 8 + import net.minecraft.world.item.CreativeModeTab; 9 + import org.slf4j.Logger; 10 + import org.slf4j.LoggerFactory; 11 + 12 + public class BskyCam implements ModInitializer { 13 + public static final String MOD_ID = "bskycam"; 14 + public static final Logger LOGGER = LoggerFactory.getLogger(MOD_ID); 15 + 16 + private static final ResourceKey<CreativeModeTab> TOOLS_AND_UTILITIES_TAB = ResourceKey.create( 17 + Registries.CREATIVE_MODE_TAB, 18 + Identifier.withDefaultNamespace("tools_and_utilities") 19 + ); 20 + 21 + @Override 22 + public void onInitialize() { 23 + ModItems.register(); 24 + ModEntities.register(); 25 + 26 + CreativeModeTabEvents.modifyOutputEvent(TOOLS_AND_UTILITIES_TAB).register(output -> { 27 + output.accept(ModItems.CAMERA); 28 + output.accept(ModItems.TEXT_SIGN); 29 + }); 30 + 31 + LOGGER.info("BskyCam initialized"); 32 + } 33 + }
+148
src/main/java/com/florian/bskycam/BskySignEntity.java
··· 1 + package com.florian.bskycam; 2 + 3 + import net.minecraft.core.BlockPos; 4 + import net.minecraft.core.Direction; 5 + import net.minecraft.network.protocol.Packet; 6 + import net.minecraft.network.protocol.game.ClientGamePacketListener; 7 + import net.minecraft.network.protocol.game.ClientboundAddEntityPacket; 8 + import net.minecraft.network.syncher.EntityDataAccessor; 9 + import net.minecraft.network.syncher.EntityDataSerializers; 10 + import net.minecraft.network.syncher.SynchedEntityData; 11 + import net.minecraft.server.level.ServerEntity; 12 + import net.minecraft.server.level.ServerLevel; 13 + import net.minecraft.world.entity.Entity; 14 + import net.minecraft.world.entity.EntityType; 15 + import net.minecraft.world.entity.decoration.BlockAttachedEntity; 16 + import net.minecraft.world.item.ItemStack; 17 + import net.minecraft.world.level.Level; 18 + import net.minecraft.world.level.storage.ValueInput; 19 + import net.minecraft.world.level.storage.ValueOutput; 20 + import net.minecraft.world.phys.AABB; 21 + 22 + public abstract class BskySignEntity extends BlockAttachedEntity { 23 + private static final EntityDataAccessor<ItemStack> DATA_STACK = 24 + SynchedEntityData.defineId(BskySignEntity.class, EntityDataSerializers.ITEM_STACK); 25 + private static final EntityDataAccessor<Direction> DATA_DIRECTION = 26 + SynchedEntityData.defineId(BskySignEntity.class, EntityDataSerializers.DIRECTION); 27 + 28 + protected BskySignEntity(EntityType<? extends BskySignEntity> type, Level level) { 29 + super(type, level); 30 + } 31 + 32 + protected BskySignEntity(EntityType<? extends BskySignEntity> type, Level level, BlockPos attachedAirBlock, Direction facing, ItemStack stack) { 33 + super(type, level, attachedAirBlock); 34 + this.entityData.set(DATA_STACK, stack.copy()); 35 + setDirection(facing); 36 + } 37 + 38 + @Override 39 + protected void defineSynchedData(SynchedEntityData.Builder builder) { 40 + builder.define(DATA_STACK, ItemStack.EMPTY); 41 + builder.define(DATA_DIRECTION, Direction.SOUTH); 42 + } 43 + 44 + public ItemStack getSignStack() { return this.entityData.get(DATA_STACK); } 45 + public void setSignStack(ItemStack stack) { this.entityData.set(DATA_STACK, stack.copy()); } 46 + 47 + public Direction getDirection() { return this.entityData.get(DATA_DIRECTION); } 48 + 49 + public void setDirection(Direction direction) { 50 + this.entityData.set(DATA_DIRECTION, direction); 51 + setYRot(direction.get2DDataValue() * 90f); 52 + yRotO = getYRot(); 53 + recalculateBoundingBox(); 54 + } 55 + 56 + @Override 57 + public void onSyncedDataUpdated(EntityDataAccessor<?> accessor) { 58 + super.onSyncedDataUpdated(accessor); 59 + if (DATA_DIRECTION.equals(accessor)) { 60 + recalculateBoundingBox(); 61 + } 62 + } 63 + 64 + @Override 65 + protected void recalculateBoundingBox() { 66 + if (pos == null) return; 67 + Direction d = getDirection(); 68 + if (d == null) d = Direction.SOUTH; 69 + 70 + // Visual offset toward the wall. Must be < (0.5 - thick) to keep AABB inside the air block. 71 + double thick = 0.0625; 72 + double visualOffset = 0.5 - thick - 0.01; // stay strictly inside the air block 73 + double cx = pos.getX() + 0.5 - d.getStepX() * visualOffset; 74 + double cy = pos.getY() + 0.5; 75 + double cz = pos.getZ() + 0.5 - d.getStepZ() * visualOffset; 76 + // setPosRaw does not trigger recalculate — setPos would recurse infinitely. 77 + setPosRaw(cx, cy, cz); 78 + 79 + double halfW = 0.49; 80 + double halfH = 0.49; 81 + double x0, x1, z0, z1; 82 + if (d.getAxis() == Direction.Axis.Z) { 83 + x0 = cx - halfW; x1 = cx + halfW; 84 + z0 = cz - thick; z1 = cz + thick; 85 + } else { 86 + x0 = cx - thick; x1 = cx + thick; 87 + z0 = cz - halfW; z1 = cz + halfW; 88 + } 89 + setBoundingBox(new AABB(x0, cy - halfH, z0, x1, cy + halfH, z1)); 90 + } 91 + 92 + @Override 93 + public boolean survives() { 94 + if (!level().noCollision(this)) return false; 95 + return level().getBlockState(pos.relative(getDirection().getOpposite())).isSolid(); 96 + } 97 + 98 + @Override 99 + public void dropItem(ServerLevel level, Entity source) { 100 + ItemStack stack = getSignStack(); 101 + if (!stack.isEmpty()) { 102 + spawnAtLocation(level, stack.copy(), 0.0f); 103 + } 104 + } 105 + 106 + @Override 107 + protected void addAdditionalSaveData(ValueOutput out) { 108 + out.store("stack", ItemStack.CODEC, getSignStack()); 109 + out.putString("direction", getDirection().getSerializedName()); 110 + out.putInt("posX", pos.getX()); 111 + out.putInt("posY", pos.getY()); 112 + out.putInt("posZ", pos.getZ()); 113 + } 114 + 115 + @Override 116 + protected void readAdditionalSaveData(ValueInput in) { 117 + ItemStack stack = in.read("stack", ItemStack.CODEC).orElse(ItemStack.EMPTY); 118 + this.entityData.set(DATA_STACK, stack); 119 + Direction dir = Direction.byName(in.getStringOr("direction", "south")); 120 + if (dir == null) dir = Direction.SOUTH; 121 + int x = in.getIntOr("posX", 0); 122 + int y = in.getIntOr("posY", 0); 123 + int z = in.getIntOr("posZ", 0); 124 + this.pos = new BlockPos(x, y, z); 125 + setDirection(dir); 126 + } 127 + 128 + @Override 129 + public Packet<ClientGamePacketListener> getAddEntityPacket(ServerEntity serverEntity) { 130 + return new ClientboundAddEntityPacket(this, getDirection().get3DDataValue(), pos); 131 + } 132 + 133 + @Override 134 + public void recreateFromPacket(ClientboundAddEntityPacket packet) { 135 + super.recreateFromPacket(packet); 136 + // The 3-arg packet constructor encodes attachment BlockPos as x/y/z. 137 + // Reconstruct pos on the client side so recalculateBoundingBox can work. 138 + this.pos = BlockPos.containing(packet.getX(), packet.getY(), packet.getZ()); 139 + setDirection(Direction.from3DDataValue(packet.getData())); 140 + } 141 + 142 + @Override 143 + public ItemStack getPickResult() { 144 + return getSignStack().copy(); 145 + } 146 + 147 + public abstract boolean isPhoto(); 148 + }
+51
src/main/java/com/florian/bskycam/CameraItem.java
··· 1 + package com.florian.bskycam; 2 + 3 + import net.minecraft.sounds.SoundEvents; 4 + import net.minecraft.stats.Stats; 5 + import net.minecraft.world.InteractionHand; 6 + import net.minecraft.world.InteractionResult; 7 + import net.minecraft.world.entity.LivingEntity; 8 + import net.minecraft.world.entity.player.Player; 9 + import net.minecraft.world.item.Item; 10 + import net.minecraft.world.item.ItemStack; 11 + import net.minecraft.world.item.ItemUseAnimation; 12 + import net.minecraft.world.item.ItemUtils; 13 + import net.minecraft.world.level.Level; 14 + 15 + public class CameraItem extends Item { 16 + public static final int USE_DURATION = 1200; 17 + public static final int MIN_HOLD_TICKS = 5; 18 + 19 + public CameraItem(Properties props) { 20 + super(props); 21 + } 22 + 23 + @Override 24 + public int getUseDuration(ItemStack stack, LivingEntity entity) { 25 + return USE_DURATION; 26 + } 27 + 28 + @Override 29 + public ItemUseAnimation getUseAnimation(ItemStack stack) { 30 + return ItemUseAnimation.SPYGLASS; 31 + } 32 + 33 + @Override 34 + public InteractionResult use(Level level, Player player, InteractionHand hand) { 35 + player.playSound(SoundEvents.SPYGLASS_USE, 1.0f, 1.0f); 36 + player.awardStat(Stats.ITEM_USED.get(this)); 37 + return ItemUtils.startUsingInstantly(level, player, hand); 38 + } 39 + 40 + @Override 41 + public ItemStack finishUsingItem(ItemStack stack, Level level, LivingEntity entity) { 42 + entity.playSound(SoundEvents.SPYGLASS_STOP_USING, 1.0f, 1.0f); 43 + return stack; 44 + } 45 + 46 + @Override 47 + public boolean releaseUsing(ItemStack stack, Level level, LivingEntity entity, int remainingTicks) { 48 + entity.playSound(SoundEvents.SPYGLASS_STOP_USING, 1.0f, 1.0f); 49 + return true; 50 + } 51 + }
+34
src/main/java/com/florian/bskycam/ModEntities.java
··· 1 + package com.florian.bskycam; 2 + 3 + import net.minecraft.core.Registry; 4 + import net.minecraft.core.registries.BuiltInRegistries; 5 + import net.minecraft.core.registries.Registries; 6 + import net.minecraft.resources.Identifier; 7 + import net.minecraft.resources.ResourceKey; 8 + import net.minecraft.world.entity.EntityType; 9 + import net.minecraft.world.entity.MobCategory; 10 + 11 + public final class ModEntities { 12 + public static final EntityType<PhotoSignEntity> PHOTO_SIGN = register("photo_sign", 13 + EntityType.Builder.<PhotoSignEntity>of(PhotoSignEntity::new, MobCategory.MISC) 14 + .sized(1.0f, 1.0f) 15 + .clientTrackingRange(10) 16 + .updateInterval(Integer.MAX_VALUE)); 17 + 18 + public static final EntityType<TextSignEntity> TEXT_SIGN = register("text_sign", 19 + EntityType.Builder.<TextSignEntity>of(TextSignEntity::new, MobCategory.MISC) 20 + .sized(1.0f, 1.0f) 21 + .clientTrackingRange(10) 22 + .updateInterval(Integer.MAX_VALUE)); 23 + 24 + public static void register() {} 25 + 26 + private static <T extends net.minecraft.world.entity.Entity> EntityType<T> register(String name, EntityType.Builder<T> builder) { 27 + ResourceKey<EntityType<?>> key = ResourceKey.create(Registries.ENTITY_TYPE, 28 + Identifier.fromNamespaceAndPath(BskyCam.MOD_ID, name)); 29 + EntityType<T> type = builder.build(key); 30 + return Registry.register(BuiltInRegistries.ENTITY_TYPE, key, type); 31 + } 32 + 33 + private ModEntities() {} 34 + }
+29
src/main/java/com/florian/bskycam/ModItems.java
··· 1 + package com.florian.bskycam; 2 + 3 + import java.util.function.Function; 4 + import net.minecraft.core.Registry; 5 + import net.minecraft.core.registries.BuiltInRegistries; 6 + import net.minecraft.core.registries.Registries; 7 + import net.minecraft.resources.Identifier; 8 + import net.minecraft.resources.ResourceKey; 9 + import net.minecraft.world.item.Item; 10 + 11 + public final class ModItems { 12 + public static final Item CAMERA = register("camera", CameraItem::new, new Item.Properties().stacksTo(1)); 13 + public static final Item PHOTO_SIGN = register("photo_sign", PhotoSignItem::new, new Item.Properties().stacksTo(1)); 14 + public static final Item TEXT_SIGN = register("text_sign", TextSignItem::new, new Item.Properties().stacksTo(16)); 15 + 16 + public static void register() { 17 + } 18 + 19 + private static Item register(String name, Function<Item.Properties, Item> factory, Item.Properties props) { 20 + ResourceKey<Item> key = ResourceKey.create( 21 + Registries.ITEM, 22 + Identifier.fromNamespaceAndPath(BskyCam.MOD_ID, name) 23 + ); 24 + Item item = factory.apply(props.setId(key)); 25 + return Registry.register(BuiltInRegistries.ITEM, key, item); 26 + } 27 + 28 + private ModItems() {} 29 + }
+19
src/main/java/com/florian/bskycam/PhotoSignEntity.java
··· 1 + package com.florian.bskycam; 2 + 3 + import net.minecraft.core.BlockPos; 4 + import net.minecraft.core.Direction; 5 + import net.minecraft.world.entity.EntityType; 6 + import net.minecraft.world.item.ItemStack; 7 + import net.minecraft.world.level.Level; 8 + 9 + public class PhotoSignEntity extends BskySignEntity { 10 + public PhotoSignEntity(EntityType<? extends PhotoSignEntity> type, Level level) { 11 + super(type, level); 12 + } 13 + 14 + public PhotoSignEntity(Level level, BlockPos airBlock, Direction facing, ItemStack stack) { 15 + super(ModEntities.PHOTO_SIGN, level, airBlock, facing, stack); 16 + } 17 + 18 + @Override public boolean isPhoto() { return true; } 19 + }
+75
src/main/java/com/florian/bskycam/PhotoSignItem.java
··· 1 + package com.florian.bskycam; 2 + 3 + import java.util.function.Consumer; 4 + import net.minecraft.ChatFormatting; 5 + import net.minecraft.core.BlockPos; 6 + import net.minecraft.core.Direction; 7 + import net.minecraft.network.chat.Component; 8 + import net.minecraft.world.InteractionResult; 9 + import net.minecraft.world.item.Item; 10 + import net.minecraft.world.item.ItemStack; 11 + import net.minecraft.world.item.TooltipFlag; 12 + import net.minecraft.world.item.component.TooltipDisplay; 13 + import net.minecraft.world.item.context.UseOnContext; 14 + import net.minecraft.world.level.Level; 15 + 16 + public class PhotoSignItem extends Item { 17 + public PhotoSignItem(Properties props) { 18 + super(props); 19 + } 20 + 21 + @Override 22 + public InteractionResult useOn(UseOnContext ctx) { 23 + ItemStack stack = ctx.getItemInHand(); 24 + // Unposted signs are never placed via useOn — the client-side UseBlockCallback 25 + // opens the edit screen and SignPostHelper places after posting. 26 + if (!SignData.isPosted(stack)) return InteractionResult.PASS; 27 + 28 + Level level = ctx.getLevel(); 29 + BlockPos clicked = ctx.getClickedPos(); 30 + Direction face = ctx.getClickedFace(); 31 + if (face.getAxis() == Direction.Axis.Y) return InteractionResult.FAIL; 32 + BlockPos signPos = clicked.relative(face); 33 + if (!level.getBlockState(signPos).isAir()) return InteractionResult.FAIL; 34 + if (level.isClientSide()) return InteractionResult.SUCCESS; 35 + 36 + PhotoSignEntity entity = new PhotoSignEntity(level, signPos, face, stack); 37 + if (!entity.survives()) { 38 + entity.discard(); 39 + return InteractionResult.FAIL; 40 + } 41 + level.addFreshEntity(entity); 42 + if (ctx.getPlayer() != null && !ctx.getPlayer().getAbilities().instabuild) { 43 + stack.shrink(1); 44 + } 45 + return InteractionResult.CONSUME; 46 + } 47 + 48 + @Override 49 + public Component getName(ItemStack stack) { 50 + if (SignData.isPosted(stack)) { 51 + return Component.translatable("item.bskycam.photo_sign.posted"); 52 + } 53 + return super.getName(stack); 54 + } 55 + 56 + @Override 57 + public void appendHoverText(ItemStack stack, Item.TooltipContext context, TooltipDisplay display, 58 + Consumer<Component> consumer, TooltipFlag flag) { 59 + super.appendHoverText(stack, context, display, consumer, flag); 60 + int w = SignData.getWidth(stack); 61 + int h = SignData.getHeight(stack); 62 + String capturedAt = SignData.getCapturedAt(stack); 63 + if (w > 0 && h > 0) { 64 + consumer.accept(Component.literal(" " + w + " × " + h).withStyle(ChatFormatting.DARK_GRAY)); 65 + } 66 + if (!capturedAt.isEmpty()) { 67 + consumer.accept(Component.literal(" " + capturedAt).withStyle(ChatFormatting.DARK_GRAY)); 68 + } 69 + if (SignData.isPosted(stack)) { 70 + consumer.accept(Component.translatable("item.bskycam.photo_sign.posted_hint").withStyle(ChatFormatting.AQUA)); 71 + } else { 72 + consumer.accept(Component.translatable("item.bskycam.photo_sign.unposted_hint").withStyle(ChatFormatting.GRAY)); 73 + } 74 + } 75 + }
+72
src/main/java/com/florian/bskycam/SignData.java
··· 1 + package com.florian.bskycam; 2 + 3 + import java.util.function.Consumer; 4 + import net.minecraft.core.component.DataComponents; 5 + import net.minecraft.nbt.CompoundTag; 6 + import net.minecraft.world.item.ItemStack; 7 + import net.minecraft.world.item.component.CustomData; 8 + 9 + /** 10 + * Helper for reading/writing bskycam sign payload (photo bytes, metadata, text, posted flag) 11 + * from an ItemStack's CUSTOM_DATA. Kept in main source so both Item and Entity classes can use it. 12 + */ 13 + public final class SignData { 14 + public static final String TAG_PHOTO = "photo"; 15 + public static final String TAG_WIDTH = "width"; 16 + public static final String TAG_HEIGHT = "height"; 17 + public static final String TAG_CAPTURED_AT = "capturedAt"; 18 + public static final String TAG_MINECRAFT_DATA = "minecraftData"; 19 + public static final String TAG_TEXT = "text"; 20 + public static final String TAG_POSTED = "posted"; 21 + public static final String TAG_POST_URL = "postUrl"; 22 + 23 + private SignData() {} 24 + 25 + public static CompoundTag read(ItemStack stack) { 26 + CustomData data = stack.get(DataComponents.CUSTOM_DATA); 27 + return data == null ? new CompoundTag() : data.copyTag(); 28 + } 29 + 30 + public static void write(ItemStack stack, CompoundTag tag) { 31 + stack.set(DataComponents.CUSTOM_DATA, CustomData.of(tag)); 32 + } 33 + 34 + public static void mutate(ItemStack stack, Consumer<CompoundTag> mutator) { 35 + CompoundTag tag = read(stack); 36 + mutator.accept(tag); 37 + write(stack, tag); 38 + } 39 + 40 + public static byte[] getPhoto(ItemStack stack) { 41 + return read(stack).getByteArray(TAG_PHOTO).orElse(null); 42 + } 43 + 44 + public static int getWidth(ItemStack stack) { 45 + return read(stack).getInt(TAG_WIDTH).orElse(0); 46 + } 47 + 48 + public static int getHeight(ItemStack stack) { 49 + return read(stack).getInt(TAG_HEIGHT).orElse(0); 50 + } 51 + 52 + public static String getCapturedAt(ItemStack stack) { 53 + return read(stack).getString(TAG_CAPTURED_AT).orElse(""); 54 + } 55 + 56 + public static String getText(ItemStack stack) { 57 + return read(stack).getString(TAG_TEXT).orElse(""); 58 + } 59 + 60 + public static boolean isPosted(ItemStack stack) { 61 + return read(stack).getBoolean(TAG_POSTED).orElse(false); 62 + } 63 + 64 + public static String getPostUrl(ItemStack stack) { 65 + return read(stack).getString(TAG_POST_URL).orElse(""); 66 + } 67 + 68 + /** Returns the minecraftData JSON string (or empty string). */ 69 + public static String getMinecraftDataJson(ItemStack stack) { 70 + return read(stack).getString(TAG_MINECRAFT_DATA).orElse(""); 71 + } 72 + }
+19
src/main/java/com/florian/bskycam/TextSignEntity.java
··· 1 + package com.florian.bskycam; 2 + 3 + import net.minecraft.core.BlockPos; 4 + import net.minecraft.core.Direction; 5 + import net.minecraft.world.entity.EntityType; 6 + import net.minecraft.world.item.ItemStack; 7 + import net.minecraft.world.level.Level; 8 + 9 + public class TextSignEntity extends BskySignEntity { 10 + public TextSignEntity(EntityType<? extends TextSignEntity> type, Level level) { 11 + super(type, level); 12 + } 13 + 14 + public TextSignEntity(Level level, BlockPos airBlock, Direction facing, ItemStack stack) { 15 + super(ModEntities.TEXT_SIGN, level, airBlock, facing, stack); 16 + } 17 + 18 + @Override public boolean isPhoto() { return false; } 19 + }
+56
src/main/java/com/florian/bskycam/TextSignItem.java
··· 1 + package com.florian.bskycam; 2 + 3 + import java.util.function.Consumer; 4 + import net.minecraft.ChatFormatting; 5 + import net.minecraft.core.BlockPos; 6 + import net.minecraft.core.Direction; 7 + import net.minecraft.network.chat.Component; 8 + import net.minecraft.world.InteractionResult; 9 + import net.minecraft.world.item.Item; 10 + import net.minecraft.world.item.ItemStack; 11 + import net.minecraft.world.item.TooltipFlag; 12 + import net.minecraft.world.item.component.TooltipDisplay; 13 + import net.minecraft.world.item.context.UseOnContext; 14 + import net.minecraft.world.level.Level; 15 + 16 + public class TextSignItem extends Item { 17 + public TextSignItem(Properties props) { 18 + super(props); 19 + } 20 + 21 + @Override 22 + public InteractionResult useOn(UseOnContext ctx) { 23 + ItemStack stack = ctx.getItemInHand(); 24 + if (!SignData.isPosted(stack)) return InteractionResult.PASS; 25 + 26 + Level level = ctx.getLevel(); 27 + BlockPos clicked = ctx.getClickedPos(); 28 + Direction face = ctx.getClickedFace(); 29 + if (face.getAxis() == Direction.Axis.Y) return InteractionResult.FAIL; 30 + BlockPos signPos = clicked.relative(face); 31 + if (!level.getBlockState(signPos).isAir()) return InteractionResult.FAIL; 32 + if (level.isClientSide()) return InteractionResult.SUCCESS; 33 + 34 + TextSignEntity entity = new TextSignEntity(level, signPos, face, stack); 35 + if (!entity.survives()) { 36 + entity.discard(); 37 + return InteractionResult.FAIL; 38 + } 39 + level.addFreshEntity(entity); 40 + if (ctx.getPlayer() != null && !ctx.getPlayer().getAbilities().instabuild) { 41 + stack.shrink(1); 42 + } 43 + return InteractionResult.CONSUME; 44 + } 45 + 46 + @Override 47 + public void appendHoverText(ItemStack stack, Item.TooltipContext context, TooltipDisplay display, 48 + Consumer<Component> consumer, TooltipFlag flag) { 49 + super.appendHoverText(stack, context, display, consumer, flag); 50 + if (SignData.isPosted(stack)) { 51 + consumer.accept(Component.translatable("item.bskycam.text_sign.posted_hint").withStyle(ChatFormatting.AQUA)); 52 + } else { 53 + consumer.accept(Component.translatable("item.bskycam.text_sign.hint").withStyle(ChatFormatting.GRAY)); 54 + } 55 + } 56 + }
+6
src/main/resources/assets/bskycam/items/camera.json
··· 1 + { 2 + "model": { 3 + "type": "minecraft:model", 4 + "model": "bskycam:item/camera" 5 + } 6 + }
+6
src/main/resources/assets/bskycam/items/photo_sign.json
··· 1 + { 2 + "model": { 3 + "type": "minecraft:model", 4 + "model": "bskycam:item/photo_sign" 5 + } 6 + }
+6
src/main/resources/assets/bskycam/items/text_sign.json
··· 1 + { 2 + "model": { 3 + "type": "minecraft:model", 4 + "model": "bskycam:item/text_sign" 5 + } 6 + }
+12
src/main/resources/assets/bskycam/lang/en_us.json
··· 1 + { 2 + "item.bskycam.camera": "Camera", 3 + "item.bskycam.photo_sign": "Photo Sign", 4 + "item.bskycam.photo_sign.posted": "Photo Sign (Posted)", 5 + "item.bskycam.photo_sign.unposted_hint": "Right-click to write caption and post", 6 + "item.bskycam.photo_sign.posted_hint": "Already posted to Bluesky", 7 + "item.bskycam.text_sign": "Text Sign", 8 + "item.bskycam.text_sign.hint": "Right-click to write, right-click a wall to place", 9 + "item.bskycam.text_sign.posted_hint": "Already posted — right-click a wall to display", 10 + "entity.bskycam.photo_sign": "Photo Sign", 11 + "entity.bskycam.text_sign": "Text Sign" 12 + }
+6
src/main/resources/assets/bskycam/models/item/camera.json
··· 1 + { 2 + "parent": "minecraft:item/generated", 3 + "textures": { 4 + "layer0": "bskycam:item/camera" 5 + } 6 + }
+6
src/main/resources/assets/bskycam/models/item/photo_sign.json
··· 1 + { 2 + "parent": "minecraft:item/generated", 3 + "textures": { 4 + "layer0": "bskycam:item/photo_sign" 5 + } 6 + }
+6
src/main/resources/assets/bskycam/models/item/text_sign.json
··· 1 + { 2 + "parent": "minecraft:item/generated", 3 + "textures": { 4 + "layer0": "bskycam:item/text_sign" 5 + } 6 + }
src/main/resources/assets/bskycam/textures/item/camera.png

This is a binary file and will not be displayed.

src/main/resources/assets/bskycam/textures/item/photo_sign.png

This is a binary file and will not be displayed.

src/main/resources/assets/bskycam/textures/item/text_sign.png

This is a binary file and will not be displayed.

+17
src/main/resources/data/bskycam/recipe/camera.json
··· 1 + { 2 + "type": "minecraft:crafting_shaped", 3 + "category": "equipment", 4 + "key": { 5 + "I": "minecraft:iron_ingot", 6 + "G": "minecraft:glass_pane", 7 + "R": "minecraft:redstone" 8 + }, 9 + "pattern": [ 10 + "III", 11 + "IGI", 12 + "IRI" 13 + ], 14 + "result": { 15 + "id": "bskycam:camera" 16 + } 17 + }
+18
src/main/resources/data/bskycam/recipe/text_sign.json
··· 1 + { 2 + "type": "minecraft:crafting_shaped", 3 + "category": "misc", 4 + "key": { 5 + "P": "minecraft:paper", 6 + "I": "minecraft:ink_sac", 7 + "S": "minecraft:stick" 8 + }, 9 + "pattern": [ 10 + "PPP", 11 + "PIP", 12 + " S " 13 + ], 14 + "result": { 15 + "id": "bskycam:text_sign", 16 + "count": 4 17 + } 18 + }
+32
src/main/resources/fabric.mod.json
··· 1 + { 2 + "schemaVersion": 1, 3 + "id": "bskycam", 4 + "version": "${version}", 5 + "name": "Bluesky Camera", 6 + "description": "A craftable camera that posts screenshots to Bluesky via app password.", 7 + "authors": [ 8 + "Florian" 9 + ], 10 + "license": "CC0-1.0", 11 + "environment": "*", 12 + "entrypoints": { 13 + "main": [ 14 + "com.florian.bskycam.BskyCam" 15 + ], 16 + "client": [ 17 + "com.florian.bskycam.client.BskyCamClient" 18 + ] 19 + }, 20 + "mixins": [ 21 + { 22 + "config": "bskycam.client.mixins.json", 23 + "environment": "client" 24 + } 25 + ], 26 + "depends": { 27 + "fabricloader": ">=0.18.0", 28 + "minecraft": ">=26.1 <26.2", 29 + "java": ">=25", 30 + "fabric-api": "*" 31 + } 32 + }