├── .github └── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── translation.yml ├── .gitignore ├── build.gradle ├── changelog.md ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── readme.md ├── settings.gradle ├── src └── main │ ├── java │ └── de │ │ └── maxhenkel │ │ └── audioplayer │ │ ├── AudioCache.java │ │ ├── AudioConverter.java │ │ ├── AudioManager.java │ │ ├── AudioPlayer.java │ │ ├── AudioPlayerPermissionManager.java │ │ ├── ComponentUtils.java │ │ ├── CustomSound.java │ │ ├── FileNameManager.java │ │ ├── Filebin.java │ │ ├── PlayerManager.java │ │ ├── PlayerType.java │ │ ├── Plugin.java │ │ ├── StaticAudioPlayer.java │ │ ├── VolumeOverrideManager.java │ │ ├── command │ │ ├── ApplyCommands.java │ │ ├── PlayCommands.java │ │ ├── UploadCommands.java │ │ ├── UtilityCommands.java │ │ └── VolumeCommands.java │ │ ├── config │ │ ├── ServerConfig.java │ │ └── WebServerConfig.java │ │ ├── interfaces │ │ ├── ChannelHolder.java │ │ ├── CustomJukeboxSongPlayer.java │ │ └── CustomSoundHolder.java │ │ ├── mixin │ │ ├── AbstractSkullBlockMixin.java │ │ ├── BlockMixin.java │ │ ├── InstrumentItemMixin.java │ │ ├── JukeboxBlockEntityMixin.java │ │ ├── JukeboxSongPlayerMixin.java │ │ ├── NoteBlockMixin.java │ │ └── SkullBlockEntityMixin.java │ │ └── webserver │ │ ├── StaticFileCache.java │ │ ├── TokenManager.java │ │ ├── UrlUtils.java │ │ ├── WebServer.java │ │ └── WebServerEvents.java │ └── resources │ ├── META-INF │ └── services │ │ ├── javax.sound.sampled.spi.AudioFileReader │ │ └── javax.sound.sampled.spi.FormatConversionProvider │ ├── audioplayer.accesswidener │ ├── audioplayer.mixins.json │ ├── category_goat_horns.png │ ├── category_music_discs.png │ ├── category_note_blocks.png │ ├── data │ └── audioplayer │ │ └── jukebox_song │ │ └── custom.json │ ├── fabric.mod.json │ ├── icon.png │ └── web │ └── .gitkeep └── web ├── .eslintrc.cjs ├── .gitignore ├── .prettierrc.json ├── README.md ├── env.d.ts ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public └── favicon.ico ├── src ├── App.vue ├── assets │ └── main.css ├── main.ts └── services │ ├── FileUploadService.ts │ └── api │ └── api.ts ├── tailwind.config.js ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: Bug report 2 | description: File a bug report 3 | labels: [triage] 4 | assignees: henkelmax 5 | body: 6 | - type: markdown 7 | attributes: 8 | value: | 9 | > [!WARNING] 10 | > This form is **only for bug reports**! 11 | > Please don't abuse this for feature requests or questions. 12 | > Forms that are not filled out properly will be closed without response! 13 | - type: textarea 14 | id: description 15 | attributes: 16 | label: Bug description 17 | description: A clear and concise description of what the bug is. 18 | validations: 19 | required: true 20 | - type: input 21 | id: mc_version 22 | attributes: 23 | label: Minecraft version 24 | description: The Minecraft version you are using. 25 | placeholder: 1.20.4 26 | validations: 27 | required: true 28 | - type: input 29 | id: voicechat_version 30 | attributes: 31 | label: Simple Voice Chat version 32 | description: The version of Simple Voice Chat. 33 | placeholder: 1.20.4-1.2.3 34 | validations: 35 | required: true 36 | - type: input 37 | id: mod_version 38 | attributes: 39 | label: Mod version 40 | description: The version of the mod. 41 | placeholder: 1.20.4-1.2.3 42 | validations: 43 | required: true 44 | - type: input 45 | id: mod_loader_version 46 | attributes: 47 | label: Mod loader and version 48 | description: The mod loader and mod loader version you are using. 49 | placeholder: Fabric Loader 0.15.6 / NeoForge 20.4.1 / Forge 48.1.0 50 | validations: 51 | required: true 52 | - type: textarea 53 | id: steps 54 | attributes: 55 | label: Steps to reproduce 56 | description: | 57 | Steps to reproduce the issue. 58 | Please **don't** report issues that are not reproducible. 59 | placeholder: | 60 | 1. Go to '...' 61 | 2. Click on '...' 62 | 3. Scroll down to '...' 63 | 4. See error 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: expected 68 | attributes: 69 | label: Expected behavior 70 | description: A clear and concise description of what you expected to happen. 71 | validations: 72 | required: false 73 | - type: input 74 | id: logs 75 | attributes: 76 | label: Log files 77 | description: | 78 | Please provide log files of the game session in which the problem occurred. 79 | Don't paste the complete logs into the issue. 80 | You can use [https://gist.github.com/](https://gist.github.com/). 81 | placeholder: https://gist.github.com/exampleuser/example 82 | validations: 83 | required: true 84 | - type: textarea 85 | id: screenshots 86 | attributes: 87 | label: Screenshots 88 | description: Screenshots of the issue. 89 | validations: 90 | required: false 91 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Simple Voice Chat Wiki 4 | about: Useful information about configuring and setting up the voice chat mod 5 | url: https://modrepo.de/minecraft/voicechat/wiki 6 | - name: Simple Voice Chat Discord 7 | about: Get support on the Simple Voice Chat Discord Server 8 | url: https://discord.gg/4dH2zwTmyX 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/translation.yml: -------------------------------------------------------------------------------- 1 | name: Translation 2 | description: Submit a translation for this project 3 | labels: [translation] 4 | assignees: henkelmax 5 | body: 6 | - type: textarea 7 | id: notes 8 | attributes: 9 | label: Additional notes 10 | description: Additional information. 11 | validations: 12 | required: false 13 | - type: input 14 | id: locale_code 15 | attributes: 16 | label: Locale code 17 | description: The Minecraft locale code (See [this](https://minecraft.wiki/w/Language#Languages) for more information). 18 | placeholder: en_us 19 | validations: 20 | required: true 21 | - type: textarea 22 | id: translation 23 | attributes: 24 | label: Translation json 25 | description: The contents of your translation file. 26 | render: json 27 | placeholder: | 28 | { 29 | "translation.key": "Translated value" 30 | } 31 | validations: 32 | required: true 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # User-specific stuff 2 | .idea/ 3 | 4 | *.iml 5 | *.ipr 6 | *.iws 7 | 8 | # IntelliJ 9 | out/ 10 | # mpeltonen/sbt-idea plugin 11 | .idea_modules/ 12 | 13 | # JIRA plugin 14 | atlassian-ide-plugin.xml 15 | 16 | # Compiled class file 17 | *.class 18 | 19 | # Log file 20 | *.log 21 | 22 | # BlueJ files 23 | *.ctxt 24 | 25 | # Package Files # 26 | *.jar 27 | *.war 28 | *.nar 29 | *.ear 30 | *.zip 31 | *.tar.gz 32 | *.rar 33 | 34 | # virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml 35 | hs_err_pid* 36 | 37 | *~ 38 | 39 | # temporary files which can be created if a process still has a handle open of a deleted file 40 | .fuse_hidden* 41 | 42 | # KDE directory preferences 43 | .directory 44 | 45 | # Linux trash folder which might appear on any partition or disk 46 | .Trash-* 47 | 48 | # .nfs files are created when an open file is removed but is still being accessed 49 | .nfs* 50 | 51 | # General 52 | .DS_Store 53 | .AppleDouble 54 | .LSOverride 55 | 56 | # Icon must end with two \r 57 | Icon 58 | 59 | # Thumbnails 60 | ._* 61 | 62 | # Files that might appear in the root of a volume 63 | .DocumentRevisions-V100 64 | .fseventsd 65 | .Spotlight-V100 66 | .TemporaryItems 67 | .Trashes 68 | .VolumeIcon.icns 69 | .com.apple.timemachine.donotpresent 70 | 71 | # Directories potentially created on remote AFP share 72 | .AppleDB 73 | .AppleDesktop 74 | Network Trash Folder 75 | Temporary Items 76 | .apdisk 77 | 78 | # Windows thumbnail cache files 79 | Thumbs.db 80 | Thumbs.db:encryptable 81 | ehthumbs.db 82 | ehthumbs_vista.db 83 | 84 | # Dump file 85 | *.stackdump 86 | 87 | # Folder config file 88 | [Dd]esktop.ini 89 | 90 | # Recycle Bin used on file shares 91 | $RECYCLE.BIN/ 92 | 93 | # Windows Installer files 94 | *.cab 95 | *.msi 96 | *.msix 97 | *.msm 98 | *.msp 99 | 100 | # Windows shortcuts 101 | *.lnk 102 | 103 | .gradle 104 | build/ 105 | 106 | # Ignore Gradle GUI config 107 | gradle-app.setting 108 | 109 | # Cache of project 110 | .gradletasknamecache 111 | 112 | **/build/ 113 | 114 | # Common working directory 115 | run/ 116 | 117 | # Avoid ignoring Gradle wrapper jar file (.jar files are usually ignored) 118 | !gradle-wrapper.jar 119 | 120 | curseforge_api_key.txt 121 | mod_update_api_key.txt 122 | modrinth_token.txt 123 | libs/ 124 | src/main/resources/web/ -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | plugins { 2 | id 'com.github.johnrengelman.shadow' version "${shadow_version}" 3 | id 'fabric-loom' version "${fabric_loom_version}" 4 | id 'mod-update' version "${mod_update_version}" 5 | id 'com.matthewprenger.cursegradle' version "${cursegradle_version}" 6 | id 'com.modrinth.minotaur' version "${minotaur_version}" 7 | id 'com.github.node-gradle.node' version "${node_gradle_version}" 8 | } 9 | 10 | apply from: "https://raw.githubusercontent.com/henkelmax/mod-gradle-scripts/${mod_gradle_script_version}/mod.gradle" 11 | 12 | repositories { 13 | 14 | } 15 | 16 | dependencies { 17 | include(modImplementation("maven.modrinth:admiral:${admiral_version}+fabric")) 18 | modImplementation "me.lucko:fabric-permissions-api:${fabric_permission_api_version}" 19 | 20 | implementation("com.googlecode.soundlibs:mp3spi:${mp3spi_version}") { 21 | exclude group: 'junit', module: 'junit' 22 | } 23 | shadow("com.googlecode.soundlibs:mp3spi:${mp3spi_version}") { 24 | exclude group: 'junit', module: 'junit' 25 | } 26 | 27 | implementation "org.microhttp:microhttp:${microhttp_version}" 28 | shadow "org.microhttp:microhttp:${microhttp_version}" 29 | 30 | implementation "de.maxhenkel.voicechat:voicechat-api:${voicechat_api_version}" 31 | modRuntimeOnly "maven.modrinth:simple-voice-chat:fabric-${voicechat_mod_version}" 32 | 33 | Set voicechatModules = [ 34 | 'fabric-api-base', 35 | 'fabric-command-api-v2', 36 | 'fabric-lifecycle-events-v1', 37 | 'fabric-networking-api-v1', 38 | 'fabric-resource-loader-v0', 39 | 'fabric-key-binding-api-v1' 40 | ] 41 | voicechatModules.forEach { 42 | modRuntimeOnly(fabricApi.module(it, fabric_api_version)) 43 | } 44 | } 45 | 46 | processResources { 47 | filesMatching('fabric.mod.json') { 48 | expand 'mod_version': mod_version, 49 | 'minecraft_dependency': minecraft_dependency, 50 | 'minecraft_version': minecraft_version, 51 | 'fabric_loader_dependency': fabric_loader_dependency, 52 | 'fabric_api_dependency_breaks': fabric_api_dependency_breaks, 53 | 'voicechat_api_version': voicechat_api_version 54 | } 55 | exclude '**/.gitkeep' 56 | } 57 | 58 | shadowJar { 59 | relocate 'javazoom', "de.maxhenkel.audioplayer.javazoom" 60 | relocate 'org.tritonus', "de.maxhenkel.audioplayer.tritonus" 61 | relocate 'org.microhttp', "de.maxhenkel.audioplayer.microhttp" 62 | } 63 | 64 | node { 65 | download = false 66 | nodeProjectDir = file("${project.projectDir}/web") 67 | } 68 | 69 | tasks.register('buildWeb', NpmTask) { 70 | group 'web' 71 | args = ['run', 'build'] 72 | } 73 | tasks.buildWeb.dependsOn(npmInstall) 74 | 75 | tasks.register('copyWeb', Copy) { 76 | group 'web' 77 | from 'web/dist' 78 | into 'src/main/resources/web' 79 | } 80 | tasks.copyWeb.dependsOn(buildWeb) 81 | tasks.processResources.dependsOn(copyWeb) 82 | 83 | tasks.register('cleanWeb', Delete) { 84 | group 'web' 85 | delete fileTree(dir: 'src/main/resources/web', exclude: '**/.gitkeep') 86 | delete 'web/dist' 87 | followSymlinks = true 88 | } 89 | tasks.clean.dependsOn(cleanWeb) -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | - Updated to 1.21.5 2 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | org.gradle.jvmargs=-Xmx4G 2 | org.gradle.daemon=false 3 | 4 | java_version=21 5 | java_toolchain_version=21 6 | 7 | mod_loader=fabric 8 | minecraft_version=1.21.5 9 | minecraft_dependency=1.21.5 10 | fabric_loader_version=0.16.10 11 | fabric_loader_dependency=>=0.16.10 12 | fabric_api_version=0.119.5+1.21.5 13 | fabric_api_dependency_breaks=<0.119.5+1.21.5 14 | 15 | voicechat_api_version=2.3.3 16 | voicechat_mod_version=1.21.5-2.5.28 17 | 18 | mp3spi_version=1.9.5.4 19 | admiral_version=0.4.8+1.21.4 20 | fabric_permission_api_version=0.3.3 21 | microhttp_version=0.11 22 | 23 | # Mod information 24 | mod_version=1.21.5-1.13.2 25 | mod_id=audioplayer 26 | mod_display_name=AudioPlayer 27 | 28 | # Script configuration 29 | use_mixins=true 30 | enable_configbuilder=true 31 | enable_accesswideners=true 32 | 33 | # Project upload 34 | curseforge_upload_id=549719 35 | modrinth_upload_id=SRlzjEBS 36 | upload_release_type=alpha 37 | upload_recommended=true 38 | 39 | curseforge_upload_required_dependencies=simple-voice-chat 40 | modrinth_upload_required_dependencies=simple-voice-chat 41 | 42 | # Fabric API modules 43 | included_fabric_api_modules=fabric-api-base, fabric-command-api-v2, fabric-lifecycle-events-v1 44 | 45 | # Gradle plugins 46 | mod_gradle_script_version=1.0.40 47 | shadow_version=8.1.1 48 | fabric_loom_version=1.10-SNAPSHOT 49 | mod_update_version=2.0.0 50 | cursegradle_version=1.4.0 51 | minotaur_version=2.+ 52 | node_gradle_version=7.0.2 53 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | distributionBase=GRADLE_USER_HOME 2 | distributionPath=wrapper/dists 3 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.12.1-bin.zip 4 | networkTimeout=10000 5 | validateDistributionUrl=true 6 | zipStoreBase=GRADLE_USER_HOME 7 | zipStorePath=wrapper/dists 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # 4 | # Copyright © 2015-2021 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/HEAD/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 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 118 | 119 | 120 | # Determine the Java command to use to start the JVM. 121 | if [ -n "$JAVA_HOME" ] ; then 122 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 123 | # IBM's JDK on AIX uses strange locations for the executables 124 | JAVACMD=$JAVA_HOME/jre/sh/java 125 | else 126 | JAVACMD=$JAVA_HOME/bin/java 127 | fi 128 | if [ ! -x "$JAVACMD" ] ; then 129 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 130 | 131 | Please set the JAVA_HOME variable in your environment to match the 132 | location of your Java installation." 133 | fi 134 | else 135 | JAVACMD=java 136 | if ! command -v java >/dev/null 2>&1 137 | then 138 | die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 139 | 140 | Please set the JAVA_HOME variable in your environment to match the 141 | location of your Java installation." 142 | fi 143 | fi 144 | 145 | # Increase the maximum file descriptors if we can. 146 | if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then 147 | case $MAX_FD in #( 148 | max*) 149 | # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. 150 | # shellcheck disable=SC2039,SC3045 151 | MAX_FD=$( ulimit -H -n ) || 152 | warn "Could not query maximum file descriptor limit" 153 | esac 154 | case $MAX_FD in #( 155 | '' | soft) :;; #( 156 | *) 157 | # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. 158 | # shellcheck disable=SC2039,SC3045 159 | ulimit -n "$MAX_FD" || 160 | warn "Could not set maximum file descriptor limit to $MAX_FD" 161 | esac 162 | fi 163 | 164 | # Collect all arguments for the java command, stacking in reverse order: 165 | # * args from the command line 166 | # * the main class name 167 | # * -classpath 168 | # * -D...appname settings 169 | # * --module-path (only if needed) 170 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. 171 | 172 | # For Cygwin or MSYS, switch paths to Windows format before running java 173 | if "$cygwin" || "$msys" ; then 174 | APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) 175 | CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) 176 | 177 | JAVACMD=$( cygpath --unix "$JAVACMD" ) 178 | 179 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 180 | for arg do 181 | if 182 | case $arg in #( 183 | -*) false ;; # don't mess with options #( 184 | /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath 185 | [ -e "$t" ] ;; #( 186 | *) false ;; 187 | esac 188 | then 189 | arg=$( cygpath --path --ignore --mixed "$arg" ) 190 | fi 191 | # Roll the args list around exactly as many times as the number of 192 | # args, so each arg winds up back in the position where it started, but 193 | # possibly modified. 194 | # 195 | # NB: a `for` loop captures its iteration list before it begins, so 196 | # changing the positional parameters here affects neither the number of 197 | # iterations, nor the values presented in `arg`. 198 | shift # remove old arg 199 | set -- "$@" "$arg" # push replacement arg 200 | done 201 | fi 202 | 203 | 204 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 205 | DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' 206 | 207 | # Collect all arguments for the java command: 208 | # * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, 209 | # and any embedded shellness will be escaped. 210 | # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be 211 | # treated as '${Hostname}' itself on the command line. 212 | 213 | set -- \ 214 | "-Dorg.gradle.appname=$APP_BASE_NAME" \ 215 | -classpath "$CLASSPATH" \ 216 | org.gradle.wrapper.GradleWrapperMain \ 217 | "$@" 218 | 219 | # Stop when "xargs" is not available. 220 | if ! command -v xargs >/dev/null 2>&1 221 | then 222 | die "xargs is not available" 223 | fi 224 | 225 | # Use "xargs" to parse quoted args. 226 | # 227 | # With -n1 it outputs one arg per line, with the quotes and backslashes removed. 228 | # 229 | # In Bash we could simply go: 230 | # 231 | # readarray ARGS < <( xargs -n1 <<<"$var" ) && 232 | # set -- "${ARGS[@]}" "$@" 233 | # 234 | # but POSIX shell has neither arrays nor command substitution, so instead we 235 | # post-process each arg (as a line of input to sed) to backslash-escape any 236 | # character that might be a shell metacharacter, then use eval to reverse 237 | # that process (while maintaining the separation between arguments), and wrap 238 | # the whole thing up as a single "set" statement. 239 | # 240 | # This will of course break if any of these variables contains a newline or 241 | # an unmatched quote. 242 | # 243 | 244 | eval "set -- $( 245 | printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | 246 | xargs -n1 | 247 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | 248 | tr '\n' ' ' 249 | )" '"$@"' 250 | 251 | exec "$JAVACMD" "$@" 252 | -------------------------------------------------------------------------------- /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 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 74 | 75 | 76 | @rem Execute Gradle 77 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* 78 | 79 | :end 80 | @rem End local scope for the variables with windows NT shell 81 | if %ERRORLEVEL% equ 0 goto mainEnd 82 | 83 | :fail 84 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 85 | rem the _cmd.exe /c_ return code! 86 | set EXIT_CODE=%ERRORLEVEL% 87 | if %EXIT_CODE% equ 0 set EXIT_CODE=1 88 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% 89 | exit /b %EXIT_CODE% 90 | 91 | :mainEnd 92 | if "%OS%"=="Windows_NT" endlocal 93 | 94 | :omega 95 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # AudioPlayer 4 | 5 | ## Links 6 | 7 | - [CurseForge](https://www.curseforge.com/minecraft/mc-mods/audioplayer) 8 | - [Modrinth](https://modrinth.com/mod/audioplayer) 9 | - [Simple Voice Chat Discord](https://discord.gg/4dH2zwTmyX) 10 | 11 | --- 12 | 13 | 14 | 15 | This server side Fabric mod enables uploading custom audio for music discs, goat horns and note blocks with heads. 16 | 17 | This mod requires [Simple Voice Chat](https://www.curseforge.com/minecraft/mc-mods/simple-voice-chat) on the client and 18 | server. 19 | 20 | ## Features 21 | 22 | - On the fly audio uploading without needing to restart the server 23 | - Support for `mp3` and `wav` 24 | - Upload audio via a URL 25 | - Upload audio directly to your server 26 | - Upload audio via [Filebin](https://github.com/espebra/filebin2/) 27 | - Server side only 28 | - No server restart needed 29 | - No resource pack needed 30 | - No changes needed on the client 31 | - Configurable upload limit 32 | - Configurable command permissions 33 | - Configurable audio range 34 | - Per-item custom audio range 35 | - Bulk applying audio to all items in a shulker box 36 | - Configurable goat horn cooldown 37 | 38 | ## Commands 39 | 40 | Run `/audioplayer` to get general information on how to upload files. 41 | 42 | **Uploading audio files via URL** 43 | 44 | Run `/audioplayer url "https://example.com/myaudio.mp3"` where `https://example.com/myaudio.mp3` is the link to 45 | your `.mp3` or `.wav` file. 46 | 47 | **Uploading audio files directly to the server** 48 | 49 | Copy your `.mp3` or `.wav` file to the `audioplayer_uploads` folder in your server. 50 | Run `/audioplayer serverfile "yourfile.mp3"` where `yourfile.mp3` is the name of the file you put on the server. 51 | 52 | **Uploading audio files via Filebin** 53 | 54 | Run `/audioplayer filebin` and follow the instructions. 55 | 56 | **Putting custom audio on a music disc or goat horn** 57 | 58 | Run `/audioplayer apply ` and hold a **music disc**, **goat horn** or **head** in your main hand. 59 | Additionally, you can add a custom name and range to the item `/audioplayer apply ""`. 60 | 61 | It's also possible to bulk apply audio to more than one item at a time by holding a shulker box in your hand. 62 | 63 | Starting with version `1.9.1`, you can also apply custom audio by its original file name: 64 | `/audioplayer apply ""`. 65 | This command works with and without the file extension (like `.mp3` or `.wav`). 66 | Note that the file name must be unique for this to work. 67 | 68 | **Getting the audio from an existing item** 69 | 70 | Run `/audioplayer id` while holding a music disc, goat horn or head with custom audio in your main hand. 71 | 72 | **Getting the audio file name from an existing item** 73 | 74 | Run `/audioplayer name` while holding a music disc, goat horn or head with custom audio in your main hand. 75 | 76 | --- 77 | [![](https://user-images.githubusercontent.com/13237524/179395180-05f2ec3b-2ed3-412d-8639-72c7f13a8068.png)](https://youtu.be/j8GRcYnjUp8) 78 | 79 | [![](https://user-images.githubusercontent.com/13237524/179395233-582b70bc-f308-47c7-96ff-541257e86545.png)](https://youtu.be/tixidvB4Zko) 80 | 81 | ![](https://user-images.githubusercontent.com/13237524/179395296-be3643eb-1c23-4300-ac17-25d11d53d6f3.png) 82 | 83 | ![](https://user-images.githubusercontent.com/13237524/142997959-9120d038-4ee6-45bb-8815-2179884ef958.png) 84 | 85 | ![](https://user-images.githubusercontent.com/13237524/143213769-99a6b03a-887a-4b30-8b18-baf394be6b6c.png) 86 | 87 | ## Credits 88 | 89 | - [MP3SPI](https://github.com/umjammer/mp3spi) 90 | - [Simple Voice Chat](https://github.com/henkelmax/simple-voice-chat) 91 | - [Admiral](https://github.com/henkelmax/admiral) 92 | 93 | *Note that the files you upload to Filebin are publicly available if the upload link is disclosed!* 94 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | pluginManagement { 2 | repositories { 3 | gradlePluginPortal() 4 | maven { url = 'https://maven.fabricmc.net/' } 5 | maven { url = 'https://maven.maxhenkel.de/repository/public' } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/AudioCache.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import java.util.*; 4 | 5 | public class AudioCache { 6 | 7 | private final int size; 8 | private final Map audioCache; 9 | private final Deque accessQueue; 10 | 11 | public AudioCache(int size) { 12 | this.size = size; 13 | this.audioCache = new HashMap<>(); 14 | this.accessQueue = new ArrayDeque<>(); 15 | } 16 | 17 | public short[] get(UUID id, AudioSupplier supplier) throws Exception { 18 | synchronized (audioCache) { 19 | short[] data = audioCache.get(id); 20 | if (data == null) { 21 | short[] uncachedData = supplier.get(); 22 | pushCache(id, uncachedData); 23 | return uncachedData; 24 | } 25 | accessQueue.remove(id); 26 | accessQueue.addFirst(id); 27 | return data; 28 | } 29 | } 30 | 31 | public void remove(UUID id) { 32 | synchronized (audioCache) { 33 | audioCache.remove(id); 34 | accessQueue.remove(id); 35 | } 36 | } 37 | 38 | private void pushCache(UUID id, short[] data) { 39 | if (size <= 0) { 40 | return; 41 | } 42 | if (audioCache.containsKey(id)) { 43 | return; 44 | } 45 | if (accessQueue.size() >= size) { 46 | UUID leastRecentlyUsed = accessQueue.removeLast(); 47 | audioCache.remove(leastRecentlyUsed); 48 | } 49 | accessQueue.addFirst(id); 50 | audioCache.put(id, data); 51 | } 52 | 53 | public interface AudioSupplier { 54 | short[] get() throws Exception; 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/AudioConverter.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.voicechat.api.mp3.Mp3Decoder; 4 | 5 | import javax.annotation.Nullable; 6 | import javax.sound.sampled.*; 7 | import java.io.BufferedInputStream; 8 | import java.io.ByteArrayInputStream; 9 | import java.io.IOException; 10 | import java.io.InputStream; 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | 14 | public class AudioConverter { 15 | 16 | public static AudioFormat FORMAT = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, 48000F, 16, 1, 2, 48000F, false); 17 | 18 | @Nullable 19 | public static AudioType getAudioType(Path path) throws IOException { 20 | if (isWav(Files.newInputStream(path))) { 21 | return AudioType.WAV; 22 | } 23 | if (isMp3File(Files.newInputStream(path))) { 24 | return AudioType.MP3; 25 | } 26 | return null; 27 | } 28 | 29 | @Nullable 30 | public static AudioType getAudioType(byte[] data) throws IOException { 31 | if (isWav(new ByteArrayInputStream(data))) { 32 | return AudioType.WAV; 33 | } 34 | if (isMp3File(new ByteArrayInputStream(data))) { 35 | return AudioType.MP3; 36 | } 37 | return null; 38 | } 39 | 40 | public static boolean isWav(InputStream inputStream) throws IOException { 41 | try (BufferedInputStream bis = new BufferedInputStream(inputStream)) { 42 | AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(bis); 43 | return fileFormat.getType().toString().equalsIgnoreCase("wave"); 44 | } catch (UnsupportedAudioFileException e) { 45 | return false; 46 | } 47 | } 48 | 49 | public static boolean isMp3File(InputStream inputStream) throws IOException { 50 | try (BufferedInputStream bis = new BufferedInputStream(inputStream)) { 51 | AudioFileFormat fileFormat = AudioSystem.getAudioFileFormat(bis); 52 | return fileFormat.getType().toString().equalsIgnoreCase("mp3"); 53 | } catch (UnsupportedAudioFileException e) { 54 | return false; 55 | } 56 | } 57 | 58 | public static short[] convert(Path file, float volume) throws IOException, UnsupportedAudioFileException { 59 | return convert(file, getAudioType(file), volume); 60 | } 61 | 62 | public static short[] convert(Path file, AudioType audioType, float volume) throws IOException, UnsupportedAudioFileException { 63 | if (audioType == AudioType.WAV) { 64 | return convertWav(file, volume); 65 | } else if (audioType == AudioType.MP3) { 66 | return convertMp3(file, volume); 67 | } 68 | throw new UnsupportedAudioFileException("Unsupported audio type"); 69 | } 70 | 71 | public static short[] convertWav(Path file, float volume) throws IOException, UnsupportedAudioFileException { 72 | try (AudioInputStream source = AudioSystem.getAudioInputStream(file.toFile())) { 73 | return convert(source, volume); 74 | } 75 | } 76 | 77 | private static short[] convert(AudioInputStream source, float volume) throws IOException { 78 | AudioFormat sourceFormat = source.getFormat(); 79 | AudioFormat convertFormat = new AudioFormat(AudioFormat.Encoding.PCM_SIGNED, sourceFormat.getSampleRate(), 16, sourceFormat.getChannels(), sourceFormat.getChannels() * 2, sourceFormat.getSampleRate(), false); 80 | AudioInputStream stream1 = AudioSystem.getAudioInputStream(convertFormat, source); 81 | AudioInputStream stream2 = AudioSystem.getAudioInputStream(FORMAT, stream1); 82 | return Plugin.voicechatApi.getAudioConverter().bytesToShorts(adjustVolume(stream2.readAllBytes(), volume)); 83 | } 84 | 85 | private static byte[] adjustVolume(byte[] audioSamples, float volume) { 86 | for (int i = 0; i < audioSamples.length; i += 2) { 87 | short buf1 = audioSamples[i + 1]; 88 | short buf2 = audioSamples[i]; 89 | 90 | buf1 = (short) ((buf1 & 0xFF) << 8); 91 | buf2 = (short) (buf2 & 0xFF); 92 | 93 | short res = (short) (buf1 | buf2); 94 | res = (short) (res * volume); 95 | 96 | audioSamples[i] = (byte) res; 97 | audioSamples[i + 1] = (byte) (res >> 8); 98 | 99 | } 100 | return audioSamples; 101 | } 102 | 103 | public static short[] convertMp3(Path file, float volume) throws IOException, UnsupportedAudioFileException { 104 | try { 105 | Mp3Decoder mp3Decoder = Plugin.voicechatApi.createMp3Decoder(Files.newInputStream(file)); 106 | if (mp3Decoder == null) { 107 | throw new IOException("Error creating mp3 decoder"); 108 | } 109 | byte[] data = Plugin.voicechatApi.getAudioConverter().shortsToBytes(mp3Decoder.decode()); 110 | ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(data); 111 | AudioFormat audioFormat = mp3Decoder.getAudioFormat(); 112 | AudioInputStream source = new AudioInputStream(byteArrayInputStream, audioFormat, data.length / audioFormat.getFrameSize()); 113 | return convert(source, volume); 114 | } catch (Exception e) { 115 | AudioPlayer.LOGGER.warn("Error converting mp3 file with native decoder"); 116 | return convert(AudioSystem.getAudioInputStream(file.toFile()), volume); 117 | } 118 | } 119 | 120 | public enum AudioType { 121 | MP3("mp3"), 122 | WAV("wav"); 123 | 124 | private final String extension; 125 | 126 | AudioType(String fileName) { 127 | this.extension = fileName; 128 | } 129 | 130 | public boolean isValidFileName(Path path) { 131 | return path.toString().toLowerCase().endsWith(".%s".formatted(extension)); 132 | } 133 | 134 | public String getExtension() { 135 | return extension; 136 | } 137 | } 138 | 139 | } 140 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/AudioManager.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.voicechat.api.VoicechatServerApi; 4 | import net.fabricmc.loader.api.FabricLoader; 5 | import net.minecraft.core.BlockPos; 6 | import net.minecraft.server.MinecraftServer; 7 | import net.minecraft.server.level.ServerLevel; 8 | import net.minecraft.server.level.ServerPlayer; 9 | import net.minecraft.world.entity.player.Player; 10 | import net.minecraft.world.level.storage.LevelResource; 11 | import net.minecraft.world.phys.Vec3; 12 | import org.apache.commons.io.IOUtils; 13 | 14 | import javax.annotation.Nullable; 15 | import javax.sound.sampled.UnsupportedAudioFileException; 16 | import java.io.*; 17 | import java.net.HttpURLConnection; 18 | import java.net.URL; 19 | import java.nio.file.FileAlreadyExistsException; 20 | import java.nio.file.Files; 21 | import java.nio.file.NoSuchFileException; 22 | import java.nio.file.Path; 23 | import java.util.UUID; 24 | 25 | public class AudioManager { 26 | 27 | public static LevelResource AUDIO_DATA = new LevelResource("audio_player_data"); 28 | 29 | public static short[] getSound(MinecraftServer server, UUID id) throws Exception { 30 | float volume; 31 | if (VolumeOverrideManager.instance().isPresent()) { 32 | volume = VolumeOverrideManager.instance().get().getAudioVolume(id); 33 | } else { 34 | volume = 1F; 35 | } 36 | return AudioPlayer.AUDIO_CACHE.get(id, () -> AudioConverter.convert(getExistingSoundFile(server, id), volume)); 37 | } 38 | 39 | public static Path getSoundFile(MinecraftServer server, UUID id, String extension) { 40 | return getAudioDataFolder(server).resolve(id.toString() + "." + extension); 41 | } 42 | 43 | public static Path getAudioDataFolder(MinecraftServer server) { 44 | return server.getWorldPath(AUDIO_DATA); 45 | } 46 | 47 | public static Path getExistingSoundFile(MinecraftServer server, UUID id) throws FileNotFoundException { 48 | Path file = getSoundFile(server, id, AudioConverter.AudioType.MP3.getExtension()); 49 | if (Files.exists(file)) { 50 | return file; 51 | } 52 | file = getSoundFile(server, id, AudioConverter.AudioType.WAV.getExtension()); 53 | if (Files.exists(file)) { 54 | return file; 55 | } 56 | throw new FileNotFoundException("Audio does not exist"); 57 | } 58 | 59 | public static boolean checkSoundExists(MinecraftServer server, UUID id) { 60 | Path file = getSoundFile(server, id, AudioConverter.AudioType.MP3.getExtension()); 61 | if (Files.exists(file)) { 62 | return true; 63 | } 64 | file = getSoundFile(server, id, AudioConverter.AudioType.WAV.getExtension()); 65 | return Files.exists(file); 66 | } 67 | 68 | public static Path getUploadFolder() { 69 | return FabricLoader.getInstance().getGameDir().resolve("audioplayer_uploads"); 70 | } 71 | 72 | public static void saveSound(MinecraftServer server, UUID id, String url) throws UnsupportedAudioFileException, IOException { 73 | byte[] data = download(new URL(url), AudioPlayer.SERVER_CONFIG.maxUploadSize.get()); 74 | saveSound(server, id, FileNameManager.getFileNameFromUrl(url), data); 75 | } 76 | 77 | public static void saveSound(MinecraftServer server, UUID id, String fileName, byte[] data) throws UnsupportedAudioFileException, IOException { 78 | AudioConverter.AudioType audioType = AudioConverter.getAudioType(data); 79 | checkExtensionAllowed(audioType); 80 | 81 | Path soundFile = getSoundFile(server, id, audioType.getExtension()); 82 | if (Files.exists(soundFile)) { 83 | throw new FileAlreadyExistsException("This audio already exists"); 84 | } 85 | Files.createDirectories(soundFile.getParent()); 86 | 87 | try (OutputStream outputStream = Files.newOutputStream(soundFile)) { 88 | IOUtils.write(data, outputStream); 89 | } 90 | 91 | FileNameManager.instance().ifPresent(mgr -> mgr.addFileName(id, fileName)); 92 | } 93 | 94 | public static void saveSound(MinecraftServer server, UUID id, Path file) throws UnsupportedAudioFileException, IOException { 95 | if (!Files.exists(file) || !Files.isRegularFile(file)) { 96 | throw new NoSuchFileException("The file %s does not exist".formatted(file.toString())); 97 | } 98 | 99 | long size = Files.size(file); 100 | if (size > AudioPlayer.SERVER_CONFIG.maxUploadSize.get()) { 101 | throw new IOException("Maximum file size exceeded (%sMB>%sMB)".formatted(Math.round((float) size / 1_000_000F), Math.round(AudioPlayer.SERVER_CONFIG.maxUploadSize.get().floatValue() / 1_000_000F))); 102 | } 103 | 104 | AudioConverter.AudioType audioType = AudioConverter.getAudioType(file); 105 | checkExtensionAllowed(audioType); 106 | 107 | Path soundFile = getSoundFile(server, id, audioType.getExtension()); 108 | if (Files.exists(soundFile)) { 109 | throw new FileAlreadyExistsException("This audio already exists"); 110 | } 111 | Files.createDirectories(soundFile.getParent()); 112 | 113 | Files.move(file, soundFile); 114 | FileNameManager.instance().ifPresent(mgr -> mgr.addFileName(id, FileNameManager.getFileNameFromPath(file))); 115 | } 116 | 117 | public static void checkExtensionAllowed(@Nullable AudioConverter.AudioType audioType) throws UnsupportedAudioFileException { 118 | if (audioType == null) { 119 | throw new UnsupportedAudioFileException("Unsupported audio format"); 120 | } 121 | if (audioType.equals(AudioConverter.AudioType.MP3)) { 122 | if (!AudioPlayer.SERVER_CONFIG.allowMp3Upload.get()) { 123 | throw new UnsupportedAudioFileException("Uploading mp3 files is not allowed on this server"); 124 | } 125 | } 126 | if (audioType.equals(AudioConverter.AudioType.WAV)) { 127 | if (!AudioPlayer.SERVER_CONFIG.allowWavUpload.get()) { 128 | throw new UnsupportedAudioFileException("Uploading wav files is not allowed on this server"); 129 | } 130 | } 131 | } 132 | 133 | private static byte[] download(URL url, long limit) throws IOException { 134 | ByteArrayOutputStream bos = new ByteArrayOutputStream(); 135 | HttpURLConnection connection = (HttpURLConnection) url.openConnection(); 136 | connection.setRequestProperty("User-Agent", Filebin.USER_AGENT); 137 | connection.connect(); 138 | 139 | BufferedInputStream bis = new BufferedInputStream(connection.getInputStream()); 140 | 141 | int nRead; 142 | byte[] data = new byte[32768]; 143 | 144 | while ((nRead = bis.read(data, 0, data.length)) != -1) { 145 | bos.write(data, 0, nRead); 146 | if (bos.size() > limit) { 147 | bis.close(); 148 | throw new IOException("Maximum file size of %sMB exceeded".formatted((int) (((float) limit) / 1_000_000F))); 149 | } 150 | } 151 | bis.close(); 152 | return bos.toByteArray(); 153 | } 154 | 155 | @Nullable 156 | public static UUID play(ServerLevel level, BlockPos pos, PlayerType type, CustomSound sound, @Nullable Player player) { 157 | float range = sound.getRange(type); 158 | 159 | VoicechatServerApi api = Plugin.voicechatServerApi; 160 | if (api == null) { 161 | return null; 162 | } 163 | 164 | @Nullable UUID channelID; 165 | if (type.equals(PlayerType.GOAT_HORN)) { 166 | Vec3 playerPos; 167 | if (player == null) { 168 | playerPos = new Vec3(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D); 169 | } else { 170 | playerPos = player.position(); 171 | } 172 | channelID = PlayerManager.instance().playLocational( 173 | api, 174 | level, 175 | playerPos, 176 | sound.getSoundId(), 177 | (player instanceof ServerPlayer p) ? p : null, 178 | range, 179 | type.getCategory(), 180 | type.getMaxDuration().get() 181 | ); 182 | } else if (sound.isStaticSound() && AudioPlayer.SERVER_CONFIG.allowStaticAudio.get()) { //TODO Move option 183 | channelID = PlayerManager.instance().playStatic( 184 | api, 185 | level, 186 | new Vec3(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D), 187 | sound.getSoundId(), 188 | (player instanceof ServerPlayer p) ? p : null, 189 | range, 190 | type.getCategory(), 191 | type.getMaxDuration().get() 192 | ); 193 | } else { 194 | channelID = PlayerManager.instance().playLocational( 195 | api, 196 | level, 197 | new Vec3(pos.getX() + 0.5D, pos.getY() + 0.5D, pos.getZ() + 0.5D), 198 | sound.getSoundId(), 199 | (player instanceof ServerPlayer p) ? p : null, 200 | range, 201 | type.getCategory(), 202 | type.getMaxDuration().get() 203 | ); 204 | } 205 | 206 | return channelID; 207 | } 208 | 209 | public static float getLengthSeconds(short[] audio) { 210 | return (float) audio.length / AudioConverter.FORMAT.getSampleRate(); 211 | } 212 | 213 | } 214 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/AudioPlayer.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.admiral.MinecraftAdmiral; 4 | import de.maxhenkel.audioplayer.command.*; 5 | import de.maxhenkel.audioplayer.config.ServerConfig; 6 | import de.maxhenkel.audioplayer.config.WebServerConfig; 7 | import de.maxhenkel.audioplayer.webserver.WebServerEvents; 8 | import de.maxhenkel.configbuilder.ConfigBuilder; 9 | import net.fabricmc.api.ModInitializer; 10 | import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; 11 | import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; 12 | import net.fabricmc.loader.api.FabricLoader; 13 | import org.apache.logging.log4j.LogManager; 14 | import org.apache.logging.log4j.Logger; 15 | 16 | import java.io.IOException; 17 | import java.nio.file.Files; 18 | import java.nio.file.Path; 19 | import java.util.concurrent.Executors; 20 | import java.util.concurrent.ScheduledExecutorService; 21 | 22 | public class AudioPlayer implements ModInitializer { 23 | 24 | public static final String MODID = "audioplayer"; 25 | public static final Logger LOGGER = LogManager.getLogger(MODID); 26 | public static ServerConfig SERVER_CONFIG; 27 | public static WebServerConfig WEB_SERVER_CONFIG; 28 | 29 | public static AudioCache AUDIO_CACHE; 30 | public static ScheduledExecutorService SCHEDULED_EXECUTOR = Executors.newScheduledThreadPool(1, r -> { 31 | Thread thread = new Thread(r, "AudioPlayerExecutor"); 32 | thread.setDaemon(true); 33 | thread.setUncaughtExceptionHandler((t, e) -> { 34 | AudioPlayer.LOGGER.error("Uncaught exception in thread {}", t.getName(), e); 35 | }); 36 | return thread; 37 | }); 38 | 39 | @Override 40 | public void onInitialize() { 41 | CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> { 42 | MinecraftAdmiral.builder(dispatcher, registryAccess).addCommandClasses( 43 | UploadCommands.class, 44 | ApplyCommands.class, 45 | UtilityCommands.class, 46 | VolumeCommands.class, 47 | PlayCommands.class 48 | ).setPermissionManager(AudioPlayerPermissionManager.INSTANCE).build(); 49 | }); 50 | VolumeOverrideManager.init(); 51 | FileNameManager.init(); 52 | Path configFolder = FabricLoader.getInstance().getConfigDir().resolve(MODID); 53 | SERVER_CONFIG = ConfigBuilder.builder(ServerConfig::new).path(configFolder.resolve("audioplayer-server.properties")).build(); 54 | if (SERVER_CONFIG.runWebServer.get()) { 55 | WEB_SERVER_CONFIG = ConfigBuilder.builder(WebServerConfig::new).path(configFolder.resolve("webserver.properties")).build(); 56 | } else { 57 | WEB_SERVER_CONFIG = ConfigBuilder.builder(WebServerConfig::new).build(); 58 | } 59 | 60 | try { 61 | Files.createDirectories(AudioManager.getUploadFolder()); 62 | } catch (IOException e) { 63 | LOGGER.warn("Failed to create upload folder", e); 64 | } 65 | 66 | AUDIO_CACHE = new AudioCache(SERVER_CONFIG.cacheSize.get()); 67 | 68 | ServerLifecycleEvents.SERVER_STARTED.register(WebServerEvents::onServerStarted); 69 | ServerLifecycleEvents.SERVER_STOPPING.register(WebServerEvents::onServerStopped); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/AudioPlayerPermissionManager.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.admiral.permissions.PermissionManager; 4 | import me.lucko.fabric.api.permissions.v0.Permissions; 5 | import net.fabricmc.fabric.api.util.TriState; 6 | import net.fabricmc.loader.api.FabricLoader; 7 | import net.minecraft.commands.CommandSourceStack; 8 | import net.minecraft.server.level.ServerPlayer; 9 | 10 | import java.util.List; 11 | 12 | public class AudioPlayerPermissionManager implements PermissionManager { 13 | 14 | public static final AudioPlayerPermissionManager INSTANCE = new AudioPlayerPermissionManager(); 15 | 16 | private static final Permission VOLUME_PERMISSION = new Permission("audioplayer.volume", PermissionType.EVERYONE); 17 | private static final Permission UPLOAD_PERMISSION = new Permission("audioplayer.upload", PermissionType.EVERYONE); 18 | private static final Permission APPLY_PERMISSION = new Permission("audioplayer.apply", PermissionType.EVERYONE); 19 | private static final Permission APPLY_ANNOUNCER_PERMISSION = new AnnouncerPermission("audioplayer.set_static", PermissionType.EVERYONE); 20 | private static final Permission PLAY_COMMAND_PERMISSION = new Permission("audioplayer.play_command", PermissionType.OPS); 21 | 22 | private static final List PERMISSIONS = List.of( 23 | UPLOAD_PERMISSION, 24 | APPLY_PERMISSION, 25 | APPLY_ANNOUNCER_PERMISSION, 26 | PLAY_COMMAND_PERMISSION, 27 | VOLUME_PERMISSION 28 | ); 29 | 30 | @Override 31 | public boolean hasPermission(CommandSourceStack stack, String permission) { 32 | for (Permission p : PERMISSIONS) { 33 | if (!p.permission.equals(permission)) { 34 | continue; 35 | } 36 | if (!p.canUse()) { 37 | return false; 38 | } 39 | if (stack.isPlayer()) { 40 | return p.hasPermission(stack.getPlayer()); 41 | } 42 | return stack.hasPermission(2); 43 | } 44 | return false; 45 | } 46 | 47 | private static Boolean loaded; 48 | 49 | private static boolean isFabricPermissionsAPILoaded() { 50 | if (loaded == null) { 51 | loaded = FabricLoader.getInstance().isModLoaded("fabric-permissions-api-v0"); 52 | if (loaded) { 53 | AudioPlayer.LOGGER.info("Using Fabric Permissions API"); 54 | } 55 | } 56 | return loaded; 57 | } 58 | 59 | private static class Permission { 60 | private final String permission; 61 | private final PermissionType type; 62 | 63 | public Permission(String permission, PermissionType type) { 64 | this.permission = permission; 65 | this.type = type; 66 | } 67 | 68 | public boolean canUse() { 69 | return true; 70 | } 71 | 72 | public boolean hasPermission(ServerPlayer player) { 73 | if (isFabricPermissionsAPILoaded()) { 74 | return checkFabricPermission(player); 75 | } 76 | return type.hasPermission(player); 77 | } 78 | 79 | private boolean checkFabricPermission(ServerPlayer player) { 80 | TriState permissionValue = Permissions.getPermissionValue(player, permission); 81 | return switch (permissionValue) { 82 | case DEFAULT -> type.hasPermission(player); 83 | case TRUE -> true; 84 | default -> false; 85 | }; 86 | } 87 | 88 | public PermissionType getType() { 89 | return type; 90 | } 91 | } 92 | 93 | private static class AnnouncerPermission extends Permission { 94 | 95 | public AnnouncerPermission(String permission, PermissionType type) { 96 | super(permission, type); 97 | } 98 | 99 | @Override 100 | public boolean canUse() { 101 | return AudioPlayer.SERVER_CONFIG.allowStaticAudio.get() && super.canUse(); 102 | } 103 | } 104 | 105 | private static enum PermissionType { 106 | 107 | EVERYONE, NOONE, OPS; 108 | 109 | boolean hasPermission(ServerPlayer player) { 110 | return switch (this) { 111 | case EVERYONE -> true; 112 | case NOONE -> false; 113 | case OPS -> player != null && player.hasPermissions(player.server.getOperatorUserPermissionLevel()); 114 | }; 115 | } 116 | 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/ComponentUtils.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import net.minecraft.core.Holder; 4 | import net.minecraft.core.registries.Registries; 5 | import net.minecraft.network.chat.Component; 6 | import net.minecraft.resources.ResourceKey; 7 | import net.minecraft.resources.ResourceLocation; 8 | import net.minecraft.sounds.SoundEvents; 9 | import net.minecraft.world.item.EitherHolder; 10 | import net.minecraft.world.item.Instrument; 11 | import net.minecraft.world.item.JukeboxPlayable; 12 | import net.minecraft.world.item.JukeboxSong; 13 | import net.minecraft.world.item.component.InstrumentComponent; 14 | 15 | public class ComponentUtils { 16 | 17 | public static final InstrumentComponent EMPTY_INSTRUMENT = new InstrumentComponent(Holder.direct(new Instrument(Holder.direct(SoundEvents.EMPTY), 140, 256F, Component.empty()))); 18 | 19 | public static final ResourceKey CUSTOM_JUKEBOX_SONG_KEY = ResourceKey.create(Registries.JUKEBOX_SONG, ResourceLocation.fromNamespaceAndPath(AudioPlayer.MODID, "custom")); 20 | public static final JukeboxPlayable CUSTOM_JUKEBOX_PLAYABLE = new JukeboxPlayable(new EitherHolder<>(CUSTOM_JUKEBOX_SONG_KEY)); 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/CustomSound.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.configbuilder.entry.ConfigEntry; 4 | import net.minecraft.ChatFormatting; 5 | import net.minecraft.core.UUIDUtil; 6 | import net.minecraft.core.component.DataComponentType; 7 | import net.minecraft.core.component.DataComponents; 8 | import net.minecraft.nbt.*; 9 | import net.minecraft.network.chat.Component; 10 | import net.minecraft.world.item.BlockItem; 11 | import net.minecraft.world.item.ItemStack; 12 | import net.minecraft.world.item.component.CustomData; 13 | import net.minecraft.world.item.component.ItemLore; 14 | import net.minecraft.world.item.component.TooltipDisplay; 15 | import net.minecraft.world.level.block.SkullBlock; 16 | import net.minecraft.world.level.block.entity.BlockEntityType; 17 | 18 | import javax.annotation.Nullable; 19 | import java.util.*; 20 | import java.util.concurrent.ThreadLocalRandom; 21 | 22 | public class CustomSound { 23 | 24 | public static final String CUSTOM_SOUND = "CustomSound"; 25 | public static final String CUSTOM_SOUND_RANDOM = "CustomSoundRandomized"; 26 | public static final String CUSTOM_SOUND_RANGE = "CustomSoundRange"; 27 | public static final String CUSTOM_SOUND_STATIC = "IsStaticCustomSound"; 28 | private static final String ID = "id"; 29 | 30 | public static final String DEFAULT_HEAD_LORE = "Has custom audio"; 31 | 32 | protected UUID soundId; 33 | protected ArrayList randomSounds; 34 | @Nullable 35 | protected Float range; 36 | protected boolean staticSound; 37 | 38 | public CustomSound(UUID soundId, @Nullable Float range, @Nullable ArrayList randomSounds, boolean staticSound) { 39 | this.soundId = soundId; 40 | this.range = range; 41 | this.staticSound = staticSound; 42 | this.randomSounds = randomSounds; 43 | } 44 | 45 | @Nullable 46 | public static CustomSound of(ItemStack item) { 47 | CustomData customData = item.get(DataComponents.CUSTOM_DATA); 48 | if (customData == null) { 49 | return null; 50 | } 51 | return of(customData.copyTag()); 52 | } 53 | 54 | @Nullable 55 | public static CustomSound of(CompoundTag tag) { 56 | UUID soundId; 57 | if (tag.contains(CUSTOM_SOUND)) { 58 | soundId = tag.read(CUSTOM_SOUND, UUIDUtil.CODEC).orElse(null); 59 | } else { 60 | return null; 61 | } 62 | ArrayList randomSounds = null; 63 | if (tag.contains(CUSTOM_SOUND_RANDOM)) { 64 | randomSounds = readUUIDArrayFromNbt(tag, CUSTOM_SOUND_RANDOM); 65 | } 66 | Float range = tag.getFloat(CUSTOM_SOUND_RANGE).orElse(null); 67 | boolean staticSound = tag.getBoolean(CUSTOM_SOUND_STATIC).orElse(false); 68 | return new CustomSound(soundId, range, randomSounds, staticSound); 69 | } 70 | 71 | public UUID getSoundId() { 72 | if (isRandomized()) { 73 | return randomSounds.get(ThreadLocalRandom.current().nextInt(randomSounds.size())); 74 | } 75 | return soundId; 76 | } 77 | 78 | public boolean isRandomized() { 79 | return randomSounds != null && !randomSounds.isEmpty(); 80 | } 81 | 82 | public ArrayList getRandomSounds() { 83 | return randomSounds; 84 | } 85 | 86 | public void addRandomSound(UUID id) { 87 | setRandomization(true); 88 | randomSounds.add(id); 89 | } 90 | 91 | public void setRandomization(boolean enabled) { 92 | if (enabled) { 93 | if (randomSounds == null) { 94 | randomSounds = new ArrayList<>(); 95 | randomSounds.add(soundId); 96 | } 97 | } else { 98 | randomSounds = null; 99 | } 100 | } 101 | 102 | public Optional getRange() { 103 | return Optional.ofNullable(range); 104 | } 105 | 106 | public float getRange(PlayerType playerType) { 107 | return getRangeOrDefault(playerType.getDefaultRange(), playerType.getMaxRange()); 108 | } 109 | 110 | public float getRangeOrDefault(ConfigEntry defaultRange, ConfigEntry maxRange) { 111 | if (range == null) { 112 | return defaultRange.get(); 113 | } else if (range > maxRange.get()) { 114 | return maxRange.get(); 115 | } else { 116 | return range; 117 | } 118 | } 119 | 120 | public boolean isStaticSound() { 121 | return staticSound; 122 | } 123 | 124 | public void saveToNbt(CompoundTag tag) { 125 | if (soundId != null) { 126 | tag.store(CUSTOM_SOUND, UUIDUtil.CODEC, soundId); 127 | } else { 128 | tag.remove(CUSTOM_SOUND); 129 | } 130 | if (randomSounds != null) { 131 | saveUUIDArrayToNbt(tag, CUSTOM_SOUND_RANDOM, randomSounds); 132 | } else { 133 | tag.remove(CUSTOM_SOUND_RANDOM); 134 | } 135 | if (range != null) { 136 | tag.putFloat(CUSTOM_SOUND_RANGE, range); 137 | } else { 138 | tag.remove(CUSTOM_SOUND_RANGE); 139 | } 140 | if (staticSound) { 141 | tag.putBoolean(CUSTOM_SOUND_STATIC, true); 142 | } else { 143 | tag.remove(CUSTOM_SOUND_STATIC); 144 | } 145 | } 146 | 147 | public static void saveUUIDArrayToNbt(CompoundTag tag, String id, List uuids) { 148 | ListTag uuidList = new ListTag(); 149 | for (UUID uuid : uuids) { 150 | uuidList.add(UUIDUtil.CODEC.encodeStart(NbtOps.INSTANCE, uuid).getOrThrow()); 151 | } 152 | tag.put(id, uuidList); 153 | } 154 | 155 | public static ArrayList readUUIDArrayFromNbt(CompoundTag tag, String id) { 156 | ListTag list = tag.getList(id).orElse(new ListTag()); 157 | ArrayList uuidList = new ArrayList<>(list.size()); 158 | for (Tag value : list) { 159 | uuidList.add(UUIDUtil.CODEC.decode(NbtOps.INSTANCE, value).getOrThrow().getFirst()); 160 | } 161 | return uuidList; 162 | } 163 | 164 | public void saveToItemIgnoreLore(ItemStack stack) { 165 | saveToItem(stack, null, false); 166 | } 167 | 168 | public void saveToItem(ItemStack stack) { 169 | saveToItem(stack, null); 170 | } 171 | 172 | public void saveToItem(ItemStack stack, @Nullable String loreString) { 173 | saveToItem(stack, loreString, true); 174 | } 175 | 176 | public void saveToItem(ItemStack stack, @Nullable String loreString, boolean applyLore) { 177 | CustomData customData = stack.getOrDefault(DataComponents.CUSTOM_DATA, CustomData.EMPTY); 178 | CompoundTag tag = customData.copyTag(); 179 | saveToNbt(tag); 180 | stack.set(DataComponents.CUSTOM_DATA, CustomData.of(tag)); 181 | 182 | ItemLore l = null; 183 | 184 | if (stack.getItem() instanceof BlockItem blockItem && blockItem.getBlock() instanceof SkullBlock) { 185 | CustomData blockEntityData = stack.getOrDefault(DataComponents.BLOCK_ENTITY_DATA, CustomData.EMPTY); 186 | CompoundTag blockEntityTag = blockEntityData.copyTag(); 187 | saveToNbt(blockEntityTag); 188 | blockEntityTag.putString(ID, BlockEntityType.SKULL.builtInRegistryHolder().key().location().toString()); 189 | stack.set(DataComponents.BLOCK_ENTITY_DATA, CustomData.of(blockEntityTag)); 190 | if (loreString == null) { 191 | l = new ItemLore(Collections.singletonList(Component.literal(DEFAULT_HEAD_LORE).withStyle(style -> style.withItalic(false)).withStyle(ChatFormatting.GRAY))); 192 | } 193 | } 194 | if (loreString != null) { 195 | l = new ItemLore(Collections.singletonList(Component.literal(loreString).withStyle(style -> style.withItalic(false)).withStyle(ChatFormatting.GRAY))); 196 | } 197 | 198 | if (applyLore) { 199 | if (l != null) { 200 | stack.set(DataComponents.LORE, l); 201 | } else { 202 | stack.remove(DataComponents.LORE); 203 | } 204 | } 205 | 206 | TooltipDisplay tooltipDisplay = stack.getOrDefault(DataComponents.TOOLTIP_DISPLAY, TooltipDisplay.DEFAULT); 207 | LinkedHashSet> hiddenComponents = new LinkedHashSet<>(tooltipDisplay.hiddenComponents()); 208 | hiddenComponents.add(DataComponents.JUKEBOX_PLAYABLE); 209 | hiddenComponents.add(DataComponents.INSTRUMENT); 210 | stack.set(DataComponents.TOOLTIP_DISPLAY, new TooltipDisplay(tooltipDisplay.hideTooltip(), hiddenComponents)); 211 | } 212 | 213 | public CustomSound asStatic(boolean staticSound) { 214 | return new CustomSound(soundId, range, randomSounds, staticSound); 215 | } 216 | 217 | public static boolean clearItem(ItemStack stack) { 218 | CustomData customData = stack.get(DataComponents.CUSTOM_DATA); 219 | if (customData == null) { 220 | return false; 221 | } 222 | CompoundTag tag = customData.copyTag(); 223 | if (!tag.contains(CUSTOM_SOUND)) { 224 | return false; 225 | } 226 | tag.remove(CUSTOM_SOUND); 227 | tag.remove(CUSTOM_SOUND_RANDOM); 228 | tag.remove(CUSTOM_SOUND_RANGE); 229 | tag.remove(CUSTOM_SOUND_STATIC); 230 | stack.set(DataComponents.CUSTOM_DATA, CustomData.of(tag)); 231 | if (stack.getItem() instanceof BlockItem) { 232 | CustomData blockEntityData = stack.get(DataComponents.BLOCK_ENTITY_DATA); 233 | if (blockEntityData == null) { 234 | return true; 235 | } 236 | CompoundTag blockEntityTag = blockEntityData.copyTag(); 237 | blockEntityTag.remove(CUSTOM_SOUND); 238 | blockEntityTag.remove(CUSTOM_SOUND_RANDOM); 239 | blockEntityTag.remove(CUSTOM_SOUND_RANGE); 240 | blockEntityTag.remove(CUSTOM_SOUND_STATIC); 241 | stack.set(DataComponents.BLOCK_ENTITY_DATA, CustomData.of(blockEntityTag)); 242 | } 243 | return true; 244 | } 245 | 246 | } 247 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/FileNameManager.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import com.google.gson.reflect.TypeToken; 6 | import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; 7 | 8 | import javax.annotation.Nullable; 9 | import java.io.*; 10 | import java.lang.reflect.Type; 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | import java.util.*; 14 | 15 | public class FileNameManager { 16 | 17 | private final File file; 18 | private final Gson gson; 19 | private Map fileNames; 20 | 21 | public FileNameManager(File file) { 22 | this.file = file; 23 | this.gson = new GsonBuilder().create(); 24 | this.fileNames = new HashMap<>(); 25 | load(); 26 | } 27 | 28 | public void load() { 29 | if (!file.exists()) { 30 | return; 31 | } 32 | try (Reader reader = new FileReader(file)) { 33 | Type fileNameMapType = new TypeToken>() { 34 | }.getType(); 35 | fileNames = gson.fromJson(reader, fileNameMapType); 36 | } catch (Exception e) { 37 | AudioPlayer.LOGGER.error("Failed to load file name mappings", e); 38 | } 39 | if (fileNames == null) { 40 | fileNames = new HashMap<>(); 41 | } 42 | save(); 43 | } 44 | 45 | public void save() { 46 | file.getParentFile().mkdirs(); 47 | try (Writer writer = new FileWriter(file)) { 48 | gson.toJson(fileNames, writer); 49 | } catch (Exception e) { 50 | AudioPlayer.LOGGER.error("Failed to save file name mappings", e); 51 | } 52 | } 53 | 54 | @Nullable 55 | public String getFileName(UUID audioId) { 56 | return fileNames.get(audioId); 57 | } 58 | 59 | /** 60 | * @param fileName the file name with or without extension 61 | * @return the audio ID or null if there is no ID associated to the file name or there are multiple IDs associated to the file name 62 | */ 63 | @Nullable 64 | public UUID getAudioId(String fileName) { 65 | UUID id = null; 66 | for (Map.Entry entry : fileNames.entrySet()) { 67 | if (isNameEqualsWithoutExtension(entry.getValue(), fileName)) { 68 | if (id == null) { 69 | id = entry.getKey(); 70 | } else { 71 | return null; 72 | } 73 | } 74 | } 75 | return id; 76 | } 77 | 78 | private static boolean isNameEqualsWithoutExtension(String name1, String name2) { 79 | if (name1 == null || name2 == null) { 80 | return false; 81 | } 82 | return fileNameWithoutExtension(name1).equals(fileNameWithoutExtension(name2)); 83 | } 84 | 85 | private static String fileNameWithoutExtension(String name) { 86 | int dotIndex = name.lastIndexOf('.'); 87 | if (dotIndex < 0) { 88 | return name; 89 | } 90 | return name.substring(0, dotIndex); 91 | } 92 | 93 | /** 94 | * Saves the file name associated with the provided ID. Does nothing if the file name is null. 95 | * 96 | * @param audioId the audio ID 97 | * @param fileName the file name or null 98 | */ 99 | public void addFileName(UUID audioId, @Nullable String fileName) { 100 | if (fileName == null) { 101 | return; 102 | } 103 | fileNames.put(audioId, fileName); 104 | //TODO Save off-thread 105 | save(); 106 | } 107 | 108 | public void remove(UUID audioId) { 109 | fileNames.remove(audioId); 110 | save(); 111 | } 112 | 113 | @Nullable 114 | public static String getFileNameFromUrl(String url) { 115 | String name = url.substring(url.lastIndexOf('/') + 1).trim(); 116 | if (name.isEmpty()) { 117 | return null; 118 | } 119 | return name; 120 | } 121 | 122 | @Nullable 123 | public static String getFileNameFromPath(Path path) { 124 | if (Files.isDirectory(path)) { 125 | return null; 126 | } 127 | String name = path.getFileName().toString(); 128 | if (name.isEmpty()) { 129 | return null; 130 | } 131 | return name; 132 | } 133 | 134 | @Nullable 135 | private static FileNameManager INSTANCE; 136 | 137 | public static void init() { 138 | ServerLifecycleEvents.SERVER_STARTED.register(server -> { 139 | AudioPlayer.LOGGER.info("Loading audio file name mappings..."); 140 | Path audioDataFolder = AudioManager.getAudioDataFolder(server); 141 | if (Files.exists(audioDataFolder)) { 142 | try { 143 | Files.createDirectories(audioDataFolder); 144 | } catch (IOException e) { 145 | AudioPlayer.LOGGER.error("Failed to create audio data folder", e); 146 | return; 147 | } 148 | } 149 | INSTANCE = new FileNameManager(audioDataFolder.resolve("file-name-mappings.json").toFile()); 150 | }); 151 | ServerLifecycleEvents.SERVER_STOPPED.register(server -> { 152 | INSTANCE = null; 153 | }); 154 | } 155 | 156 | public static Optional instance() { 157 | return Optional.ofNullable(INSTANCE); 158 | } 159 | 160 | } 161 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/Filebin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import com.google.gson.JsonArray; 4 | import com.google.gson.JsonElement; 5 | import com.google.gson.JsonObject; 6 | import com.google.gson.JsonParser; 7 | import net.minecraft.server.MinecraftServer; 8 | 9 | import javax.sound.sampled.UnsupportedAudioFileException; 10 | import java.io.IOException; 11 | import java.net.URI; 12 | import java.net.URISyntaxException; 13 | import java.net.http.HttpClient; 14 | import java.net.http.HttpRequest; 15 | import java.net.http.HttpResponse; 16 | import java.util.UUID; 17 | 18 | public class Filebin { 19 | 20 | public static final String USER_AGENT = "AudioPlayer/curl"; 21 | 22 | public static void downloadSound(MinecraftServer server, UUID sound) throws IOException, InterruptedException, UnsupportedAudioFileException, URISyntaxException { 23 | URI url = getBin(sound); 24 | 25 | try (HttpClient client = HttpClient.newHttpClient()) { 26 | HttpRequest request = HttpRequest.newBuilder() 27 | .uri(url) 28 | .header("Accept", "application/json") 29 | .header("User-Agent", USER_AGENT) 30 | .build(); 31 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 32 | 33 | if (response.statusCode() != 200) { 34 | throw new IOException("%s responded with status %s".formatted(url, response.statusCode())); 35 | } 36 | 37 | JsonElement json = JsonParser.parseString(response.body()); 38 | 39 | if (!(json instanceof JsonObject object)) { 40 | throw new IOException("Invalid response"); 41 | } 42 | 43 | JsonElement filesElement = object.get("files"); 44 | 45 | if (filesElement == null) { 46 | throw new IOException("No files uploaded"); 47 | } 48 | 49 | if (!(filesElement instanceof JsonArray files)) { 50 | throw new IOException("No files uploaded"); 51 | } 52 | 53 | for (JsonElement element : files) { 54 | if (!(element instanceof JsonObject file)) { 55 | continue; 56 | } 57 | 58 | String contentType = file.get("content-type").getAsString(); 59 | 60 | if (contentType.equals("audio/wav") || contentType.equals("audio/mpeg")) { 61 | long size = file.get("bytes").getAsLong(); 62 | 63 | if (size > AudioPlayer.SERVER_CONFIG.maxUploadSize.get()) { 64 | throw new IOException("Maximum file size exceeded (%sMB>%sMB)".formatted(Math.round((float) size / 1_000_000F), Math.round(AudioPlayer.SERVER_CONFIG.maxUploadSize.get().floatValue() / 1_000_000F))); 65 | } 66 | 67 | String filename = file.get("filename").getAsString(); 68 | AudioManager.saveSound(server, sound, url + "/" + new URI(null, null, filename, null).toASCIIString()); 69 | deleteBin(url); 70 | return; 71 | } 72 | } 73 | throw new IOException("No mp3 or wav files uploaded"); 74 | } 75 | } 76 | 77 | public static void deleteBin(URI url) { 78 | try (HttpClient client = HttpClient.newHttpClient()) { 79 | HttpRequest request = HttpRequest.newBuilder() 80 | .uri(url) 81 | .header("Accept", "application/json") 82 | .header("User-Agent", USER_AGENT) 83 | .DELETE() 84 | .build(); 85 | HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); 86 | 87 | if (response.statusCode() != 200) { 88 | throw new IOException("%s responded with status %s".formatted(url, response.statusCode())); 89 | } 90 | } catch (Exception e) { 91 | AudioPlayer.LOGGER.warn("Failed to delete bin '{}'", url, e); 92 | } 93 | } 94 | 95 | public static URI getBin(UUID sound) { 96 | String filebinUrl = AudioPlayer.SERVER_CONFIG.filebinUrl.get(); 97 | 98 | if (!filebinUrl.endsWith("/")) { 99 | filebinUrl += "/"; 100 | } 101 | 102 | return URI.create(filebinUrl + sound); 103 | } 104 | 105 | } 106 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/PlayerManager.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.voicechat.api.Player; 4 | import de.maxhenkel.voicechat.api.VoicechatConnection; 5 | import de.maxhenkel.voicechat.api.VoicechatServerApi; 6 | import de.maxhenkel.voicechat.api.audiochannel.AudioChannel; 7 | import de.maxhenkel.voicechat.api.audiochannel.LocationalAudioChannel; 8 | import net.minecraft.ChatFormatting; 9 | import net.minecraft.network.chat.Component; 10 | import net.minecraft.server.level.ServerLevel; 11 | import net.minecraft.server.level.ServerPlayer; 12 | import net.minecraft.world.phys.Vec3; 13 | import org.jetbrains.annotations.Nullable; 14 | 15 | import java.util.Map; 16 | import java.util.UUID; 17 | import java.util.concurrent.ConcurrentHashMap; 18 | import java.util.concurrent.ExecutorService; 19 | import java.util.concurrent.Executors; 20 | import java.util.concurrent.atomic.AtomicBoolean; 21 | import java.util.concurrent.atomic.AtomicReference; 22 | 23 | public class PlayerManager { 24 | 25 | private final Map players; 26 | private final ExecutorService executor; 27 | 28 | public PlayerManager() { 29 | this.players = new ConcurrentHashMap<>(); 30 | this.executor = Executors.newSingleThreadExecutor(r -> { 31 | Thread thread = new Thread(r, "AudioPlayerThread"); 32 | thread.setDaemon(true); 33 | return thread; 34 | }); 35 | } 36 | 37 | @Nullable 38 | public UUID playLocational(VoicechatServerApi api, ServerLevel level, Vec3 pos, UUID sound, @Nullable ServerPlayer p, float distance, @Nullable String category, int maxLengthSeconds) { 39 | return playLocational(api, level, pos, sound, p, distance, category, maxLengthSeconds, false); 40 | } 41 | 42 | @Nullable 43 | public UUID playLocational(VoicechatServerApi api, ServerLevel level, Vec3 pos, UUID sound, @Nullable ServerPlayer p, float distance, @Nullable String category, int maxLengthSeconds, boolean byCommand) { 44 | UUID channelID = UUID.randomUUID(); 45 | LocationalAudioChannel channel = api.createLocationalAudioChannel(channelID, api.fromServerLevel(level), api.createPosition(pos.x, pos.y, pos.z)); 46 | if (channel == null) { 47 | return null; 48 | } 49 | if (category != null) { 50 | channel.setCategory(category); 51 | } 52 | channel.setDistance(distance); 53 | api.getPlayersInRange(api.fromServerLevel(level), channel.getLocation(), distance + 1F, serverPlayer -> { 54 | VoicechatConnection connection = api.getConnectionOf(serverPlayer); 55 | if (connection != null) { 56 | return connection.isDisabled(); 57 | } 58 | return true; 59 | }).stream().map(Player::getPlayer).map(ServerPlayer.class::cast).forEach(player -> { 60 | player.displayClientMessage(Component.literal("You need to enable voice chat to hear custom audio"), true); 61 | }); 62 | 63 | AtomicBoolean stopped = new AtomicBoolean(); 64 | AtomicReference player = new AtomicReference<>(); 65 | 66 | players.put(channelID, new PlayerReference(() -> { 67 | synchronized (stopped) { 68 | stopped.set(true); 69 | de.maxhenkel.voicechat.api.audiochannel.AudioPlayer audioPlayer = player.get(); 70 | if (audioPlayer != null) { 71 | audioPlayer.stopPlaying(); 72 | } 73 | } 74 | }, player, sound, byCommand)); 75 | 76 | executor.execute(() -> { 77 | de.maxhenkel.voicechat.api.audiochannel.AudioPlayer audioPlayer = playChannel(api, channel, level, sound, p, maxLengthSeconds); 78 | if (audioPlayer == null) { 79 | players.remove(channelID); 80 | return; 81 | } 82 | audioPlayer.setOnStopped(() -> { 83 | players.remove(channelID); 84 | }); 85 | synchronized (stopped) { 86 | if (!stopped.get()) { 87 | player.set(audioPlayer); 88 | } else { 89 | audioPlayer.stopPlaying(); 90 | } 91 | } 92 | }); 93 | return channelID; 94 | } 95 | 96 | @Nullable 97 | public UUID playStatic(VoicechatServerApi api, ServerLevel level, Vec3 pos, UUID sound, @Nullable ServerPlayer p, float distance, @Nullable String category, int maxLengthSeconds) { 98 | return playStatic(api, level, pos, sound, p, distance, category, maxLengthSeconds, false); 99 | } 100 | 101 | @Nullable 102 | public UUID playStatic(VoicechatServerApi api, ServerLevel level, Vec3 pos, UUID sound, @Nullable ServerPlayer p, float distance, @Nullable String category, int maxLengthSeconds, boolean byCommand) { 103 | UUID channelID = UUID.randomUUID(); 104 | 105 | api.getPlayersInRange(api.fromServerLevel(level), api.createPosition(pos.x, pos.y, pos.z), distance + 1F, serverPlayer -> { 106 | VoicechatConnection connection = api.getConnectionOf(serverPlayer); 107 | if (connection != null) { 108 | return connection.isDisabled(); 109 | } 110 | return true; 111 | }).stream().map(Player::getPlayer).map(ServerPlayer.class::cast).forEach(player -> { 112 | player.displayClientMessage(Component.literal("You need to enable voice chat to hear custom audio"), true); 113 | }); 114 | 115 | StaticAudioPlayer staticAudioPlayer = StaticAudioPlayer.create(api, level, sound, p, maxLengthSeconds, category, pos, channelID, distance); 116 | 117 | AtomicBoolean stopped = new AtomicBoolean(); 118 | AtomicReference player = new AtomicReference<>(); 119 | 120 | players.put(channelID, new PlayerReference(() -> { 121 | synchronized (stopped) { 122 | stopped.set(true); 123 | de.maxhenkel.voicechat.api.audiochannel.AudioPlayer audioPlayer = player.get(); 124 | if (audioPlayer != null) { 125 | audioPlayer.stopPlaying(); 126 | } 127 | } 128 | }, player, sound, byCommand)); 129 | 130 | executor.execute(() -> { 131 | if (staticAudioPlayer == null) { 132 | players.remove(channelID); 133 | return; 134 | } 135 | staticAudioPlayer.setOnStopped(() -> { 136 | players.remove(channelID); 137 | }); 138 | synchronized (stopped) { 139 | if (!stopped.get()) { 140 | player.set(staticAudioPlayer); 141 | } else { 142 | staticAudioPlayer.stopPlaying(); 143 | } 144 | } 145 | }); 146 | return channelID; 147 | } 148 | 149 | 150 | @Nullable 151 | private de.maxhenkel.voicechat.api.audiochannel.AudioPlayer playChannel(VoicechatServerApi api, AudioChannel channel, ServerLevel level, UUID sound, ServerPlayer p, int maxLengthSeconds) { 152 | try { 153 | short[] audio = AudioManager.getSound(level.getServer(), sound); 154 | 155 | if (AudioManager.getLengthSeconds(audio) > maxLengthSeconds) { 156 | if (p != null) { 157 | p.displayClientMessage(Component.literal("Audio is too long to play").withStyle(ChatFormatting.DARK_RED), true); 158 | } else { 159 | AudioPlayer.LOGGER.error("Audio {} was too long to play", sound); 160 | } 161 | return null; 162 | } 163 | 164 | de.maxhenkel.voicechat.api.audiochannel.AudioPlayer player = api.createAudioPlayer(channel, api.createEncoder(), audio); 165 | player.startPlaying(); 166 | return player; 167 | } catch (Exception e) { 168 | AudioPlayer.LOGGER.error("Failed to play audio", e); 169 | if (p != null) { 170 | p.displayClientMessage(Component.literal("Failed to play audio: %s".formatted(e.getMessage())).withStyle(ChatFormatting.DARK_RED), true); 171 | } 172 | return null; 173 | } 174 | } 175 | 176 | public void stop(UUID channelID) { 177 | PlayerReference player = players.get(channelID); 178 | if (player != null) { 179 | player.onStop.stop(); 180 | } 181 | players.remove(channelID); 182 | } 183 | 184 | public boolean isPlaying(UUID channelID) { 185 | PlayerReference player = players.get(channelID); 186 | if (player == null) { 187 | return false; 188 | } 189 | de.maxhenkel.voicechat.api.audiochannel.AudioPlayer p = player.player.get(); 190 | if (p == null) { 191 | return true; 192 | } 193 | return p.isPlaying(); 194 | } 195 | 196 | private static PlayerManager instance; 197 | 198 | public static PlayerManager instance() { 199 | if (instance == null) { 200 | instance = new PlayerManager(); 201 | } 202 | return instance; 203 | } 204 | 205 | private interface Stoppable { 206 | void stop(); 207 | } 208 | 209 | private record PlayerReference(Stoppable onStop, 210 | AtomicReference player, 211 | UUID sound, boolean byCommand) { 212 | } 213 | 214 | @Nullable 215 | public UUID findChannelID(UUID sound, boolean onlyByCommand) { 216 | for (Map.Entry entry : players.entrySet()) { 217 | if (entry.getValue().sound.equals(sound) && (entry.getValue().byCommand || !onlyByCommand)) { 218 | return entry.getKey(); 219 | } 220 | } 221 | return null; 222 | } 223 | 224 | } 225 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/PlayerType.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.configbuilder.entry.ConfigEntry; 4 | import net.minecraft.core.component.DataComponents; 5 | import net.minecraft.world.item.BlockItem; 6 | import net.minecraft.world.item.InstrumentItem; 7 | import net.minecraft.world.item.ItemStack; 8 | import net.minecraft.world.level.block.SkullBlock; 9 | 10 | import javax.annotation.Nullable; 11 | import java.util.function.Predicate; 12 | 13 | public enum PlayerType { 14 | 15 | MUSIC_DISC( 16 | AudioPlayer.SERVER_CONFIG.musicDiscRange, 17 | AudioPlayer.SERVER_CONFIG.maxMusicDiscRange, 18 | AudioPlayer.SERVER_CONFIG.maxMusicDiscDuration, 19 | Plugin.MUSIC_DISC_CATEGORY, 20 | itemStack -> itemStack.has(DataComponents.JUKEBOX_PLAYABLE) 21 | ), 22 | NOTE_BLOCK( 23 | AudioPlayer.SERVER_CONFIG.noteBlockRange, 24 | AudioPlayer.SERVER_CONFIG.maxNoteBlockRange, 25 | AudioPlayer.SERVER_CONFIG.maxNoteBlockDuration, 26 | Plugin.NOTE_BLOCK_CATEGORY, 27 | itemStack -> itemStack.getItem() instanceof BlockItem blockItem && blockItem.getBlock() instanceof SkullBlock 28 | ), 29 | GOAT_HORN( 30 | AudioPlayer.SERVER_CONFIG.goatHornRange, 31 | AudioPlayer.SERVER_CONFIG.maxGoatHornRange, 32 | AudioPlayer.SERVER_CONFIG.maxGoatHornDuration, 33 | Plugin.GOAT_HORN_CATEGORY, 34 | itemStack -> itemStack.getItem() instanceof InstrumentItem 35 | ); 36 | 37 | private final ConfigEntry defaultRange; 38 | private final ConfigEntry maxRange; 39 | private final ConfigEntry maxDuration; 40 | private final String category; 41 | private final Predicate validator; 42 | 43 | PlayerType(ConfigEntry defaultRange, ConfigEntry maxRange, ConfigEntry maxDuration, String category, Predicate validator) { 44 | this.defaultRange = defaultRange; 45 | this.maxRange = maxRange; 46 | this.maxDuration = maxDuration; 47 | this.category = category; 48 | this.validator = validator; 49 | } 50 | 51 | public ConfigEntry getDefaultRange() { 52 | return defaultRange; 53 | } 54 | 55 | public ConfigEntry getMaxRange() { 56 | return maxRange; 57 | } 58 | 59 | public ConfigEntry getMaxDuration() { 60 | return maxDuration; 61 | } 62 | 63 | public String getCategory() { 64 | return category; 65 | } 66 | 67 | public Predicate getValidator() { 68 | return validator; 69 | } 70 | 71 | public boolean isValid(ItemStack itemStack) { 72 | return validator.test(itemStack); 73 | } 74 | 75 | @Nullable 76 | public static PlayerType fromItemStack(ItemStack itemStack) { 77 | for (PlayerType type : values()) { 78 | if (type.getValidator().test(itemStack)) { 79 | return type; 80 | } 81 | } 82 | return null; 83 | } 84 | 85 | } 86 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/Plugin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.voicechat.api.VoicechatApi; 4 | import de.maxhenkel.voicechat.api.VoicechatPlugin; 5 | import de.maxhenkel.voicechat.api.VoicechatServerApi; 6 | import de.maxhenkel.voicechat.api.VolumeCategory; 7 | import de.maxhenkel.voicechat.api.events.EventRegistration; 8 | import de.maxhenkel.voicechat.api.events.VoicechatServerStartedEvent; 9 | 10 | import javax.annotation.Nullable; 11 | import javax.imageio.ImageIO; 12 | import java.awt.image.BufferedImage; 13 | import java.net.URL; 14 | import java.util.Enumeration; 15 | 16 | public class Plugin implements VoicechatPlugin { 17 | 18 | public static String MUSIC_DISC_CATEGORY = "music_discs"; 19 | public static String NOTE_BLOCK_CATEGORY = "note_blocks"; 20 | public static String GOAT_HORN_CATEGORY = "goat_horns"; 21 | 22 | public static VoicechatApi voicechatApi; 23 | @Nullable 24 | public static VoicechatServerApi voicechatServerApi; 25 | @Nullable 26 | public static VolumeCategory musicDiscs; 27 | @Nullable 28 | public static VolumeCategory noteBlocks; 29 | @Nullable 30 | public static VolumeCategory goatHorns; 31 | 32 | @Override 33 | public String getPluginId() { 34 | return "audioplayer"; 35 | } 36 | 37 | @Override 38 | public void initialize(VoicechatApi api) { 39 | voicechatApi = api; 40 | } 41 | 42 | @Override 43 | public void registerEvents(EventRegistration registration) { 44 | registration.registerEvent(VoicechatServerStartedEvent.class, this::onServerStarted); 45 | } 46 | 47 | private void onServerStarted(VoicechatServerStartedEvent event) { 48 | voicechatServerApi = event.getVoicechat(); 49 | musicDiscs = voicechatServerApi.volumeCategoryBuilder() 50 | .setId(MUSIC_DISC_CATEGORY) 51 | .setName("Music discs") 52 | .setDescription("The volume of all custom music discs") 53 | .setIcon(getIcon("category_music_discs.png")) 54 | .build(); 55 | noteBlocks = voicechatServerApi.volumeCategoryBuilder() 56 | .setId(NOTE_BLOCK_CATEGORY) 57 | .setName("Note blocks") 58 | .setDescription("The volume of all note blocks with custom heads") 59 | .setIcon(getIcon("category_note_blocks.png")) 60 | .build(); 61 | goatHorns = voicechatServerApi.volumeCategoryBuilder() 62 | .setId(GOAT_HORN_CATEGORY) 63 | .setName("Goat horns") 64 | .setDescription("The volume of all custom goat horns") 65 | .setIcon(getIcon("category_goat_horns.png")) 66 | .build(); 67 | 68 | voicechatServerApi.registerVolumeCategory(musicDiscs); 69 | voicechatServerApi.registerVolumeCategory(noteBlocks); 70 | voicechatServerApi.registerVolumeCategory(goatHorns); 71 | } 72 | 73 | @Nullable 74 | private int[][] getIcon(String path) { 75 | try { 76 | Enumeration resources = Plugin.class.getClassLoader().getResources(path); 77 | while (resources.hasMoreElements()) { 78 | BufferedImage bufferedImage = ImageIO.read(resources.nextElement().openStream()); 79 | if (bufferedImage.getWidth() != 16) { 80 | continue; 81 | } 82 | if (bufferedImage.getHeight() != 16) { 83 | continue; 84 | } 85 | int[][] image = new int[16][16]; 86 | for (int x = 0; x < bufferedImage.getWidth(); x++) { 87 | for (int y = 0; y < bufferedImage.getHeight(); y++) { 88 | image[x][y] = bufferedImage.getRGB(x, y); 89 | } 90 | } 91 | return image; 92 | } 93 | 94 | } catch (Exception e) { 95 | e.printStackTrace(); 96 | } 97 | return null; 98 | } 99 | 100 | } 101 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/StaticAudioPlayer.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import de.maxhenkel.voicechat.api.Player; 4 | import de.maxhenkel.voicechat.api.VoicechatConnection; 5 | import de.maxhenkel.voicechat.api.VoicechatServerApi; 6 | import de.maxhenkel.voicechat.api.audiochannel.StaticAudioChannel; 7 | import de.maxhenkel.voicechat.api.opus.OpusEncoder; 8 | import net.minecraft.ChatFormatting; 9 | import net.minecraft.network.chat.Component; 10 | import net.minecraft.server.level.ServerLevel; 11 | import net.minecraft.server.level.ServerPlayer; 12 | import net.minecraft.world.phys.Vec3; 13 | 14 | import javax.annotation.Nullable; 15 | import java.util.Arrays; 16 | import java.util.List; 17 | import java.util.UUID; 18 | import java.util.concurrent.ConcurrentHashMap; 19 | import java.util.concurrent.ScheduledFuture; 20 | import java.util.concurrent.TimeUnit; 21 | import java.util.function.Supplier; 22 | 23 | // TODO Move this to the voice chat API 24 | public class StaticAudioPlayer implements de.maxhenkel.voicechat.api.audiochannel.AudioPlayer, Runnable { 25 | 26 | private final Thread playbackThread; 27 | private final AudioSupplier audio; 28 | private final VoicechatServerApi api; 29 | private final String category; 30 | private final Vec3 pos; 31 | private final ServerLevel level; 32 | private final float distance; 33 | private final OpusEncoder encoder; 34 | 35 | private static final long FRAME_SIZE_NS = 20_000_000; 36 | public static final int SAMPLE_RATE = 48000; 37 | public static final int FRAME_SIZE = (SAMPLE_RATE / 1000) * 20; 38 | 39 | private final ConcurrentHashMap audioChannels; 40 | private boolean started; 41 | @Nullable 42 | private Runnable onStopped; 43 | 44 | public StaticAudioPlayer(short[] audio, VoicechatServerApi api, String category, Vec3 pos, UUID playerID, ServerLevel level, float distance) { 45 | this.playbackThread = new Thread(this); 46 | this.audio = new AudioSupplier(audio); 47 | this.api = api; 48 | this.category = category; 49 | this.pos = pos; 50 | this.audioChannels = new ConcurrentHashMap<>(); 51 | this.encoder = api.createEncoder(); 52 | this.playbackThread.setDaemon(true); 53 | this.playbackThread.setName("StaticAudioPlayer-%s".formatted(playerID)); 54 | this.level = level; 55 | this.distance = distance; 56 | } 57 | 58 | public static StaticAudioPlayer create(VoicechatServerApi api, ServerLevel level, UUID sound, ServerPlayer p, int maxLengthSeconds, String category, Vec3 pos, UUID playerID, float distance) { 59 | try { 60 | short[] audio = AudioManager.getSound(level.getServer(), sound); 61 | 62 | if (AudioManager.getLengthSeconds(audio) > maxLengthSeconds) { 63 | if (p != null) { 64 | p.displayClientMessage(Component.literal("Audio is too long to play").withStyle(ChatFormatting.DARK_RED), true); 65 | } else { 66 | AudioPlayer.LOGGER.error("Audio {} was too long to play", sound); 67 | } 68 | return null; 69 | } 70 | 71 | StaticAudioPlayer instance = new StaticAudioPlayer(audio, api, category, pos, playerID, level, distance); 72 | instance.startPlaying(); 73 | return instance; 74 | } catch (Exception e) { 75 | AudioPlayer.LOGGER.error("Failed to play audio", e); 76 | if (p != null) { 77 | p.displayClientMessage(Component.literal("Failed to play audio: %s".formatted(e.getMessage())).withStyle(ChatFormatting.DARK_RED), true); 78 | } 79 | return null; 80 | } 81 | } 82 | 83 | @Override 84 | public void startPlaying() { 85 | if (started) { 86 | return; 87 | } 88 | this.playbackThread.start(); 89 | started = true; 90 | } 91 | 92 | @Override 93 | public void stopPlaying() { 94 | this.playbackThread.interrupt(); 95 | } 96 | 97 | @Override 98 | public boolean isStarted() { 99 | return started; 100 | } 101 | 102 | @Override 103 | public boolean isPlaying() { 104 | return playbackThread.isAlive(); 105 | } 106 | 107 | @Override 108 | public boolean isStopped() { 109 | return started && !playbackThread.isAlive(); 110 | } 111 | 112 | @Override 113 | public void setOnStopped(Runnable onStopped) { 114 | this.onStopped = onStopped; 115 | } 116 | 117 | @Override 118 | public void run() { 119 | int framePosition = 0; 120 | 121 | ScheduledFuture nearbyPlayersTask = AudioPlayer.SCHEDULED_EXECUTOR.scheduleAtFixedRate(() -> { 122 | List players = api.getPlayersInRange(api.fromServerLevel(this.level), api.createPosition(pos.x, pos.y, pos.z), distance + 1F, serverPlayer -> { 123 | VoicechatConnection connection = api.getConnectionOf(serverPlayer); 124 | if (connection != null) { 125 | // TODO Either document in the api that this helper is square distance, or provide a spherical version (or both?) 126 | Vec3 playerPos = ((ServerPlayer) serverPlayer.getPlayer()).getPosition(0.0F); 127 | return !connection.isDisabled() && pos.distanceTo(playerPos) <= distance; 128 | } 129 | return false; 130 | }).stream().map(Player::getPlayer).map(ServerPlayer.class::cast).toList(); 131 | 132 | for (ServerPlayer player : players) { 133 | this.audioChannels.computeIfAbsent(player.getUUID(), uuid -> { 134 | StaticAudioChannel audioChannel = api.createStaticAudioChannel(UUID.randomUUID(), api.fromServerLevel(this.level), api.getConnectionOf(api.fromServerPlayer(player))); 135 | audioChannel.setCategory(this.category); 136 | return audioChannel; 137 | }); 138 | } 139 | 140 | List uuids = players.stream().map(ServerPlayer::getUUID).toList(); 141 | 142 | for (UUID uuid : this.audioChannels.keySet()) { 143 | if (!uuids.contains(uuid)) { 144 | StaticAudioChannel toRemove = this.audioChannels.remove(uuid); 145 | toRemove.flush(); 146 | } 147 | } 148 | }, 0L, 100L, TimeUnit.MILLISECONDS); 149 | 150 | long startTime = System.nanoTime(); 151 | 152 | short[] frame; 153 | 154 | while ((frame = this.audio.get()) != null) { 155 | if (frame.length != FRAME_SIZE) { 156 | AudioPlayer.LOGGER.error("Got invalid audio frame size {}!={}", frame.length, FRAME_SIZE); 157 | break; 158 | } 159 | byte[] encoded = encoder.encode(frame); 160 | for (StaticAudioChannel audioChannel : this.audioChannels.values()) { 161 | audioChannel.send(encoded); 162 | } 163 | framePosition++; 164 | long waitTimestamp = startTime + framePosition * FRAME_SIZE_NS; 165 | 166 | long waitNanos = waitTimestamp - System.nanoTime(); 167 | 168 | try { 169 | if (waitNanos > 0L) { 170 | Thread.sleep(waitNanos / 1_000_000L, (int) (waitNanos % 1_000_000)); 171 | } 172 | } catch (InterruptedException e) { 173 | break; 174 | } 175 | } 176 | 177 | encoder.close(); 178 | nearbyPlayersTask.cancel(true); 179 | 180 | for (StaticAudioChannel audioChannel : this.audioChannels.values()) { 181 | audioChannel.flush(); 182 | } 183 | 184 | if (onStopped != null) { 185 | onStopped.run(); 186 | } 187 | } 188 | 189 | public class AudioSupplier implements Supplier { 190 | 191 | private final short[] audioData; 192 | private final short[] frame; 193 | private int framePosition; 194 | 195 | public AudioSupplier(short[] audioData) { 196 | this.audioData = audioData; 197 | this.frame = new short[FRAME_SIZE]; 198 | } 199 | 200 | @Override 201 | public short[] get() { 202 | if (framePosition >= audioData.length) { 203 | return null; 204 | } 205 | 206 | Arrays.fill(frame, (short) 0); 207 | System.arraycopy(audioData, framePosition, frame, 0, Math.min(frame.length, audioData.length - framePosition)); 208 | framePosition += frame.length; 209 | return frame; 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/VolumeOverrideManager.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer; 2 | 3 | import com.google.gson.Gson; 4 | import com.google.gson.GsonBuilder; 5 | import com.google.gson.reflect.TypeToken; 6 | import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; 7 | 8 | import javax.annotation.Nullable; 9 | import java.io.*; 10 | import java.lang.reflect.Type; 11 | import java.nio.file.Files; 12 | import java.nio.file.Path; 13 | import java.util.HashMap; 14 | import java.util.Map; 15 | import java.util.Optional; 16 | import java.util.UUID; 17 | 18 | public class VolumeOverrideManager { 19 | 20 | private final File file; 21 | private final Gson gson; 22 | private Map volumes; 23 | 24 | public VolumeOverrideManager(File file) { 25 | this.file = file; 26 | this.gson = new GsonBuilder().create(); 27 | this.volumes = new HashMap<>(); 28 | load(); 29 | } 30 | 31 | public void load() { 32 | if (!file.exists()) { 33 | return; 34 | } 35 | try (Reader reader = new FileReader(file)) { 36 | Type fileNameMapType = new TypeToken>() { 37 | }.getType(); 38 | volumes = gson.fromJson(reader, fileNameMapType); 39 | } catch (Exception e) { 40 | AudioPlayer.LOGGER.error("Failed to load volume overrides", e); 41 | } 42 | if (volumes == null) { 43 | volumes = new HashMap<>(); 44 | } 45 | save(); 46 | } 47 | 48 | public void save() { 49 | file.getParentFile().mkdirs(); 50 | try (Writer writer = new FileWriter(file)) { 51 | gson.toJson(volumes, writer); 52 | } catch (Exception e) { 53 | AudioPlayer.LOGGER.error("Failed to save file name mappings", e); 54 | } 55 | } 56 | 57 | /** 58 | * Gets the volume override associated with the provided sound ID, or 1 if there is no override set 59 | * 60 | * @param audioId the audio ID 61 | */ 62 | public float getAudioVolume(UUID audioId) { 63 | return volumes.getOrDefault(audioId, 1F); 64 | } 65 | 66 | /** 67 | * Sets the volume override associated with the provided ID, removes override if the volume is null 68 | * 69 | * @param audioId the audio ID 70 | * @param volume the file name or null 71 | */ 72 | public void setAudioVolume(UUID audioId, @Nullable Float volume) { 73 | if (volume == null) { 74 | volumes.remove(audioId); 75 | //TODO Save off-thread 76 | save(); 77 | return; 78 | } 79 | volumes.put(audioId, volume); 80 | //TODO Save off-thread 81 | save(); 82 | } 83 | 84 | @Nullable 85 | private static VolumeOverrideManager INSTANCE; 86 | 87 | public static void init() { 88 | ServerLifecycleEvents.SERVER_STARTED.register(server -> { 89 | AudioPlayer.LOGGER.info("Loading audio file volume overrides..."); 90 | Path audioDataFolder = AudioManager.getAudioDataFolder(server); 91 | if (Files.exists(audioDataFolder)) { 92 | try { 93 | Files.createDirectories(audioDataFolder); 94 | } catch (IOException e) { 95 | AudioPlayer.LOGGER.error("Failed to create audio data folder", e); 96 | return; 97 | } 98 | } 99 | INSTANCE = new VolumeOverrideManager(audioDataFolder.resolve("volume-overrides.json").toFile()); 100 | }); 101 | ServerLifecycleEvents.SERVER_STOPPED.register(server -> { 102 | INSTANCE = null; 103 | }); 104 | } 105 | 106 | public static Optional instance() { 107 | return Optional.ofNullable(INSTANCE); 108 | } 109 | 110 | private static final float LOG_BASE = 2F; 111 | 112 | public static float convertToLinearScaleFactor(float logarithmicScaleFactor) { 113 | if (logarithmicScaleFactor <= 0F) { 114 | return 0F; 115 | } 116 | 117 | return 1F + (float) (Math.log10(logarithmicScaleFactor) / LOG_BASE); 118 | } 119 | 120 | public static float convertToLogarithmicScaleFactor(float linearScaleFactor) { 121 | linearScaleFactor = Math.max(0F, Math.min(linearScaleFactor, 1F)); 122 | 123 | return (float) Math.pow(10D, (linearScaleFactor - 1F) * LOG_BASE); 124 | } 125 | 126 | } 127 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/command/ApplyCommands.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.command; 2 | 3 | import com.mojang.brigadier.context.CommandContext; 4 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 5 | import de.maxhenkel.admiral.annotations.*; 6 | import de.maxhenkel.audioplayer.CustomSound; 7 | import de.maxhenkel.audioplayer.FileNameManager; 8 | import de.maxhenkel.audioplayer.ComponentUtils; 9 | import de.maxhenkel.audioplayer.PlayerType; 10 | import de.maxhenkel.configbuilder.entry.ConfigEntry; 11 | import net.minecraft.commands.CommandSourceStack; 12 | import net.minecraft.core.NonNullList; 13 | import net.minecraft.core.component.DataComponents; 14 | import net.minecraft.network.chat.Component; 15 | import net.minecraft.server.level.ServerPlayer; 16 | import net.minecraft.world.InteractionHand; 17 | import net.minecraft.world.item.BlockItem; 18 | import net.minecraft.world.item.ItemStack; 19 | import net.minecraft.world.item.component.ItemContainerContents; 20 | import net.minecraft.world.level.block.ShulkerBoxBlock; 21 | import net.minecraft.world.level.block.entity.ShulkerBoxBlockEntity; 22 | import org.jetbrains.annotations.Nullable; 23 | 24 | import java.util.Optional; 25 | import java.util.UUID; 26 | 27 | @Command("audioplayer") 28 | public class ApplyCommands { 29 | @RequiresPermission("audioplayer.apply") 30 | @Command("apply") 31 | public void apply(CommandContext context, @Name("file_name") String fileName, @OptionalArgument @Name("range") @Min("1") Float range, @OptionalArgument @Name("custom_name") String customName) throws CommandSyntaxException { 32 | UUID id = getId(context, fileName); 33 | if (id == null) { 34 | return; 35 | } 36 | apply(context, new CustomSound(id, range, null, false), customName); 37 | } 38 | 39 | @RequiresPermission("audioplayer.apply") 40 | @Command("apply") 41 | public void apply(CommandContext context, @Name("file_name") String fileName, @OptionalArgument @Name("custom_name") String customName) throws CommandSyntaxException { 42 | UUID id = getId(context, fileName); 43 | if (id == null) { 44 | return; 45 | } 46 | apply(context, new CustomSound(id, null, null, false), customName); 47 | } 48 | 49 | // The apply commands for UUIDs must be below the ones with file names, so that the file name does not overwrite the UUID argument 50 | 51 | @RequiresPermission("audioplayer.apply") 52 | @Command("apply") 53 | @Command("musicdisc") 54 | @Command("goathorn") 55 | public void apply(CommandContext context, @Name("sound_id") UUID sound, @OptionalArgument @Name("range") @Min("1") Float range, @OptionalArgument @Name("custom_name") String customName) throws CommandSyntaxException { 56 | apply(context, new CustomSound(sound, range, null, false), customName); 57 | } 58 | 59 | @RequiresPermission("audioplayer.apply") 60 | @Command("apply") 61 | @Command("musicdisc") 62 | @Command("goathorn") 63 | public void apply(CommandContext context, @Name("sound_id") UUID sound, @OptionalArgument @Name("custom_name") String customName) throws CommandSyntaxException { 64 | apply(context, new CustomSound(sound, null, null, false), customName); 65 | } 66 | 67 | @Nullable 68 | private static UUID getId(CommandContext context, String fileName) { 69 | try { 70 | return UUID.fromString(fileName); 71 | } catch (Exception ignored) { 72 | } 73 | 74 | Optional optionalFileNameManager = FileNameManager.instance(); 75 | if (optionalFileNameManager.isEmpty()) { 76 | context.getSource().sendFailure(Component.literal("An internal error occurred")); 77 | return null; 78 | } 79 | 80 | FileNameManager fileNameManager = optionalFileNameManager.get(); 81 | UUID audioId = fileNameManager.getAudioId(fileName); 82 | 83 | if (audioId == null) { 84 | context.getSource().sendFailure(Component.literal("No audio with name '%s' found or more than one found".formatted(fileName))); 85 | return null; 86 | } 87 | return audioId; 88 | } 89 | 90 | private static void apply(CommandContext context, CustomSound sound, @Nullable String customName) throws CommandSyntaxException { 91 | ServerPlayer player = context.getSource().getPlayerOrException(); 92 | ItemStack itemInHand = player.getItemInHand(InteractionHand.MAIN_HAND); 93 | 94 | if (isShulkerBox(itemInHand)) { 95 | applyShulker(context, sound, customName); 96 | return; 97 | } 98 | 99 | PlayerType type = PlayerType.fromItemStack(itemInHand); 100 | if (type == null) { 101 | sendInvalidHandItemMessage(context, itemInHand); 102 | return; 103 | } 104 | apply(context, itemInHand, type, sound, customName); 105 | } 106 | 107 | @RequiresPermission("audioplayer.set_static") 108 | @Command("setstatic") 109 | public void setStatic(CommandContext context, @Name("enabled") Optional enabled) throws CommandSyntaxException { 110 | ServerPlayer player = context.getSource().getPlayerOrException(); 111 | ItemStack itemInHand = player.getItemInHand(InteractionHand.MAIN_HAND); 112 | 113 | PlayerType playerType = PlayerType.fromItemStack(itemInHand); 114 | 115 | if (playerType == null) { 116 | sendInvalidHandItemMessage(context, itemInHand); 117 | return; 118 | } 119 | CustomSound customSound = CustomSound.of(itemInHand); 120 | if (customSound == null) { 121 | context.getSource().sendFailure(Component.literal("This item does not have custom audio")); 122 | return; 123 | } 124 | 125 | CustomSound newSound = customSound.asStatic(enabled.orElse(true)); 126 | newSound.saveToItemIgnoreLore(itemInHand); 127 | 128 | context.getSource().sendSuccess(() -> Component.literal((enabled.orElse(true) ? "Enabled" : "Disabled") + " static audio"), false); 129 | } 130 | 131 | private static void applyShulker(CommandContext context, CustomSound sound, @Nullable String customName) throws CommandSyntaxException { 132 | ServerPlayer player = context.getSource().getPlayerOrException(); 133 | ItemStack itemInHand = player.getItemInHand(InteractionHand.MAIN_HAND); 134 | if (isShulkerBox(itemInHand)) { 135 | processShulker(context, itemInHand, sound, customName); 136 | return; 137 | } 138 | context.getSource().sendFailure(Component.literal("You don't have a shulker box in your main hand")); 139 | } 140 | 141 | private static void processShulker(CommandContext context, ItemStack shulkerItem, CustomSound sound, @Nullable String customName) throws CommandSyntaxException { 142 | ItemContainerContents contents = shulkerItem.getOrDefault(DataComponents.CONTAINER, ItemContainerContents.EMPTY); 143 | NonNullList shulkerContents = NonNullList.withSize(ShulkerBoxBlockEntity.CONTAINER_SIZE, ItemStack.EMPTY); 144 | contents.copyInto(shulkerContents); 145 | for (ItemStack itemStack : shulkerContents) { 146 | PlayerType playerType = PlayerType.fromItemStack(itemStack); 147 | if (playerType == null) { 148 | continue; 149 | } 150 | apply(context, itemStack, playerType, sound, customName); 151 | } 152 | shulkerItem.set(DataComponents.CONTAINER, ItemContainerContents.fromItems(shulkerContents)); 153 | context.getSource().sendSuccess(() -> Component.literal("Successfully updated contents"), false); 154 | } 155 | 156 | private static void apply(CommandContext context, ItemStack stack, PlayerType type, CustomSound customSound, @Nullable String customName) throws CommandSyntaxException { 157 | checkRange(type.getMaxRange(), customSound.getRange().orElse(null)); 158 | if (!type.isValid(stack)) { 159 | return; 160 | } 161 | 162 | if (stack.has(DataComponents.INSTRUMENT)) { 163 | stack.set(DataComponents.INSTRUMENT, ComponentUtils.EMPTY_INSTRUMENT); 164 | } 165 | if (stack.has(DataComponents.JUKEBOX_PLAYABLE)) { 166 | stack.set(DataComponents.JUKEBOX_PLAYABLE, ComponentUtils.CUSTOM_JUKEBOX_PLAYABLE); 167 | } 168 | 169 | CustomSound handItemSound = CustomSound.of(stack); 170 | 171 | if (handItemSound != null && handItemSound.isRandomized()) { 172 | handItemSound.addRandomSound(customSound.getSoundId()); 173 | handItemSound.saveToItem(stack, customName); 174 | context.getSource().sendSuccess(() -> Component.literal("Successfully added sound to ").append(stack.getHoverName()), false); 175 | } else { 176 | customSound.saveToItem(stack, customName); 177 | context.getSource().sendSuccess(() -> Component.literal("Successfully updated ").append(stack.getHoverName()), false); 178 | } 179 | } 180 | 181 | private static void checkRange(ConfigEntry maxRange, @Nullable Float range) throws CommandSyntaxException { 182 | if (range == null) { 183 | return; 184 | } 185 | if (range > maxRange.get()) { 186 | throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.floatTooHigh().create(range, maxRange.get()); 187 | } 188 | } 189 | 190 | public static boolean isShulkerBox(ItemStack stack) { 191 | return stack.getItem() instanceof BlockItem blockitem && blockitem.getBlock() instanceof ShulkerBoxBlock; 192 | } 193 | 194 | private static void sendInvalidHandItemMessage(CommandContext context, ItemStack invalidItem) { 195 | if (invalidItem.isEmpty()) { 196 | context.getSource().sendFailure(Component.literal("You don't have an item in your main hand")); 197 | return; 198 | } 199 | context.getSource().sendFailure(Component.literal("The item in your main hand can not have custom audio")); 200 | } 201 | 202 | } 203 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/command/PlayCommands.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.command; 2 | 3 | import com.mojang.brigadier.context.CommandContext; 4 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 5 | import de.maxhenkel.admiral.annotations.Command; 6 | import de.maxhenkel.admiral.annotations.Min; 7 | import de.maxhenkel.admiral.annotations.Name; 8 | import de.maxhenkel.admiral.annotations.RequiresPermission; 9 | import de.maxhenkel.audioplayer.PlayerManager; 10 | import de.maxhenkel.audioplayer.Plugin; 11 | import de.maxhenkel.voicechat.api.VoicechatServerApi; 12 | import net.minecraft.commands.CommandSourceStack; 13 | import net.minecraft.network.chat.Component; 14 | import net.minecraft.server.level.ServerPlayer; 15 | import net.minecraft.world.phys.Vec3; 16 | import org.jetbrains.annotations.Nullable; 17 | 18 | import java.util.UUID; 19 | 20 | @Command("audioplayer") 21 | public class PlayCommands { 22 | 23 | @RequiresPermission("audioplayer.play_command") 24 | @Command("play") 25 | public void play(CommandContext context, @Name("sound") UUID sound, @Name("location") Vec3 location, @Name("range") @Min("0") float range) throws CommandSyntaxException { 26 | @Nullable ServerPlayer player = context.getSource().getPlayer(); 27 | VoicechatServerApi api = Plugin.voicechatServerApi; 28 | if (api == null) { 29 | return; 30 | } 31 | PlayerManager.instance().playLocational( 32 | api, 33 | context.getSource().getLevel(), 34 | location, 35 | sound, 36 | player, 37 | range, 38 | null, 39 | Integer.MAX_VALUE, 40 | true 41 | ); 42 | context.getSource().sendSuccess(() -> Component.literal("Successfully played %s".formatted(sound)), false); 43 | } 44 | 45 | @RequiresPermission("audioplayer.play_command") 46 | @Command("stop") 47 | private static int stop(CommandContext context, @Name("sound") UUID sound) { 48 | UUID channelID = PlayerManager.instance().findChannelID(sound, true); 49 | 50 | if (channelID != null) { 51 | PlayerManager.instance().stop(channelID); 52 | context.getSource().sendSuccess(() -> Component.literal("Successfully stopped %s".formatted(sound)), false); 53 | return 1; 54 | } else { 55 | context.getSource().sendFailure(Component.literal("Failed to stop, could not find sound with ID %s".formatted(sound))); 56 | } 57 | return 0; 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/command/UploadCommands.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.command; 2 | 3 | import com.mojang.brigadier.context.CommandContext; 4 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 5 | import de.maxhenkel.admiral.annotations.Command; 6 | import de.maxhenkel.admiral.annotations.Name; 7 | import de.maxhenkel.admiral.annotations.RequiresPermission; 8 | import de.maxhenkel.audioplayer.AudioManager; 9 | import de.maxhenkel.audioplayer.AudioPlayer; 10 | import de.maxhenkel.audioplayer.Filebin; 11 | import de.maxhenkel.audioplayer.webserver.UrlUtils; 12 | import de.maxhenkel.audioplayer.webserver.WebServer; 13 | import de.maxhenkel.audioplayer.webserver.WebServerEvents; 14 | import net.minecraft.ChatFormatting; 15 | import net.minecraft.commands.CommandSourceStack; 16 | import net.minecraft.network.chat.*; 17 | 18 | import javax.sound.sampled.UnsupportedAudioFileException; 19 | import java.net.URI; 20 | import java.net.UnknownHostException; 21 | import java.nio.file.NoSuchFileException; 22 | import java.nio.file.Path; 23 | import java.util.UUID; 24 | import java.util.regex.Matcher; 25 | import java.util.regex.Pattern; 26 | 27 | @Command("audioplayer") 28 | public class UploadCommands { 29 | 30 | public static final Pattern SOUND_FILE_PATTERN = Pattern.compile("^[a-z0-9_ -]+.((wav)|(mp3))$", Pattern.CASE_INSENSITIVE); 31 | 32 | @RequiresPermission("audioplayer.upload") 33 | @Command 34 | public void audioPlayer(CommandContext context) { 35 | context.getSource().sendSuccess(() -> 36 | Component.literal("Upload audio via Filebin ") 37 | .append(Component.literal("here").withStyle(style -> { 38 | return style 39 | .withClickEvent(new ClickEvent.RunCommand("/audioplayer upload")) 40 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to show more"))); 41 | }).withStyle(ChatFormatting.GREEN)) 42 | .append(".") 43 | , false); 44 | context.getSource().sendSuccess(() -> 45 | Component.literal("Upload audio with access to the servers file system ") 46 | .append(Component.literal("here").withStyle(style -> { 47 | return style 48 | .withClickEvent(new ClickEvent.RunCommand("/audioplayer serverfile")) 49 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to show more"))); 50 | }).withStyle(ChatFormatting.GREEN)) 51 | .append(".") 52 | , false); 53 | context.getSource().sendSuccess(() -> 54 | Component.literal("Upload audio from a URL ") 55 | .append(Component.literal("here").withStyle(style -> { 56 | return style 57 | .withClickEvent(new ClickEvent.RunCommand("/audioplayer url")) 58 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to show more"))); 59 | }).withStyle(ChatFormatting.GREEN)) 60 | .append(".") 61 | , false); 62 | } 63 | 64 | @RequiresPermission("audioplayer.upload") 65 | @Command("upload") 66 | @Command("filebin") 67 | public void filebin(CommandContext context) { 68 | UUID uuid = UUID.randomUUID(); 69 | URI uploadURL = Filebin.getBin(uuid); 70 | 71 | MutableComponent msg = Component.literal("Click ") 72 | .append(Component.literal("this link") 73 | .withStyle(style -> { 74 | return style 75 | .withClickEvent(new ClickEvent.OpenUrl(uploadURL)) 76 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to open"))); 77 | }) 78 | .withStyle(ChatFormatting.GREEN) 79 | ) 80 | .append(" and upload your sound as ") 81 | .append(Component.literal("mp3").withStyle(ChatFormatting.GRAY)) 82 | .append(" or ") 83 | .append(Component.literal("wav").withStyle(ChatFormatting.GRAY)) 84 | .append(".\n") 85 | .append("Once you have uploaded the file, click ") 86 | .append(Component.literal("here") 87 | .withStyle(style -> { 88 | return style 89 | .withClickEvent(new ClickEvent.RunCommand("/audioplayer filebin " + uuid)) 90 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to confirm upload"))); 91 | }) 92 | .withStyle(ChatFormatting.GREEN) 93 | ) 94 | .append("."); 95 | 96 | context.getSource().sendSuccess(() -> msg, false); 97 | } 98 | 99 | @RequiresPermission("audioplayer.upload") 100 | @Command("filebin") 101 | public void filebinUpload(CommandContext context, @Name("id") UUID sound) { 102 | new Thread(() -> { 103 | try { 104 | context.getSource().sendSuccess(() -> Component.literal("Downloading sound, please wait..."), false); 105 | Filebin.downloadSound(context.getSource().getServer(), sound); 106 | context.getSource().sendSuccess(() -> sendUUIDMessage(sound, Component.literal("Successfully downloaded sound.")), false); 107 | } catch (Exception e) { 108 | AudioPlayer.LOGGER.warn("{} failed to download a sound: {}", context.getSource().getTextName(), e.getMessage()); 109 | context.getSource().sendFailure(Component.literal("Failed to download sound: %s".formatted(e.getMessage()))); 110 | } 111 | }).start(); 112 | } 113 | 114 | @RequiresPermission("audioplayer.upload") 115 | @Command("url") 116 | public void url(CommandContext context) { 117 | context.getSource().sendSuccess(() -> 118 | Component.literal("If you have a direct link to a ") 119 | .append(Component.literal(".mp3").withStyle(ChatFormatting.GRAY)) 120 | .append(" or ") 121 | .append(Component.literal(".wav").withStyle(ChatFormatting.GRAY)) 122 | .append(" file, enter the following command: ") 123 | .append(Component.literal("/audioplayer url ").withStyle(ChatFormatting.GRAY).withStyle(style -> { 124 | return style 125 | .withClickEvent(new ClickEvent.SuggestCommand("/audioplayer url ")) 126 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to fill in the command"))); 127 | })) 128 | .append(".") 129 | , false); 130 | } 131 | 132 | @RequiresPermission("audioplayer.upload") 133 | @Command("url") 134 | public void urlUpload(CommandContext context, @Name("url") String url) { 135 | UUID sound = UUID.randomUUID(); 136 | new Thread(() -> { 137 | try { 138 | context.getSource().sendSuccess(() -> Component.literal("Downloading sound, please wait..."), false); 139 | AudioManager.saveSound(context.getSource().getServer(), sound, url); 140 | context.getSource().sendSuccess(() -> sendUUIDMessage(sound, Component.literal("Successfully downloaded sound.")), false); 141 | } catch (UnknownHostException e) { 142 | AudioPlayer.LOGGER.warn("{} failed to download a sound: {}", context.getSource().getTextName(), e.toString()); 143 | context.getSource().sendFailure(Component.literal("Failed to download sound: Unknown host")); 144 | } catch (UnsupportedAudioFileException e) { 145 | AudioPlayer.LOGGER.warn("{} failed to download a sound: {}", context.getSource().getTextName(), e.toString()); 146 | context.getSource().sendFailure(Component.literal("Failed to download sound: Invalid file format")); 147 | } catch (Exception e) { 148 | AudioPlayer.LOGGER.warn("{} failed to download a sound: {}", context.getSource().getTextName(), e.toString()); 149 | context.getSource().sendFailure(Component.literal("Failed to download sound: %s".formatted(e.getMessage()))); 150 | } 151 | }).start(); 152 | } 153 | 154 | @RequiresPermission("audioplayer.upload") 155 | @Command("web") 156 | public void web(CommandContext context) throws CommandSyntaxException { 157 | WebServer webServer = WebServerEvents.getWebServer(); 158 | if (webServer == null) { 159 | context.getSource().sendFailure(Component.literal("Web server is not running")); 160 | return; 161 | } 162 | 163 | UUID token = webServer.getTokenManager().generateToken(context.getSource().getPlayerOrException().getUUID()); 164 | 165 | URI uploadUrl = UrlUtils.generateUploadUrl(token); 166 | 167 | if (uploadUrl != null) { 168 | context.getSource().sendSuccess(() -> 169 | Component.literal("Click ") 170 | .append(Component.literal("here").withStyle(ChatFormatting.GREEN, ChatFormatting.UNDERLINE).withStyle(style -> { 171 | return style 172 | .withClickEvent(new ClickEvent.OpenUrl(uploadUrl)) 173 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to open"))); 174 | })) 175 | .append(" to upload your sound.") 176 | , false); 177 | return; 178 | } 179 | 180 | context.getSource().sendSuccess(() -> 181 | Component.literal("Visit the website and use ") 182 | .append(Component.literal("this token").withStyle(ChatFormatting.GREEN).withStyle(style -> { 183 | return style 184 | .withClickEvent(new ClickEvent.CopyToClipboard(token.toString())) 185 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to copy"))); 186 | })) 187 | .append(".") 188 | , false); 189 | } 190 | 191 | @RequiresPermission("audioplayer.upload") 192 | @Command("serverfile") 193 | public void serverFile(CommandContext context) { 194 | context.getSource().sendSuccess(() -> 195 | Component.literal("Upload a ") 196 | .append(Component.literal(".mp3").withStyle(ChatFormatting.GRAY)) 197 | .append(" or ") 198 | .append(Component.literal(".wav").withStyle(ChatFormatting.GRAY)) 199 | .append(" file to ") 200 | .append(Component.literal(AudioManager.getUploadFolder().toAbsolutePath().toString()).withStyle(ChatFormatting.GRAY)) 201 | .append(" on the server and run the command ") 202 | .append(Component.literal("/audioplayer serverfile \"yourfile.mp3\"").withStyle(ChatFormatting.GRAY).withStyle(style -> { 203 | return style 204 | .withClickEvent(new ClickEvent.SuggestCommand("/audioplayer serverfile ")) 205 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to fill in the command"))); 206 | })) 207 | .append(".") 208 | , false); 209 | } 210 | 211 | @RequiresPermission("audioplayer.upload") 212 | @Command("serverfile") 213 | public void serverFileUpload(CommandContext context, @Name("filename") String fileName) { 214 | Matcher matcher = SOUND_FILE_PATTERN.matcher(fileName); 215 | if (!matcher.matches()) { 216 | context.getSource().sendFailure(Component.literal("Invalid file name! Valid characters are ") 217 | .append(Component.literal("A-Z").withStyle(ChatFormatting.GRAY)) 218 | .append(", ") 219 | .append(Component.literal("0-9").withStyle(ChatFormatting.GRAY)) 220 | .append(", ") 221 | .append(Component.literal("_").withStyle(ChatFormatting.GRAY)) 222 | .append(" and ") 223 | .append(Component.literal("-").withStyle(ChatFormatting.GRAY)) 224 | .append(". The name must also end in ") 225 | .append(Component.literal(".mp3").withStyle(ChatFormatting.GRAY)) 226 | .append(" or ") 227 | .append(Component.literal(".wav").withStyle(ChatFormatting.GRAY)) 228 | .append(".") 229 | ); 230 | return; 231 | } 232 | UUID uuid = UUID.randomUUID(); 233 | new Thread(() -> { 234 | Path file = AudioManager.getUploadFolder().resolve(fileName); 235 | try { 236 | AudioManager.saveSound(context.getSource().getServer(), uuid, file); 237 | context.getSource().sendSuccess(() -> sendUUIDMessage(uuid, Component.literal("Successfully copied sound.")), false); 238 | context.getSource().sendSuccess(() -> Component.literal("Deleted temporary file ").append(Component.literal(fileName).withStyle(ChatFormatting.GRAY)).append("."), false); 239 | } catch (NoSuchFileException e) { 240 | context.getSource().sendFailure(Component.literal("Could not find file ").append(Component.literal(fileName).withStyle(ChatFormatting.GRAY)).append(".")); 241 | } catch (Exception e) { 242 | AudioPlayer.LOGGER.warn("{} failed to copy a sound: {}", context.getSource().getTextName(), e.getMessage()); 243 | context.getSource().sendFailure(Component.literal("Failed to copy sound: %s".formatted(e.getMessage()))); 244 | } 245 | }).start(); 246 | } 247 | 248 | public static MutableComponent sendUUIDMessage(UUID soundID, MutableComponent component) { 249 | return component.append(" ") 250 | .append(ComponentUtils.wrapInSquareBrackets(Component.literal("Copy ID")) 251 | .withStyle(style -> { 252 | return style 253 | .withClickEvent(new ClickEvent.CopyToClipboard(soundID.toString())) 254 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Copy sound ID"))); 255 | }) 256 | .withStyle(ChatFormatting.GREEN) 257 | ) 258 | .append(" ") 259 | .append(ComponentUtils.wrapInSquareBrackets(Component.literal("Put on item")) 260 | .withStyle(style -> { 261 | return style 262 | .withClickEvent(new ClickEvent.SuggestCommand("/audioplayer apply %s".formatted(soundID.toString()))) 263 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Put the sound on an item"))); 264 | }) 265 | .withStyle(ChatFormatting.GREEN) 266 | ); 267 | } 268 | 269 | } 270 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/command/UtilityCommands.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.command; 2 | 3 | import com.mojang.brigadier.context.CommandContext; 4 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 5 | import de.maxhenkel.admiral.annotations.*; 6 | import de.maxhenkel.audioplayer.*; 7 | import net.minecraft.ChatFormatting; 8 | import net.minecraft.commands.CommandSourceStack; 9 | import net.minecraft.core.Holder; 10 | import net.minecraft.core.component.DataComponentType; 11 | import net.minecraft.core.component.DataComponents; 12 | import net.minecraft.core.registries.Registries; 13 | import net.minecraft.network.chat.ClickEvent; 14 | import net.minecraft.network.chat.Component; 15 | import net.minecraft.network.chat.HoverEvent; 16 | import net.minecraft.server.level.ServerPlayer; 17 | import net.minecraft.world.InteractionHand; 18 | import net.minecraft.world.item.*; 19 | import net.minecraft.world.item.component.InstrumentComponent; 20 | import net.minecraft.world.item.component.TooltipDisplay; 21 | 22 | import java.util.ArrayList; 23 | import java.util.LinkedHashSet; 24 | import java.util.Optional; 25 | import java.util.UUID; 26 | 27 | @Command("audioplayer") 28 | public class UtilityCommands { 29 | @RequiresPermission("audioplayer.apply") 30 | @Command("set_random") 31 | public void set_random(CommandContext context, @Name("enabled") boolean enabled) throws CommandSyntaxException { 32 | ServerPlayer player = context.getSource().getPlayerOrException(); 33 | ItemStack itemInHand = player.getItemInHand(InteractionHand.MAIN_HAND); 34 | 35 | PlayerType playerType = PlayerType.fromItemStack(itemInHand); 36 | if (playerType == null) { 37 | context.getSource().sendFailure(Component.nullToEmpty("Invalid Item")); 38 | return; 39 | } 40 | 41 | CustomSound sound = getHeldSound(context); 42 | 43 | if (sound == null) { 44 | return; 45 | } 46 | 47 | sound.setRandomization(enabled); 48 | 49 | sound.saveToItem(itemInHand, null, false); 50 | 51 | if (enabled) { 52 | context.getSource().sendSuccess(() -> Component.literal("Successfully enabled randomization, more sounds can now be added to this item"), false); 53 | } else { 54 | context.getSource().sendSuccess(() -> Component.literal("Successfully disabled randomization, extra sounds have been removed"), false); 55 | } 56 | } 57 | 58 | @RequiresPermission("audioplayer.apply") 59 | @Command("clear") 60 | public void clear(CommandContext context) throws CommandSyntaxException { 61 | ServerPlayer player = context.getSource().getPlayerOrException(); 62 | ItemStack itemInHand = player.getItemInHand(InteractionHand.MAIN_HAND); 63 | 64 | PlayerType playerType = PlayerType.fromItemStack(itemInHand); 65 | if (playerType == null) { 66 | context.getSource().sendFailure(Component.literal("Invalid item")); 67 | return; 68 | } 69 | 70 | if (!CustomSound.clearItem(itemInHand)) { 71 | context.getSource().sendFailure(Component.literal("Item does not have custom audio")); 72 | return; 73 | } 74 | 75 | if (itemInHand.has(DataComponents.INSTRUMENT)) { 76 | Optional> holder = context.getSource().getServer().registryAccess().lookupOrThrow(Registries.INSTRUMENT).get(Instruments.PONDER_GOAT_HORN); 77 | holder.ifPresent(instrumentReference -> itemInHand.set(DataComponents.INSTRUMENT, new InstrumentComponent(instrumentReference))); 78 | } 79 | if (itemInHand.has(DataComponents.JUKEBOX_PLAYABLE)) { 80 | JukeboxPlayable jukeboxPlayable = itemInHand.getItem().components().get(DataComponents.JUKEBOX_PLAYABLE); 81 | if (jukeboxPlayable != null) { 82 | itemInHand.set(DataComponents.JUKEBOX_PLAYABLE, jukeboxPlayable); 83 | } else { 84 | itemInHand.remove(DataComponents.JUKEBOX_PLAYABLE); 85 | } 86 | } 87 | 88 | TooltipDisplay tooltipDisplay = itemInHand.get(DataComponents.TOOLTIP_DISPLAY); 89 | if (tooltipDisplay != null) { 90 | LinkedHashSet> hiddenComponents = new LinkedHashSet<>(tooltipDisplay.hiddenComponents()); 91 | hiddenComponents.remove(DataComponents.JUKEBOX_PLAYABLE); 92 | hiddenComponents.remove(DataComponents.INSTRUMENT); 93 | itemInHand.set(DataComponents.TOOLTIP_DISPLAY, new TooltipDisplay(tooltipDisplay.hideTooltip(), hiddenComponents)); 94 | } 95 | 96 | if (itemInHand.has(DataComponents.LORE)) { 97 | itemInHand.remove(DataComponents.LORE); 98 | } 99 | 100 | context.getSource().sendSuccess(() -> Component.literal("Successfully cleared item"), false); 101 | } 102 | 103 | @Command("id") 104 | public void id(CommandContext context) throws CommandSyntaxException { 105 | CustomSound customSound = getHeldSound(context); 106 | if (customSound == null) { 107 | return; 108 | } 109 | if (customSound.isRandomized()) { 110 | ArrayList sounds = customSound.getRandomSounds(); 111 | context.getSource().sendSuccess(() -> Component.literal("Item contains %d sounds".formatted(sounds.size())), false); 112 | for (int i = 0; i < sounds.size(); i++) { 113 | int finalI = i; 114 | context.getSource().sendSuccess(() -> UploadCommands.sendUUIDMessage(sounds.get(finalI), Component.literal("Sound %d.".formatted(finalI))), false); 115 | } 116 | return; 117 | } 118 | context.getSource().sendSuccess(() -> UploadCommands.sendUUIDMessage(customSound.getSoundId(), Component.literal("Successfully extracted sound ID.")), false); 119 | } 120 | 121 | @Command("name") 122 | public void name(CommandContext context) throws CommandSyntaxException { 123 | CustomSound customSound = getHeldSound(context); 124 | if (customSound == null) { 125 | return; 126 | } 127 | Optional optionalMgr = FileNameManager.instance(); 128 | 129 | if (optionalMgr.isEmpty()) { 130 | context.getSource().sendFailure(Component.literal("An internal error occurred")); 131 | return; 132 | } 133 | 134 | FileNameManager mgr = optionalMgr.get(); 135 | 136 | if (customSound.isRandomized()) { 137 | ArrayList sounds = customSound.getRandomSounds(); 138 | context.getSource().sendSuccess(() -> Component.literal("Item contains %d sounds".formatted(sounds.size())), false); 139 | for (UUID sound : sounds) { 140 | sendSoundName(context, mgr, sound); 141 | } 142 | return; 143 | } 144 | 145 | sendSoundName(context, mgr, customSound.getSoundId()); 146 | } 147 | 148 | public static void sendSoundName(CommandContext context, FileNameManager mgr, UUID id) { 149 | String fileName = mgr.getFileName(id); 150 | if (fileName == null) { 151 | context.getSource().sendFailure(Component.literal("Custom audio does not have an associated file name")); 152 | return; 153 | } 154 | 155 | context.getSource().sendSuccess(() -> Component.literal("Audio file name: ").append(Component.literal(fileName).withStyle(style -> { 156 | return style 157 | .withColor(ChatFormatting.GREEN) 158 | .withHoverEvent(new HoverEvent.ShowText(Component.literal("Click to copy"))) 159 | .withClickEvent(new ClickEvent.CopyToClipboard(fileName)); 160 | })), false); 161 | } 162 | 163 | public static CustomSound getHeldSound(CommandContext context) throws CommandSyntaxException { 164 | ServerPlayer player = context.getSource().getPlayerOrException(); 165 | ItemStack itemInHand = player.getItemInHand(InteractionHand.MAIN_HAND); 166 | 167 | PlayerType playerType = PlayerType.fromItemStack(itemInHand); 168 | 169 | if (playerType == null) { 170 | context.getSource().sendFailure(Component.literal("Invalid item")); 171 | return null; 172 | } 173 | 174 | CustomSound customSound = CustomSound.of(itemInHand); 175 | if (customSound == null) { 176 | context.getSource().sendFailure(Component.literal("Item does not have custom audio")); 177 | return null; 178 | } 179 | 180 | return customSound; 181 | } 182 | 183 | } 184 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/command/VolumeCommands.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.command; 2 | 3 | import com.mojang.brigadier.context.CommandContext; 4 | import com.mojang.brigadier.exceptions.CommandSyntaxException; 5 | import de.maxhenkel.admiral.annotations.*; 6 | import de.maxhenkel.audioplayer.*; 7 | import net.minecraft.commands.CommandSourceStack; 8 | import net.minecraft.network.chat.Component; 9 | 10 | import javax.annotation.Nullable; 11 | import java.text.DecimalFormat; 12 | import java.util.Optional; 13 | import java.util.UUID; 14 | 15 | @Command("audioplayer") 16 | public class VolumeCommands { 17 | 18 | @RequiresPermission("audioplayer.volume") 19 | @Command("volume") 20 | public void volumeWithId(CommandContext context, @Name("id") UUID uuid, @OptionalArgument @Name("volume") @Min("0.01") @Max("100") Float volume) { 21 | volumeCommand(context, uuid, volume); 22 | } 23 | 24 | @RequiresPermission("audioplayer.volume") 25 | @Command("volume") 26 | public void volumeHeldItem(CommandContext context, @OptionalArgument @Name("volume") @Min("0.01") @Max("100") Float volume) throws CommandSyntaxException { 27 | CustomSound customSound = UtilityCommands.getHeldSound(context); 28 | if (customSound == null) { 29 | return; 30 | } 31 | if (customSound.isRandomized()) { 32 | for (UUID id : customSound.getRandomSounds()) { 33 | volumeCommand(context, id, volume); 34 | } 35 | return; 36 | } 37 | volumeCommand(context, customSound.getSoundId(), volume); 38 | } 39 | 40 | private void volumeCommand(CommandContext context, UUID id, @Nullable Float volume) { 41 | if (!AudioManager.checkSoundExists(context.getSource().getServer(), id)) { 42 | context.getSource().sendFailure(Component.literal("Sound does not exist")); 43 | return; 44 | } 45 | Optional optionalMgr = VolumeOverrideManager.instance(); 46 | if (optionalMgr.isEmpty()) { 47 | context.getSource().sendFailure(Component.literal("An internal error occurred")); 48 | return; 49 | } 50 | VolumeOverrideManager mgr = optionalMgr.get(); 51 | DecimalFormat percentFormat = new DecimalFormat("#.00"); 52 | if (volume == null) { 53 | float currentVolumeLog = mgr.getAudioVolume(id); 54 | float currentVolume = VolumeOverrideManager.convertToLinearScaleFactor(currentVolumeLog); 55 | 56 | context.getSource().sendSuccess(() -> Component.literal("Current volume is %s%%".formatted(percentFormat.format(currentVolume * 100F))), false); 57 | return; 58 | } 59 | if (volume == 100F) { 60 | // Will remove volume from json, to keep json file smaller 61 | mgr.setAudioVolume(id, null); 62 | } 63 | float volumeLinear = volume / 100F; 64 | mgr.setAudioVolume(id, VolumeOverrideManager.convertToLogarithmicScaleFactor(volumeLinear)); 65 | AudioPlayer.AUDIO_CACHE.remove(id); 66 | context.getSource().sendSuccess(() -> Component.literal("Successfully set sound volume to %s%%, this will apply next time the sound plays".formatted(percentFormat.format(volume))), false); 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/config/ServerConfig.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.config; 2 | 3 | import de.maxhenkel.configbuilder.ConfigBuilder; 4 | import de.maxhenkel.configbuilder.entry.ConfigEntry; 5 | 6 | public class ServerConfig { 7 | 8 | public final ConfigEntry filebinUrl; 9 | public final ConfigEntry maxUploadSize; 10 | public final ConfigEntry goatHornCooldown; 11 | public final ConfigEntry musicDiscRange; 12 | public final ConfigEntry noteBlockRange; 13 | public final ConfigEntry goatHornRange; 14 | public final ConfigEntry maxGoatHornRange; 15 | public final ConfigEntry maxNoteBlockRange; 16 | public final ConfigEntry maxMusicDiscRange; 17 | public final ConfigEntry allowWavUpload; 18 | public final ConfigEntry allowMp3Upload; 19 | public final ConfigEntry maxMusicDiscDuration; 20 | public final ConfigEntry maxNoteBlockDuration; 21 | public final ConfigEntry maxGoatHornDuration; 22 | public final ConfigEntry cacheSize; 23 | public final ConfigEntry allowStaticAudio; 24 | public final ConfigEntry runWebServer; 25 | 26 | public ServerConfig(ConfigBuilder builder) { 27 | filebinUrl = builder.stringEntry( 28 | "filebin_url", 29 | "https://filebin.net/", 30 | "The URL of the Filebin service that the mod should use" 31 | ); 32 | maxUploadSize = builder.longEntry( 33 | "max_upload_size", 34 | 1000L * 1000L * 20L, 35 | 1L, 36 | (long) Integer.MAX_VALUE, 37 | "The maximum allowed size of an uploaded file in bytes" 38 | ); 39 | goatHornCooldown = builder.integerEntry( 40 | "goat_horn_cooldown", 41 | 140, 42 | 1, 43 | (int) Short.MAX_VALUE, 44 | "The cooldown of goat horns with custom audio in ticks" 45 | ); 46 | musicDiscRange = builder.floatEntry( 47 | "music_disc_range", 48 | 65F, 49 | 1F, 50 | (float) Integer.MAX_VALUE, 51 | "The range of music discs with custom audio in blocks" 52 | ); 53 | noteBlockRange = builder.floatEntry( 54 | "note_block_range", 55 | 16F, 56 | 1F, 57 | (float) Integer.MAX_VALUE, 58 | "The range of note blocks with custom audio in blocks" 59 | ); 60 | goatHornRange = builder.floatEntry( 61 | "goat_horn_range", 62 | 256F, 63 | 1F, 64 | (float) Integer.MAX_VALUE, 65 | "The range of goat horns with custom audio in blocks" 66 | ); 67 | maxMusicDiscRange = builder.floatEntry( 68 | "max_music_disc_range", 69 | 256F, 70 | 1F, 71 | (float) Integer.MAX_VALUE, 72 | "The maximum allowed range of a music disc with custom audio in blocks" 73 | ); 74 | maxNoteBlockRange = builder.floatEntry( 75 | "max_note_block_range", 76 | 256F, 77 | 1F, 78 | (float) Integer.MAX_VALUE, 79 | "The maximum allowed range of a note block with custom audio in blocks" 80 | ); 81 | maxGoatHornRange = builder.floatEntry( 82 | "max_goat_horn_range", 83 | 512F, 84 | 1F, 85 | (float) Integer.MAX_VALUE, 86 | "The maximum allowed range of a goat horn with custom audio in blocks" 87 | ); 88 | allowWavUpload = builder.booleanEntry( 89 | "allow_wav_upload", 90 | true, 91 | "Whether users should be able to upload .wav files", 92 | "Note that .wav files are not compressed and can be very large", 93 | "Playing .wav files may result in more RAM usage" 94 | ); 95 | allowMp3Upload = builder.booleanEntry( 96 | "allow_mp3_upload", 97 | true, 98 | "Whether users should be able to upload .mp3 files", 99 | "Note that .mp3 files require Simple Voice Chats mp3 decoder", 100 | "Playing .mp3 files can be slightly more CPU intensive" 101 | ); 102 | maxMusicDiscDuration = builder.integerEntry( 103 | "max_music_disc_duration", 104 | 60 * 5, 105 | 1, 106 | Integer.MAX_VALUE, 107 | "The maximum allowed duration of a custom music disc in seconds" 108 | ); 109 | maxNoteBlockDuration = builder.integerEntry( 110 | "max_note_block_duration", 111 | 60 * 5, 112 | 1, 113 | Integer.MAX_VALUE, 114 | "The maximum allowed duration of a note block with custom audio in seconds" 115 | ); 116 | maxGoatHornDuration = builder.integerEntry( 117 | "max_goat_horn_duration", 118 | 20, 119 | 1, 120 | Integer.MAX_VALUE, 121 | "The maximum allowed duration of a custom goat horn in seconds" 122 | ); 123 | cacheSize = builder.integerEntry( 124 | "cache_size", 125 | 16, 126 | 0, 127 | Integer.MAX_VALUE, 128 | "The maximum amount of audio files that are cached in memory", 129 | "Setting this to 0 will disable the cache", 130 | "A higher value will result in less disk reads, but more RAM usage" 131 | ); 132 | allowStaticAudio = builder.booleanEntry( 133 | "allow_static_audio", 134 | true, 135 | "Static audio does not have directionality or falloff (volume does not decrease with distance)", 136 | "The /audioplayer setstatic [enabled] command can be used when this is set to true", 137 | "If this config option is disabled, static audio is completely disabled and will play as if the option wouldn't be set" 138 | ); 139 | runWebServer = builder.booleanEntry( 140 | "run_web_server", 141 | false, 142 | "If the mod should run a webserver for uploads", 143 | "You can configure the webserver in the webserver.properties config", 144 | "The webserver.properties will only be generated if this option is set to true", 145 | "NOTE: This option is experimental and subject to change" 146 | ); 147 | } 148 | 149 | } 150 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/config/WebServerConfig.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.config; 2 | 3 | import de.maxhenkel.configbuilder.ConfigBuilder; 4 | import de.maxhenkel.configbuilder.entry.ConfigEntry; 5 | 6 | public class WebServerConfig { 7 | 8 | public final ConfigEntry port; 9 | public final ConfigEntry url; 10 | public final ConfigEntry tokenTimeout; 11 | public final ConfigEntry authUsername; 12 | public final ConfigEntry authPassword; 13 | //TODO Configurable timeout 14 | 15 | public WebServerConfig(ConfigBuilder builder) { 16 | port = builder.integerEntry( 17 | "port", 18 | 8080, 19 | 1, 20 | (int) Short.MAX_VALUE, 21 | "The webserver port" 22 | ); 23 | url = builder.stringEntry( 24 | "url", 25 | "", 26 | "The URL under which the webserver is reachable", 27 | "Example: https://test.example.com", 28 | "If this is left empty, the user will be prompted to copy the token manually", 29 | "If its set, the link will be generated automatically and the user can just open a link" 30 | ); 31 | tokenTimeout = builder.longEntry( 32 | "token_timeout", 33 | 1000L * 60L * 5L, 34 | "The timeout of the token in milliseconds" 35 | ); 36 | authUsername = builder.stringEntry( 37 | "auth_username", 38 | "", 39 | "The username for basic auth", 40 | "If this is left empty, no auth will be used" 41 | ); 42 | authPassword = builder.stringEntry( 43 | "auth_password", 44 | "", 45 | "The password for basic auth", 46 | "If this is left empty, no auth will be used" 47 | ); 48 | } 49 | 50 | } 51 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/interfaces/ChannelHolder.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.interfaces; 2 | 3 | import javax.annotation.Nullable; 4 | import java.util.UUID; 5 | 6 | public interface ChannelHolder { 7 | 8 | @Nullable 9 | UUID audioplayer$getChannelID(); 10 | 11 | void audioplayer$setChannelID(@Nullable UUID channelID); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/interfaces/CustomJukeboxSongPlayer.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.interfaces; 2 | 3 | import net.minecraft.core.HolderLookup; 4 | import net.minecraft.nbt.CompoundTag; 5 | import net.minecraft.server.level.ServerLevel; 6 | import net.minecraft.world.item.ItemStack; 7 | 8 | public interface CustomJukeboxSongPlayer { 9 | 10 | void audioplayer$onSave(ItemStack itemStack, CompoundTag compound, HolderLookup.Provider provider); 11 | 12 | void audioplayer$onLoad(ItemStack itemStack, CompoundTag compound, HolderLookup.Provider provider); 13 | 14 | boolean audioplayer$customPlay(ServerLevel level, ItemStack item); 15 | 16 | boolean audioplayer$customStop(); 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/interfaces/CustomSoundHolder.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.interfaces; 2 | 3 | import de.maxhenkel.audioplayer.CustomSound; 4 | 5 | public interface CustomSoundHolder { 6 | 7 | CustomSound audioplayer$getCustomSound(); 8 | 9 | } 10 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/AbstractSkullBlockMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.PlayerManager; 4 | import de.maxhenkel.audioplayer.interfaces.ChannelHolder; 5 | import net.minecraft.core.BlockPos; 6 | import net.minecraft.world.level.Level; 7 | import net.minecraft.world.level.block.AbstractSkullBlock; 8 | import net.minecraft.world.level.block.Block; 9 | import net.minecraft.world.level.block.NoteBlock; 10 | import net.minecraft.world.level.block.entity.BlockEntity; 11 | import net.minecraft.world.level.block.state.BlockState; 12 | import net.minecraft.world.level.redstone.Orientation; 13 | import org.spongepowered.asm.mixin.Mixin; 14 | import org.spongepowered.asm.mixin.injection.At; 15 | import org.spongepowered.asm.mixin.injection.Inject; 16 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 17 | 18 | import java.util.UUID; 19 | 20 | @Mixin(AbstractSkullBlock.class) 21 | public class AbstractSkullBlockMixin { 22 | 23 | @Inject(method = "neighborChanged", at = @At(value = "HEAD")) 24 | private void neighborChangedInject(BlockState blockState, Level level, BlockPos blockPos, Block block, Orientation orientation, boolean bl, CallbackInfo ci) { 25 | BlockState blockstate = level.getBlockState(blockPos.below()); 26 | if (blockstate.getBlock() instanceof NoteBlock) { 27 | return; 28 | } 29 | BlockEntity blockEntity = level.getBlockEntity(blockPos); 30 | if (!(blockEntity instanceof ChannelHolder channelHolder)) { 31 | return; 32 | } 33 | UUID channelID = channelHolder.audioplayer$getChannelID(); 34 | if (channelID != null) { 35 | PlayerManager.instance().stop(channelID); 36 | } 37 | channelHolder.audioplayer$setChannelID(null); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/BlockMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.CustomSound; 4 | import de.maxhenkel.audioplayer.interfaces.CustomSoundHolder; 5 | import net.minecraft.core.BlockPos; 6 | import net.minecraft.server.level.ServerLevel; 7 | import net.minecraft.world.entity.Entity; 8 | import net.minecraft.world.item.BlockItem; 9 | import net.minecraft.world.item.ItemStack; 10 | import net.minecraft.world.level.block.Block; 11 | import net.minecraft.world.level.block.SkullBlock; 12 | import net.minecraft.world.level.block.entity.BlockEntity; 13 | import net.minecraft.world.level.block.state.BlockState; 14 | import org.jetbrains.annotations.Nullable; 15 | import org.spongepowered.asm.mixin.Mixin; 16 | import org.spongepowered.asm.mixin.Unique; 17 | import org.spongepowered.asm.mixin.injection.At; 18 | import org.spongepowered.asm.mixin.injection.Inject; 19 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 20 | 21 | import java.util.List; 22 | 23 | @Mixin(Block.class) 24 | public class BlockMixin { 25 | 26 | @Inject(method = "getDrops(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/entity/BlockEntity;)Ljava/util/List;", at = @At(value = "RETURN"), cancellable = true) 27 | private static void getDrops(BlockState blockState, ServerLevel serverLevel, BlockPos blockPos, @Nullable BlockEntity blockEntity, CallbackInfoReturnable> ci) { 28 | getDropsInternal(blockState, serverLevel, blockPos, blockEntity, ci); 29 | } 30 | 31 | @Inject(method = "getDrops(Lnet/minecraft/world/level/block/state/BlockState;Lnet/minecraft/server/level/ServerLevel;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/entity/BlockEntity;Lnet/minecraft/world/entity/Entity;Lnet/minecraft/world/item/ItemStack;)Ljava/util/List;", at = @At(value = "RETURN"), cancellable = true) 32 | private static void getDrops(BlockState blockState, ServerLevel serverLevel, BlockPos blockPos, @Nullable BlockEntity blockEntity, @Nullable Entity entity, ItemStack itemStack, CallbackInfoReturnable> ci) { 33 | getDropsInternal(blockState, serverLevel, blockPos, blockEntity, ci); 34 | } 35 | 36 | @Unique 37 | private static void getDropsInternal(BlockState blockState, ServerLevel serverLevel, BlockPos blockPos, @Nullable BlockEntity blockEntity, CallbackInfoReturnable> ci) { 38 | if (!(blockState.getBlock() instanceof SkullBlock)) { 39 | return; 40 | } 41 | if (!(blockEntity instanceof CustomSoundHolder customSoundHolder)) { 42 | return; 43 | } 44 | CustomSound customSound = customSoundHolder.audioplayer$getCustomSound(); 45 | if (customSound == null) { 46 | return; 47 | } 48 | 49 | List result = ci.getReturnValue(); 50 | 51 | for (ItemStack stack : result) { 52 | if (!(stack.getItem() instanceof BlockItem blockItem)) { 53 | continue; 54 | } 55 | if (!(blockItem.getBlock() instanceof SkullBlock)) { 56 | continue; 57 | } 58 | customSound.saveToItem(stack); 59 | } 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/InstrumentItemMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.*; 4 | import net.minecraft.core.component.DataComponents; 5 | import net.minecraft.server.level.ServerLevel; 6 | import net.minecraft.server.level.ServerPlayer; 7 | import net.minecraft.world.InteractionHand; 8 | import net.minecraft.world.InteractionResult; 9 | import net.minecraft.world.entity.player.Player; 10 | import net.minecraft.world.item.InstrumentItem; 11 | import net.minecraft.world.item.ItemStack; 12 | import net.minecraft.world.level.Level; 13 | import net.minecraft.world.level.gameevent.GameEvent; 14 | import org.spongepowered.asm.mixin.Mixin; 15 | import org.spongepowered.asm.mixin.injection.At; 16 | import org.spongepowered.asm.mixin.injection.Inject; 17 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 18 | 19 | import java.util.UUID; 20 | 21 | @Mixin(InstrumentItem.class) 22 | public class InstrumentItemMixin { 23 | 24 | @Inject(method = "use", at = @At(value = "HEAD"), cancellable = true) 25 | private void useOn(Level level, Player p, InteractionHand interactionHand, CallbackInfoReturnable ci) { 26 | ItemStack itemInHand = p.getItemInHand(interactionHand); 27 | CustomSound customSound = CustomSound.of(itemInHand); 28 | if (customSound == null) { 29 | return; 30 | } 31 | if (!(p instanceof ServerPlayer player)) { 32 | ci.setReturnValue(InteractionResult.CONSUME); 33 | return; 34 | } 35 | itemInHand.set(DataComponents.INSTRUMENT, ComponentUtils.EMPTY_INSTRUMENT); 36 | UUID channel = AudioManager.play((ServerLevel) level, p.blockPosition(), PlayerType.GOAT_HORN, customSound, player); 37 | if (channel == null) { 38 | return; 39 | } 40 | player.startUsingItem(interactionHand); 41 | player.getCooldowns().addCooldown(itemInHand, AudioPlayer.SERVER_CONFIG.goatHornCooldown.get()); 42 | level.gameEvent(GameEvent.INSTRUMENT_PLAY, player.position(), GameEvent.Context.of(player)); 43 | ci.setReturnValue(InteractionResult.CONSUME); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/JukeboxBlockEntityMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.interfaces.CustomJukeboxSongPlayer; 4 | import net.minecraft.core.BlockPos; 5 | import net.minecraft.core.Holder; 6 | import net.minecraft.core.HolderLookup; 7 | import net.minecraft.nbt.CompoundTag; 8 | import net.minecraft.server.level.ServerLevel; 9 | import net.minecraft.world.item.ItemStack; 10 | import net.minecraft.world.item.JukeboxSong; 11 | import net.minecraft.world.item.JukeboxSongPlayer; 12 | import net.minecraft.world.level.LevelAccessor; 13 | import net.minecraft.world.level.block.entity.BlockEntity; 14 | import net.minecraft.world.level.block.entity.BlockEntityType; 15 | import net.minecraft.world.level.block.entity.JukeboxBlockEntity; 16 | import net.minecraft.world.level.block.state.BlockState; 17 | import org.spongepowered.asm.mixin.Final; 18 | import org.spongepowered.asm.mixin.Mixin; 19 | import org.spongepowered.asm.mixin.Shadow; 20 | import org.spongepowered.asm.mixin.injection.At; 21 | import org.spongepowered.asm.mixin.injection.Inject; 22 | import org.spongepowered.asm.mixin.injection.Redirect; 23 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 24 | 25 | @Mixin(JukeboxBlockEntity.class) 26 | public abstract class JukeboxBlockEntityMixin extends BlockEntity { 27 | 28 | @Shadow 29 | private ItemStack item; 30 | 31 | @Shadow 32 | @Final 33 | private JukeboxSongPlayer jukeboxSongPlayer; 34 | 35 | public JukeboxBlockEntityMixin(BlockEntityType blockEntityType, BlockPos blockPos, BlockState blockState) { 36 | super(blockEntityType, blockPos, blockState); 37 | } 38 | 39 | @Redirect(method = "setTheItem", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/item/JukeboxSongPlayer;play(Lnet/minecraft/world/level/LevelAccessor;Lnet/minecraft/core/Holder;)V")) 40 | public void play(JukeboxSongPlayer instance, LevelAccessor levelAccessor, Holder holder) { 41 | if (!(levelAccessor instanceof ServerLevel serverLevel)) { 42 | return; 43 | } 44 | if (!(jukeboxSongPlayer instanceof CustomJukeboxSongPlayer customJukeboxSongPlayer)) { 45 | return; 46 | } 47 | boolean custom = customJukeboxSongPlayer.audioplayer$customPlay(serverLevel, item); 48 | if (!custom) { 49 | instance.play(levelAccessor, holder); 50 | } 51 | } 52 | 53 | @Redirect(method = "setTheItem", at = @At(value = "INVOKE", target = "Lnet/minecraft/world/item/JukeboxSongPlayer;stop(Lnet/minecraft/world/level/LevelAccessor;Lnet/minecraft/world/level/block/state/BlockState;)V")) 54 | public void stop(JukeboxSongPlayer instance, LevelAccessor levelAccessor, BlockState blockState) { 55 | if (!(jukeboxSongPlayer instanceof CustomJukeboxSongPlayer customJukeboxSongPlayer)) { 56 | return; 57 | } 58 | boolean custom = customJukeboxSongPlayer.audioplayer$customStop(); 59 | if (!custom) { 60 | instance.stop(levelAccessor, blockState); 61 | } 62 | } 63 | 64 | @Inject(method = "loadAdditional", at = @At(value = "RETURN")) 65 | public void load(CompoundTag compound, HolderLookup.Provider provider, CallbackInfo ci) { 66 | if (!(jukeboxSongPlayer instanceof CustomJukeboxSongPlayer customJukeboxSongPlayer)) { 67 | return; 68 | } 69 | customJukeboxSongPlayer.audioplayer$onLoad(item, compound, provider); 70 | } 71 | 72 | @Inject(method = "saveAdditional", at = @At(value = "RETURN")) 73 | public void save(CompoundTag compound, HolderLookup.Provider provider, CallbackInfo ci) { 74 | if (!(jukeboxSongPlayer instanceof CustomJukeboxSongPlayer customJukeboxSongPlayer)) { 75 | return; 76 | } 77 | customJukeboxSongPlayer.audioplayer$onSave(item, compound, provider); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/JukeboxSongPlayerMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.AudioManager; 4 | import de.maxhenkel.audioplayer.CustomSound; 5 | import de.maxhenkel.audioplayer.PlayerManager; 6 | import de.maxhenkel.audioplayer.PlayerType; 7 | import de.maxhenkel.audioplayer.interfaces.CustomJukeboxSongPlayer; 8 | import net.minecraft.core.BlockPos; 9 | import net.minecraft.core.Holder; 10 | import net.minecraft.core.HolderLookup; 11 | import net.minecraft.core.UUIDUtil; 12 | import net.minecraft.nbt.CompoundTag; 13 | import net.minecraft.server.level.ServerLevel; 14 | import net.minecraft.world.item.ItemStack; 15 | import net.minecraft.world.item.JukeboxSong; 16 | import net.minecraft.world.item.JukeboxSongPlayer; 17 | import net.minecraft.world.level.LevelAccessor; 18 | import net.minecraft.world.level.block.state.BlockState; 19 | import org.spongepowered.asm.mixin.Final; 20 | import org.spongepowered.asm.mixin.Mixin; 21 | import org.spongepowered.asm.mixin.Shadow; 22 | import org.spongepowered.asm.mixin.Unique; 23 | import org.spongepowered.asm.mixin.injection.At; 24 | import org.spongepowered.asm.mixin.injection.Inject; 25 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 26 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 27 | 28 | import javax.annotation.Nullable; 29 | import java.util.UUID; 30 | 31 | @Mixin(JukeboxSongPlayer.class) 32 | public abstract class JukeboxSongPlayerMixin implements CustomJukeboxSongPlayer { 33 | 34 | @Shadow 35 | @Nullable 36 | private Holder song; 37 | @Shadow 38 | @Final 39 | private JukeboxSongPlayer.OnSongChanged onSongChanged; 40 | 41 | @Shadow 42 | private long ticksSinceSongStarted; 43 | 44 | @Shadow 45 | @Final 46 | private BlockPos blockPos; 47 | 48 | @Unique 49 | @Nullable 50 | private UUID channelId; 51 | 52 | @Override 53 | public boolean audioplayer$customPlay(ServerLevel level, ItemStack item) { 54 | CustomSound customSound = CustomSound.of(item); 55 | if (customSound == null) { 56 | return false; 57 | } 58 | UUID channel = AudioManager.play(level, blockPos, PlayerType.MUSIC_DISC, customSound, null); 59 | if (channel == null) { 60 | return false; 61 | } 62 | channelId = channel; 63 | song = null; 64 | ticksSinceSongStarted = 0L; 65 | onSongChanged.notifyChange(); 66 | return true; 67 | } 68 | 69 | @Override 70 | public boolean audioplayer$customStop() { 71 | if (channelId == null) { 72 | return false; 73 | } 74 | PlayerManager.instance().stop(channelId); 75 | channelId = null; 76 | song = null; 77 | ticksSinceSongStarted = 0L; 78 | onSongChanged.notifyChange(); 79 | return true; 80 | } 81 | 82 | @Inject(method = "isPlaying", at = @At(value = "HEAD"), cancellable = true) 83 | public void isPlaying(CallbackInfoReturnable cir) { 84 | if (channelId == null) { 85 | return; 86 | } 87 | cir.setReturnValue(PlayerManager.instance().isPlaying(channelId)); 88 | } 89 | 90 | @Inject(method = "tick", at = @At(value = "HEAD"), cancellable = true) 91 | public void tick(LevelAccessor levelAccessor, BlockState blockState, CallbackInfo ci) { 92 | if (channelId == null) { 93 | return; 94 | } 95 | ci.cancel(); 96 | if (!isPlaying()) { 97 | if (channelId != null) { 98 | audioplayer$customStop(); 99 | } 100 | return; 101 | } 102 | 103 | if (shouldEmitJukeboxPlayingEvent()) { 104 | spawnMusicParticles(levelAccessor, blockPos); 105 | } 106 | ticksSinceSongStarted++; 107 | } 108 | 109 | @Override 110 | public void audioplayer$onSave(ItemStack item, CompoundTag compound, HolderLookup.Provider provider) { 111 | if (channelId != null && !item.isEmpty()) { 112 | compound.store("ChannelID", UUIDUtil.CODEC, channelId); 113 | } 114 | } 115 | 116 | @Override 117 | public void audioplayer$onLoad(ItemStack item, CompoundTag compound, HolderLookup.Provider provider) { 118 | UUID id = compound.read("ChannelID", UUIDUtil.CODEC).orElse(null); 119 | if (id != null && !item.isEmpty()) { 120 | channelId = id; 121 | song = null; 122 | } else { 123 | channelId = null; 124 | } 125 | } 126 | 127 | @Shadow 128 | public abstract boolean isPlaying(); 129 | 130 | @Shadow 131 | protected abstract boolean shouldEmitJukeboxPlayingEvent(); 132 | 133 | @Shadow 134 | private static void spawnMusicParticles(LevelAccessor levelAccessor, BlockPos blockPos) { 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/NoteBlockMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.AudioManager; 4 | import de.maxhenkel.audioplayer.CustomSound; 5 | import de.maxhenkel.audioplayer.PlayerManager; 6 | import de.maxhenkel.audioplayer.PlayerType; 7 | import de.maxhenkel.audioplayer.interfaces.ChannelHolder; 8 | import de.maxhenkel.audioplayer.interfaces.CustomSoundHolder; 9 | import net.minecraft.core.BlockPos; 10 | import net.minecraft.server.level.ServerLevel; 11 | import net.minecraft.world.level.Level; 12 | import net.minecraft.world.level.LevelAccessor; 13 | import net.minecraft.world.level.block.Block; 14 | import net.minecraft.world.level.block.NoteBlock; 15 | import net.minecraft.world.level.block.entity.BlockEntity; 16 | import net.minecraft.world.level.block.state.BlockState; 17 | import org.spongepowered.asm.mixin.Mixin; 18 | import org.spongepowered.asm.mixin.injection.At; 19 | import org.spongepowered.asm.mixin.injection.Inject; 20 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable; 21 | 22 | import java.util.UUID; 23 | 24 | @Mixin(NoteBlock.class) 25 | public class NoteBlockMixin extends Block { 26 | 27 | public NoteBlockMixin(Properties properties) { 28 | super(properties); 29 | } 30 | 31 | @Inject(method = "triggerEvent", at = @At(value = "HEAD"), cancellable = true) 32 | public void triggerEvent(BlockState blockState, Level level, BlockPos blockPos, int i, int j, CallbackInfoReturnable cir) { 33 | if (!(level instanceof ServerLevel serverLevel)) { 34 | return; 35 | } 36 | BlockEntity blockEntity = level.getBlockEntity(blockPos.above()); 37 | if (!(blockEntity instanceof CustomSoundHolder soundHolder)) { 38 | return; 39 | } 40 | if (!(blockEntity instanceof ChannelHolder channelHolder)) { 41 | return; 42 | } 43 | CustomSound customSound = soundHolder.audioplayer$getCustomSound(); 44 | if (customSound == null) { 45 | return; 46 | } 47 | UUID channelId = channelHolder.audioplayer$getChannelID(); 48 | if (channelId != null && PlayerManager.instance().isPlaying(channelId)) { 49 | PlayerManager.instance().stop(channelId); 50 | channelHolder.audioplayer$setChannelID(null); 51 | } 52 | 53 | UUID channel = AudioManager.play(serverLevel, blockPos, PlayerType.NOTE_BLOCK, customSound, null); 54 | 55 | if (channel != null) { 56 | channelHolder.audioplayer$setChannelID(channel); 57 | cir.setReturnValue(true); 58 | } 59 | } 60 | 61 | @Override 62 | public void destroy(LevelAccessor levelAccessor, BlockPos blockPos, BlockState blockState) { 63 | BlockEntity blockEntity = levelAccessor.getBlockEntity(blockPos.above()); 64 | if (blockEntity instanceof ChannelHolder channelHolder) { 65 | UUID channelID = channelHolder.audioplayer$getChannelID(); 66 | if (channelID != null) { 67 | PlayerManager.instance().stop(channelID); 68 | } 69 | channelHolder.audioplayer$setChannelID(null); 70 | } 71 | super.destroy(levelAccessor, blockPos, blockState); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/mixin/SkullBlockEntityMixin.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.mixin; 2 | 3 | import de.maxhenkel.audioplayer.CustomSound; 4 | import de.maxhenkel.audioplayer.PlayerManager; 5 | import de.maxhenkel.audioplayer.interfaces.ChannelHolder; 6 | import de.maxhenkel.audioplayer.interfaces.CustomSoundHolder; 7 | import net.minecraft.core.BlockPos; 8 | import net.minecraft.core.HolderLookup; 9 | import net.minecraft.core.UUIDUtil; 10 | import net.minecraft.nbt.CompoundTag; 11 | import net.minecraft.world.level.block.entity.BlockEntity; 12 | import net.minecraft.world.level.block.entity.BlockEntityType; 13 | import net.minecraft.world.level.block.entity.SkullBlockEntity; 14 | import net.minecraft.world.level.block.state.BlockState; 15 | import org.spongepowered.asm.mixin.Mixin; 16 | import org.spongepowered.asm.mixin.Unique; 17 | import org.spongepowered.asm.mixin.injection.At; 18 | import org.spongepowered.asm.mixin.injection.Inject; 19 | import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; 20 | 21 | import javax.annotation.Nullable; 22 | import java.util.UUID; 23 | 24 | @Mixin(SkullBlockEntity.class) 25 | public class SkullBlockEntityMixin extends BlockEntity implements CustomSoundHolder, ChannelHolder { 26 | 27 | @Unique 28 | @Nullable 29 | private UUID channelID; 30 | 31 | @Unique 32 | @Nullable 33 | private CustomSound customSound; 34 | 35 | public SkullBlockEntityMixin(BlockEntityType blockEntityType, BlockPos blockPos, BlockState blockState) { 36 | super(blockEntityType, blockPos, blockState); 37 | } 38 | 39 | @Nullable 40 | @Override 41 | public UUID audioplayer$getChannelID() { 42 | return channelID; 43 | } 44 | 45 | @Override 46 | public void audioplayer$setChannelID(@Nullable UUID channelID) { 47 | this.channelID = channelID; 48 | setChanged(); 49 | } 50 | 51 | @Nullable 52 | @Override 53 | public CustomSound audioplayer$getCustomSound() { 54 | return customSound; 55 | } 56 | 57 | @Inject(method = "saveAdditional", at = @At("RETURN")) 58 | private void saveAdditional(CompoundTag tag, HolderLookup.Provider provider, CallbackInfo ci) { 59 | if (channelID != null) { 60 | tag.store("ChannelID", UUIDUtil.CODEC, channelID); 61 | } 62 | if (customSound != null) { 63 | customSound.saveToNbt(tag); 64 | } 65 | } 66 | 67 | @Inject(method = "loadAdditional", at = @At("RETURN")) 68 | private void load(CompoundTag tag, HolderLookup.Provider provider, CallbackInfo ci) { 69 | channelID = tag.read("ChannelID", UUIDUtil.CODEC).orElse(null); 70 | customSound = CustomSound.of(tag); 71 | } 72 | 73 | @Override 74 | public void setRemoved() { 75 | if (channelID != null) { 76 | PlayerManager.instance().stop(channelID); 77 | } 78 | super.setRemoved(); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/webserver/StaticFileCache.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.webserver; 2 | 3 | import javax.annotation.Nullable; 4 | import java.io.IOException; 5 | import java.net.URISyntaxException; 6 | import java.net.URL; 7 | import java.nio.file.Files; 8 | import java.nio.file.Path; 9 | import java.nio.file.Paths; 10 | import java.util.*; 11 | import java.util.stream.Stream; 12 | 13 | public class StaticFileCache { 14 | 15 | private final Map cache; 16 | 17 | public StaticFileCache(Map cache) { 18 | this.cache = cache; 19 | } 20 | 21 | @Nullable 22 | public byte[] get(String path) { 23 | return cache.get(path); 24 | } 25 | 26 | public static StaticFileCache of(String resourceFolder) throws IOException, URISyntaxException { 27 | Map cache = new HashMap<>(); 28 | 29 | URL url = StaticFileCache.class.getClassLoader().getResource(resourceFolder); 30 | if (url == null) { 31 | throw new IOException("Resource not found: %s".formatted(resourceFolder)); 32 | } 33 | Path root = Paths.get(url.toURI()); 34 | 35 | List resources = getRecursive(root); 36 | 37 | for (Path path : resources) { 38 | cache.put(pathToString(root.relativize(path)), Files.readAllBytes(path)); 39 | } 40 | 41 | return new StaticFileCache(cache); 42 | } 43 | 44 | private static String pathToString(Path path) { 45 | StringBuilder sb = new StringBuilder(); 46 | for (Path p : path) { 47 | sb.append("/"); 48 | sb.append(p); 49 | } 50 | if (sb.isEmpty()) { 51 | sb.append("/"); 52 | } 53 | return sb.toString(); 54 | } 55 | 56 | private static List getRecursive(Path path) throws IOException { 57 | if (!Files.exists(path)) { 58 | return Collections.emptyList(); 59 | } 60 | if (!Files.isDirectory(path)) { 61 | return List.of(path); 62 | } 63 | try (Stream stream = Files.list(path)) { 64 | List contents = stream.toList(); 65 | List paths = new ArrayList<>(); 66 | for (Path file : contents) { 67 | paths.addAll(getRecursive(file)); 68 | } 69 | return paths; 70 | } 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/webserver/TokenManager.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.webserver; 2 | 3 | import de.maxhenkel.audioplayer.AudioPlayer; 4 | 5 | import javax.annotation.Nullable; 6 | import java.util.Map; 7 | import java.util.UUID; 8 | import java.util.concurrent.ConcurrentHashMap; 9 | 10 | public class TokenManager { 11 | 12 | private final Map tokens; 13 | 14 | public TokenManager() { 15 | tokens = new ConcurrentHashMap<>(); 16 | } 17 | 18 | public UUID generateToken(UUID playerId) { 19 | UUID token = UUID.randomUUID(); 20 | tokens.put(token, new Token(token, playerId)); 21 | return token; 22 | } 23 | 24 | /** 25 | * @param token the token 26 | * @return the player ID or null if the token is invalid 27 | */ 28 | @Nullable 29 | public UUID useToken(UUID token) { 30 | Token t = tokens.get(token); 31 | if (t == null) { 32 | return null; 33 | } 34 | tokens.remove(token); 35 | if (!t.isValid()) { 36 | return null; 37 | } 38 | return t.getPlayerId(); 39 | } 40 | 41 | public boolean isValidToken(UUID token) { 42 | Token t = tokens.get(token); 43 | if (t == null) { 44 | return false; 45 | } 46 | return t.isValid(); 47 | } 48 | 49 | //TODO Clean tokens regularly 50 | public void cleanInvalidTokens() { 51 | tokens.values().removeIf(token -> !token.isValid()); 52 | } 53 | 54 | protected static class Token { 55 | private final UUID token; 56 | private final UUID playerId; 57 | private final long time; 58 | 59 | public Token(UUID token, UUID playerId) { 60 | this.token = token; 61 | this.playerId = playerId; 62 | this.time = System.currentTimeMillis(); 63 | } 64 | 65 | public UUID getToken() { 66 | return token; 67 | } 68 | 69 | public UUID getPlayerId() { 70 | return playerId; 71 | } 72 | 73 | public long getTime() { 74 | return time; 75 | } 76 | 77 | public boolean isValid() { 78 | return System.currentTimeMillis() - time <= AudioPlayer.WEB_SERVER_CONFIG.tokenTimeout.get(); 79 | } 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/webserver/UrlUtils.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.webserver; 2 | 3 | import de.maxhenkel.audioplayer.AudioPlayer; 4 | 5 | import javax.annotation.Nullable; 6 | import java.net.MalformedURLException; 7 | import java.net.URI; 8 | import java.net.URL; 9 | import java.util.UUID; 10 | 11 | public class UrlUtils { 12 | 13 | @Nullable 14 | public static URI generateUploadUrl(UUID token) { 15 | String urlString = AudioPlayer.WEB_SERVER_CONFIG.url.get(); 16 | 17 | if (urlString.isBlank()) { 18 | return null; 19 | } 20 | 21 | URL url; 22 | try { 23 | url = new URL(urlString); 24 | } catch (MalformedURLException e) { 25 | AudioPlayer.LOGGER.error("Invalid web server URL: {}", urlString); 26 | return null; 27 | } 28 | 29 | StringBuilder finalUrl = new StringBuilder(); 30 | if (url.getProtocol() == null || url.getProtocol().isEmpty() || url.getProtocol().equals("http")) { 31 | finalUrl.append("http"); 32 | } else if (url.getProtocol().equals("https")) { 33 | finalUrl.append("https"); 34 | } else { 35 | AudioPlayer.LOGGER.error("Invalid web server URL protocol: {}", url.getProtocol()); 36 | return null; 37 | } 38 | finalUrl.append("://"); 39 | if (url.getHost().isEmpty()) { 40 | AudioPlayer.LOGGER.error("Invalid web server URL host: {}", url.getHost()); 41 | return null; 42 | } 43 | finalUrl.append(url.getHost()); 44 | if (url.getPort() != -1) { 45 | finalUrl.append(":"); 46 | finalUrl.append(url.getPort()); 47 | } 48 | 49 | finalUrl.append("?token="); 50 | finalUrl.append(token.toString()); 51 | 52 | return URI.create(finalUrl.toString()); 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/webserver/WebServer.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.webserver; 2 | 3 | import de.maxhenkel.audioplayer.AudioManager; 4 | import de.maxhenkel.audioplayer.AudioPlayer; 5 | import de.maxhenkel.audioplayer.command.UploadCommands; 6 | import net.minecraft.network.chat.Component; 7 | import net.minecraft.server.MinecraftServer; 8 | import net.minecraft.server.level.ServerPlayer; 9 | import org.microhttp.*; 10 | 11 | import javax.annotation.Nullable; 12 | import java.time.Duration; 13 | import java.util.*; 14 | import java.util.function.Consumer; 15 | 16 | public class WebServer implements AutoCloseable { 17 | 18 | protected final MinecraftServer minecraftServer; 19 | protected final TokenManager tokenManager; 20 | @Nullable 21 | protected EventLoop eventLoop; 22 | protected int port; 23 | @Nullable 24 | protected StaticFileCache staticFileCache; 25 | 26 | protected WebServer(MinecraftServer minecraftServer) { 27 | this.minecraftServer = minecraftServer; 28 | tokenManager = new TokenManager(); 29 | } 30 | 31 | public static WebServer create(MinecraftServer minecraftServer) { 32 | return new WebServer(minecraftServer); 33 | } 34 | 35 | public WebServer start() throws Exception { 36 | port = AudioPlayer.WEB_SERVER_CONFIG.port.get(); 37 | Options options = Options.builder() 38 | .withPort(port) 39 | .withHost(null) 40 | .withRequestTimeout(Duration.ofSeconds(60)) 41 | .withConcurrency(1) 42 | .withMaxRequestSize(AudioPlayer.SERVER_CONFIG.maxUploadSize.get().intValue()) 43 | .build(); 44 | 45 | staticFileCache = StaticFileCache.of("web"); 46 | 47 | eventLoop = new EventLoop(options, NoopLogger.instance(), this::handleRequest); 48 | eventLoop.start(); 49 | 50 | return this; 51 | } 52 | 53 | private void handleRequest(Request request, Consumer responseConsumer) { 54 | if (!handleAuth(request, responseConsumer)) { 55 | return; 56 | } 57 | String path = request.uri(); 58 | if (path.startsWith("/upload")) { 59 | handleUpload(request, responseConsumer); 60 | } else { 61 | handleServeStatic(request, responseConsumer); 62 | } 63 | } 64 | 65 | private boolean handleAuth(Request request, Consumer responseConsumer) { 66 | String username = AudioPlayer.WEB_SERVER_CONFIG.authUsername.get(); 67 | String password = AudioPlayer.WEB_SERVER_CONFIG.authPassword.get(); 68 | if (username.isBlank() || password.isBlank()) { 69 | return true; 70 | } 71 | String authHeader = request.header("Authorization"); 72 | if (authHeader != null && authHeader.startsWith("Basic ")) { 73 | String encodedCredentials = authHeader.substring("Basic ".length()).trim(); 74 | String credentials = new String(Base64.getDecoder().decode(encodedCredentials)); 75 | 76 | if (credentials.equals(username + ":" + password)) { 77 | return true; 78 | } 79 | } 80 | responseConsumer.accept( 81 | new Response( 82 | 401, 83 | "UNAUTHORIZED", 84 | List.of( 85 | new Header("Content-Type", "text/plain"), 86 | new Header("WWW-Authenticate", "Basic realm=\"Restricted Area\"") 87 | ), 88 | "Unauthorized\n".getBytes() 89 | ) 90 | ); 91 | return false; 92 | } 93 | 94 | private void handleUpload(Request request, Consumer responseConsumer) { 95 | List
headers = new ArrayList<>(); 96 | headers.add(new Header("Access-Control-Allow-Origin", "*")); 97 | headers.add(new Header("Access-Control-Allow-Methods", "*")); 98 | headers.add(new Header("Access-Control-Allow-Headers", "*")); 99 | if (request.method().equalsIgnoreCase("OPTIONS")) { 100 | responseConsumer.accept( 101 | new Response( 102 | 204, 103 | "NO CONTENT", 104 | headers, 105 | "".getBytes() 106 | ) 107 | ); 108 | return; 109 | } 110 | if (!request.method().equalsIgnoreCase("POST")) { 111 | responseConsumer.accept( 112 | new Response( 113 | 400, 114 | "BAD REQUEST", 115 | headers, 116 | "Bad request".getBytes() 117 | ) 118 | ); 119 | return; 120 | } 121 | String tokenValue = request.header("token"); 122 | if (tokenValue == null) { 123 | responseConsumer.accept( 124 | new Response( 125 | 401, 126 | "UNAUTHORIZED", 127 | headers, 128 | "Unauthorized\n".getBytes() 129 | ) 130 | ); 131 | return; 132 | } 133 | UUID token; 134 | try { 135 | token = UUID.fromString(tokenValue); 136 | } catch (IllegalArgumentException e) { 137 | responseConsumer.accept( 138 | new Response( 139 | 400, 140 | "BAD REQUEST", 141 | headers, 142 | "Bad request".getBytes() 143 | ) 144 | ); 145 | return; 146 | } 147 | UUID playerId = tokenManager.useToken(token); 148 | if (playerId == null) { 149 | responseConsumer.accept( 150 | new Response( 151 | 401, 152 | "UNAUTHORIZED", 153 | headers, 154 | "Unauthorized\n".getBytes() 155 | ) 156 | ); 157 | return; 158 | } 159 | byte[] data = request.body(); 160 | 161 | if (data.length > AudioPlayer.SERVER_CONFIG.maxUploadSize.get()) { 162 | responseConsumer.accept( 163 | new Response( 164 | 414, 165 | "TOO LONG", 166 | headers, 167 | "Too long\n".getBytes() 168 | ) 169 | ); 170 | return; 171 | } 172 | 173 | upload(playerId, token, data); 174 | responseConsumer.accept( 175 | new Response( 176 | 200, 177 | "OK", 178 | headers, 179 | "".getBytes() 180 | ) 181 | ); 182 | } 183 | 184 | private void handleServeStatic(Request request, Consumer responseConsumer) { 185 | if (!request.method().equalsIgnoreCase("GET")) { 186 | responseConsumer.accept( 187 | new Response( 188 | 400, 189 | "BAD REQUEST", 190 | List.of(), 191 | "Bad Request\n".getBytes() 192 | ) 193 | ); 194 | return; 195 | } 196 | String requestedResource = request.uri().split("\\?")[0]; 197 | 198 | if (requestedResource.equals("/")) { 199 | requestedResource = "/index.html"; 200 | } 201 | 202 | byte[] data = staticFileCache.get(requestedResource); 203 | 204 | if (data == null) { 205 | responseConsumer.accept( 206 | new Response( 207 | 400, 208 | "BAD REQUEST", 209 | List.of(new Header("Content-Type", "text/plain")), 210 | "Bad Request\n".getBytes() 211 | ) 212 | ); 213 | return; 214 | } 215 | String mimeType = getMimeType(requestedResource); 216 | responseConsumer.accept( 217 | new Response( 218 | 200, 219 | "OK", 220 | mimeType != null ? List.of(new Header("Content-Type", "%s; charset=UTF-8".formatted(mimeType))) : List.of(), 221 | data 222 | ) 223 | ); 224 | } 225 | 226 | /** 227 | * @return the port the webserver is running on or -1 if not running 228 | */ 229 | public int getPort() { 230 | return eventLoop != null ? port : -1; 231 | } 232 | 233 | public TokenManager getTokenManager() { 234 | return tokenManager; 235 | } 236 | 237 | public MinecraftServer getMinecraftServer() { 238 | return minecraftServer; 239 | } 240 | 241 | @Override 242 | public void close() { 243 | if (eventLoop != null) { 244 | eventLoop.stop(); 245 | eventLoop = null; 246 | } 247 | } 248 | 249 | private static final Map MIME_TYPES = Map.of( 250 | "html", "text/html", 251 | "css", "text/css", 252 | "js", "application/javascript", 253 | "ico", "image/x-icon" 254 | ); 255 | 256 | @Nullable 257 | private static String getMimeType(String path) { 258 | int lastSlashIndex = path.lastIndexOf('/'); 259 | if (lastSlashIndex >= 0) { 260 | path = path.substring(lastSlashIndex + 1); 261 | } 262 | int lastDotIndex = path.lastIndexOf('.'); 263 | if (lastDotIndex < 0) { 264 | return null; 265 | } 266 | String extension = path.substring(lastDotIndex + 1); 267 | return MIME_TYPES.get(extension); 268 | } 269 | 270 | private void upload(UUID playerId, UUID token, byte[] audioData) { 271 | ServerPlayer player = minecraftServer.getPlayerList().getPlayer(playerId); 272 | if (player == null) { 273 | return; 274 | } 275 | new Thread(() -> { 276 | try { 277 | AudioManager.saveSound(minecraftServer, token, null, audioData); //TODO File name 278 | player.sendSystemMessage(UploadCommands.sendUUIDMessage(token, Component.literal("Successfully uploaded sound."))); 279 | } catch (Exception e) { 280 | AudioPlayer.LOGGER.warn("{} failed to upload a sound: {}", player.getName().getString(), e.getMessage()); 281 | player.sendSystemMessage(Component.literal("Failed to upload sound: %s".formatted(e.getMessage()))); 282 | } 283 | }).start(); 284 | } 285 | 286 | } 287 | -------------------------------------------------------------------------------- /src/main/java/de/maxhenkel/audioplayer/webserver/WebServerEvents.java: -------------------------------------------------------------------------------- 1 | package de.maxhenkel.audioplayer.webserver; 2 | 3 | import de.maxhenkel.audioplayer.AudioPlayer; 4 | import net.minecraft.server.MinecraftServer; 5 | 6 | import javax.annotation.Nullable; 7 | 8 | public class WebServerEvents { 9 | 10 | @Nullable 11 | private static WebServer webServer; 12 | 13 | public static void onServerStarted(MinecraftServer server) { 14 | closeServerIfRunning(); 15 | if (!AudioPlayer.SERVER_CONFIG.runWebServer.get()) { 16 | return; 17 | } 18 | try { 19 | webServer = WebServer.create(server).start(); 20 | AudioPlayer.LOGGER.info("Audio player upload web server started on port {}", webServer.getPort()); 21 | } catch (Exception e) { 22 | AudioPlayer.LOGGER.error("Failed to start web server", e); 23 | } 24 | } 25 | 26 | public static void onServerStopped(MinecraftServer server) { 27 | if (webServer != null) { 28 | AudioPlayer.LOGGER.info("Audio player upload web server stopped"); 29 | } 30 | closeServerIfRunning(); 31 | } 32 | 33 | private static void closeServerIfRunning() { 34 | if (webServer != null) { 35 | webServer.close(); 36 | webServer = null; 37 | } 38 | } 39 | 40 | public static boolean isRunning() { 41 | return webServer != null; 42 | } 43 | 44 | @Nullable 45 | public static WebServer getWebServer() { 46 | return webServer; 47 | } 48 | 49 | } 50 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/javax.sound.sampled.spi.AudioFileReader: -------------------------------------------------------------------------------- 1 | de.maxhenkel.audioplayer.javazoom.spi.mpeg.sampled.file.MpegAudioFileReader 2 | -------------------------------------------------------------------------------- /src/main/resources/META-INF/services/javax.sound.sampled.spi.FormatConversionProvider: -------------------------------------------------------------------------------- 1 | de.maxhenkel.audioplayer.javazoom.spi.mpeg.sampled.convert.MpegFormatConversionProvider 2 | -------------------------------------------------------------------------------- /src/main/resources/audioplayer.accesswidener: -------------------------------------------------------------------------------- 1 | accessWidener v1 named 2 | 3 | accessible method net/minecraft/world/level/storage/LevelResource (Ljava/lang/String;)V 4 | accessible method net/minecraft/world/item/context/UseOnContext (Lnet/minecraft/world/level/Level;Lnet/minecraft/world/entity/player/Player;Lnet/minecraft/world/InteractionHand;Lnet/minecraft/world/item/ItemStack;Lnet/minecraft/world/phys/BlockHitResult;)V -------------------------------------------------------------------------------- /src/main/resources/audioplayer.mixins.json: -------------------------------------------------------------------------------- 1 | { 2 | "required": true, 3 | "minVersion": "0.8", 4 | "package": "de.maxhenkel.audioplayer.mixin", 5 | "compatibilityLevel": "JAVA_17", 6 | "mixins": [ 7 | "JukeboxBlockEntityMixin", 8 | "InstrumentItemMixin", 9 | "NoteBlockMixin", 10 | "SkullBlockEntityMixin", 11 | "BlockMixin", 12 | "AbstractSkullBlockMixin", 13 | "JukeboxSongPlayerMixin" 14 | ], 15 | "injectors": { 16 | "defaultRequire": 1 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/main/resources/category_goat_horns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/src/main/resources/category_goat_horns.png -------------------------------------------------------------------------------- /src/main/resources/category_music_discs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/src/main/resources/category_music_discs.png -------------------------------------------------------------------------------- /src/main/resources/category_note_blocks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/src/main/resources/category_note_blocks.png -------------------------------------------------------------------------------- /src/main/resources/data/audioplayer/jukebox_song/custom.json: -------------------------------------------------------------------------------- 1 | { 2 | "comparator_output": 15, 3 | "description": { 4 | "text": "" 5 | }, 6 | "length_in_seconds": 1.0, 7 | "sound_event": "minecraft:intentionally_empty" 8 | } -------------------------------------------------------------------------------- /src/main/resources/fabric.mod.json: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 1, 3 | "id": "audioplayer", 4 | "version": "${mod_version}", 5 | "name": "AudioPlayer", 6 | "description": "Audio Player", 7 | "authors": [ 8 | "Max Henkel" 9 | ], 10 | "contact": { 11 | "website": "https://modrepo.de" 12 | }, 13 | "license": "All Rights Reserved", 14 | "icon": "icon.png", 15 | "environment": "*", 16 | "entrypoints": { 17 | "main": [ 18 | "de.maxhenkel.audioplayer.AudioPlayer" 19 | ], 20 | "voicechat": [ 21 | "de.maxhenkel.audioplayer.Plugin" 22 | ] 23 | }, 24 | "mixins": [ 25 | "audioplayer.mixins.json" 26 | ], 27 | "depends": { 28 | "fabricloader": "${fabric_loader_dependency}", 29 | "minecraft": "${minecraft_dependency}", 30 | "voicechat": ">=${minecraft_version}-${voicechat_api_version}" 31 | }, 32 | "breaks": { 33 | "fabric-api": "${fabric_api_dependency_breaks}" 34 | }, 35 | "accessWidener": "audioplayer.accesswidener" 36 | } 37 | -------------------------------------------------------------------------------- /src/main/resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/src/main/resources/icon.png -------------------------------------------------------------------------------- /src/main/resources/web/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/src/main/resources/web/.gitkeep -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require('@rushstack/eslint-patch/modern-module-resolution') 3 | 4 | module.exports = { 5 | root: true, 6 | 'extends': [ 7 | 'plugin:vue/vue3-essential', 8 | 'eslint:recommended', 9 | '@vue/eslint-config-typescript', 10 | '@vue/eslint-config-prettier/skip-formatting', 11 | '@vue/typescript/recommended', 12 | 'plugin:import/recommended', 13 | 'plugin:import/typescript' 14 | ], 15 | rules: { 16 | 'vue/no-unsupported-features': ['error', { 17 | 'version': '3.3.11', 18 | 'ignores': [] 19 | }], 20 | 'import/order': [ 21 | 'warn', 22 | { 23 | 'newlines-between': 'always', 24 | 'alphabetize': { 25 | 'order': 'asc', 26 | 'caseInsensitive': true 27 | }, 28 | } 29 | ], 30 | 'import/no-unresolved': 'off', 31 | '@typescript-eslint/ban-ts-comment': 'warn', 32 | '@typescript-eslint/ban-types': 'warn', 33 | '@typescript-eslint/no-empty-interface': 'warn', 34 | '@typescript-eslint/no-inferrable-types': 'off', 35 | '@typescript-eslint/no-this-alias': 'warn', 36 | '@typescript-eslint/no-var-requires': 'warn', 37 | '@typescript-eslint/explicit-module-boundary-types': 'warn', 38 | '@typescript-eslint/explicit-function-return-type': ['warn', 39 | { 40 | allowTypedFunctionExpressions: false, 41 | }], 42 | '@typescript-eslint/no-shadow': ['error'], 43 | 'space-before-function-paren': ['error', { 44 | 'anonymous': 'never', 45 | 'named': 'never', 46 | 'asyncArrow': 'always' 47 | }], 48 | 'no-shadow': 'off', 49 | 'getter-return': 'error', 50 | 'max-len': ['error', 200, { 'ignorePattern': 'd="([\\s\\S]*?)"', 'ignoreTrailingComments': true, 'ignoreComments': true }], 51 | 'no-console': 'error', 52 | 'no-debugger': 'warn', 53 | 'no-empty': 'error', 54 | 'no-extra-boolean-cast': 'warn', 55 | 'no-inferrable-types': 'off', 56 | 'no-irregular-whitespace': 2, 57 | 'no-prototype-builtins': 'error', 58 | 'no-trailing-spaces': 'error', 59 | 'no-undef': 'warn', 60 | 'prefer-const': 'error', 61 | 'vue/no-dupe-keys': 'warn', 62 | 'prefer-spread': 'warn', 63 | 'no-unreachable': 'warn', 64 | '@typescript-eslint/no-unused-vars': ['warn', { 'argsIgnorePattern': '^_', 'varsIgnorePattern': '^_' }], 65 | 'no-unused-vars': 'off', // It's turned off because it conflicts with the '@typescript-eslint/no-unused-vars' rule 66 | 'no-useless-escape': 'error', 67 | 'no-var': 'error', 68 | 'semi': 'error', 69 | 'padding-line-between-statements': [ 70 | 'error', 71 | { 'blankLine': 'always', 'prev': '*', 'next': 'return' }, 72 | { 'blankLine': 'always', 'prev': '*', 'next': 'switch' }, 73 | { 'blankLine': 'always', 'prev': '*', 'next': 'if' }, 74 | { 'blankLine': 'always', 'prev': 'block-like', 'next': '*' }, 75 | { 'blankLine': 'always', 'prev': ['const', 'let', 'var'], 'next': '*' }, 76 | { 'blankLine': 'any', 'prev': ['const', 'let', 'var'], 'next': ['const', 'let', 'var'] } 77 | ], 78 | 'vue/script-setup-uses-vars': 'error', 79 | 'vue/no-parsing-error': 'error', 80 | 'vue/no-use-v-if-with-v-for': 'error', 81 | 'object-curly-spacing': [2, 'always'], 82 | 'vue/object-curly-spacing': ['error', 'always'], 83 | 'vue/require-v-for-key': 'error', 84 | 'vue/no-multi-spaces': 'error', 85 | 'vue/component-tags-order': ['warn', { 86 | 'order': [ [ 'script', 'template' ], 'style' ] 87 | }], 88 | 'vue/padding-line-between-blocks': 'warn', 89 | 'vue/valid-template-root': 'error', 90 | 'vue/valid-v-for': 'warn', 91 | 'vue/valid-v-text': 'error', 92 | quotes: ['error', 'single'], 93 | 'vue/max-attributes-per-line': [ 94 | 'error', 95 | { 96 | singleline: 1, 97 | multiline: { 98 | max: 1, 99 | } 100 | } 101 | ], 102 | 'vue/first-attribute-linebreak': ['warn', { 103 | singleline: 'ignore', 104 | multiline: 'below' 105 | }], 106 | 'vue/script-indent': [ 107 | 'error', 108 | 2, 109 | { 110 | baseIndent: 1, 111 | switchCase: 1 112 | } 113 | ], 114 | 'vue/component-name-in-template-casing': 'warn', 115 | 'vue/multi-word-component-names': 'warn', 116 | 'vue/no-useless-template-attributes': 'warn', 117 | 'vue/html-closing-bracket-newline': ['warn', { 118 | 'singleline': 'never', 119 | 'multiline': 'never' 120 | }], 121 | 'vue/html-indent': [ 122 | 2, 123 | 2, 124 | { 125 | attribute: 1, 126 | baseIndent: 1, 127 | closeBracket: 0, 128 | alignAttributesVertically: true, 129 | ignores: [] 130 | } 131 | ], 132 | 'vue/no-required-prop-with-default': ['error', { 133 | 'autofix': false, 134 | }] 135 | }, 136 | overrides: [], 137 | parserOptions: { 138 | ecmaVersion: 'latest' 139 | }, 140 | ignorePatterns: [ 141 | '**/.vscode/*', 142 | '**/*.css', 143 | '**/*.min.js', 144 | '**/dist/*', 145 | '**/licenses/*', 146 | '**/locale/*', 147 | '**/misc/*', 148 | '**/node_modules/*', 149 | '**/cypress/*', 150 | '**/patches/*', 151 | '**/public/*', 152 | '**/src/assets/*', 153 | '**/src/vendor/*', 154 | '**/tests/*', 155 | 'babel.config.js', 156 | 'jest.config.js', 157 | 'postcss.config.js', 158 | 'tailwind.js', 159 | 'vc-trade.ts', 160 | 'vue.config.js' 161 | ], 162 | } -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | .idea 23 | *.suo 24 | *.ntvs* 25 | *.njsproj 26 | *.sln 27 | *.sw? 28 | 29 | *.tsbuildinfo 30 | -------------------------------------------------------------------------------- /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/prettierrc", 3 | "semi": false, 4 | "tabWidth": 2, 5 | "singleQuote": true, 6 | "printWidth": 100, 7 | "trailingComma": "none" 8 | } -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # AudioPlayer Web UI 2 | 3 | A user-friendly web ui to upload sounds from your browser to Minecraft. 4 | -------------------------------------------------------------------------------- /web/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | AudioPlayer 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "audioplayer-web", 3 | "version": "1.0.0", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "run-p type-check \"build-only {@}\" --", 8 | "preview": "vite preview", 9 | "build-only": "vite build", 10 | "type-check": "vue-tsc --build --force", 11 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore", 12 | "format": "prettier --write src/" 13 | }, 14 | "dependencies": { 15 | "vue": "^3.5.12" 16 | }, 17 | "devDependencies": { 18 | "@rushstack/eslint-patch": "^1.10.4", 19 | "@tsconfig/node20": "^20.1.4", 20 | "@types/node": "^22.7.9", 21 | "@vitejs/plugin-vue": "^5.1.4", 22 | "@vue/eslint-config-prettier": "^10.1.0", 23 | "@vue/eslint-config-typescript": "^14.1.3", 24 | "@vue/tsconfig": "^0.5.1", 25 | "autoprefixer": "^10.4.20", 26 | "eslint": "^9.13.0", 27 | "eslint-plugin-import": "^2.31.0", 28 | "eslint-plugin-vue": "^9.29.1", 29 | "npm-run-all2": "^7.0.1", 30 | "postcss": "^8.4.47", 31 | "prettier": "^3.3.3", 32 | "sass": "^1.80.4", 33 | "tailwindcss": "^3.4.14", 34 | "typescript": "~5.6.3", 35 | "vite": "^5.4.10", 36 | "vue-tsc": "^2.1.6" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/henkelmax/audio-player/3419dfb5fea8e94cc7c16bb8dfa7a0faaa4b06cd/web/public/favicon.ico -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 90 | 211 | 212 | 233 | 234 | -------------------------------------------------------------------------------- /web/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body { 6 | @apply bg-gray-900 text-white p-4 7 | } -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import './assets/main.css'; 2 | 3 | import { createApp } from 'vue'; 4 | 5 | import App from './App.vue'; 6 | 7 | createApp(App).mount('#app'); -------------------------------------------------------------------------------- /web/src/services/FileUploadService.ts: -------------------------------------------------------------------------------- 1 | import { uploadFileToApi } from '@/services/api/api'; 2 | 3 | export const uploadFile = (file: File, token: string) => { 4 | return uploadFileToApi(file, token); 5 | }; -------------------------------------------------------------------------------- /web/src/services/api/api.ts: -------------------------------------------------------------------------------- 1 | const BASEURL = import.meta.env.DEV ? 'http://localhost:8080' : ''; 2 | 3 | export const uploadFileToApi = (file: File, token: string): Promise => { 4 | const response = fetch(`${BASEURL}/upload`, { 5 | method: 'POST', 6 | headers: new Headers({ 7 | 'token': token 8 | }), 9 | body: file 10 | }); 11 | 12 | return response; 13 | }; 14 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {}, 6 | }, 7 | plugins: [], 8 | }; -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.dom.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 8 | 9 | "baseUrl": ".", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | } 13 | } 14 | } -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.node.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /web/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node20/tsconfig.json", 3 | "include": [ 4 | "vite.config.*", 5 | "vitest.config.*", 6 | "cypress.config.*", 7 | "nightwatch.conf.*", 8 | "playwright.config.*" 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "noEmit": true, 13 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 14 | 15 | "module": "ESNext", 16 | "moduleResolution": "Bundler", 17 | "types": ["node"] 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import {fileURLToPath, URL} from 'node:url'; 2 | 3 | import vue from '@vitejs/plugin-vue'; 4 | import {defineConfig} from 'vite'; 5 | 6 | // https://vitejs.dev/config/ 7 | export default defineConfig({ 8 | plugins: [ 9 | vue(), 10 | ], 11 | resolve: { 12 | alias: { 13 | '@': fileURLToPath(new URL('./src', import.meta.url)) 14 | } 15 | }, 16 | css: { 17 | preprocessorOptions: { 18 | scss: { 19 | api: 'modern' 20 | } 21 | } 22 | } 23 | }); --------------------------------------------------------------------------------