From 75ddc5b6827ef1ae7036a6631c9eaaa1129f71d6 Mon Sep 17 00:00:00 2001 From: koka Date: Sat, 29 Aug 2026 14:59:38 +0200 Subject: [PATCH 1/2] feat(ambiance): ajouter le disque blanc YouTube --- CHANGELOG.md | 5 +- README.md | 2 +- ambiance/WHITE_DISC.md | 31 +++ ambiance/build.gradle | 87 +++++++- .../sanctuary26/ambiance/AmbianceMod.java | 2 + .../ambiance/client/AmbianceClient.java | 9 + .../client/audio/LavaplayerAudioStream.java | 87 ++++++++ .../client/audio/WhiteDiscAudioPlayer.java | 195 ++++++++++++++++++ .../ambiance/item/WhiteDiscItem.java | 23 +++ .../mixin/client/LevelRendererMixin.java | 19 ++ .../mixin/client/SoundEngineAccessor.java | 12 ++ .../mixin/client/SoundManagerAccessor.java | 12 ++ .../ambiance/network/AmbianceNetworking.java | 4 +- .../network/WhiteDiscPlaybackPayload.java | 34 +++ .../ambiance/registry/AmbianceRegistries.java | 25 ++- .../ambiance/white_disc/WhiteDiscService.java | 70 +++++++ .../ambiance/white_disc/WhiteDiscUrl.java | 74 +++++++ .../resources/ambiance.client.mixins.json | 3 + .../assets/ambiance/items/white_disc.json | 6 + .../resources/assets/ambiance/lang/en_us.json | 10 +- .../resources/assets/ambiance/lang/fr_fr.json | 10 +- .../resources/assets/ambiance/lang/ru_ru.json | 10 +- .../ambiance/models/item/white_disc.json | 6 + .../ambiance/textures/item/white_disc.png | Bin 0 -> 416 bytes .../ambiance/jukebox_song/white_disc.json | 8 + .../data/ambiance/recipe/white_disc.json | 16 ++ ambiance/src/main/resources/fabric.mod.json | 10 +- .../white_disc/WhiteDiscUrlSmoke.java | 40 ++++ build.gradle | 21 +- gradle.properties | 2 +- 30 files changed, 810 insertions(+), 23 deletions(-) create mode 100644 ambiance/WHITE_DISC.md create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java create mode 100644 ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java create mode 100644 ambiance/src/main/resources/assets/ambiance/items/white_disc.json create mode 100644 ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json create mode 100644 ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png create mode 100644 ambiance/src/main/resources/data/ambiance/jukebox_song/white_disc.json create mode 100644 ambiance/src/main/resources/data/ambiance/recipe/white_disc.json create mode 100644 ambiance/src/test/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrlSmoke.java diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e7ab8d..5d18c9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,13 +8,16 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve ## Non distribué -Modules modifiés : **I Like To Move It `0.0.0-alpha.15`** et **It's Alive `0.0.0-alpha.35`**. Le pack distribué reste en `26.2.0-alpha.209` jusqu’à la prochaine release. +Modules modifiés : **I Like To Move It `0.0.0-alpha.15`**, **It's Alive `0.0.0-alpha.35`** et **Ambiance `0.0.0-alpha.15`**. Le pack distribué reste en `26.2.0-alpha.209` jusqu’à la prochaine release. - double la vitesse de croisière du Happy Ghast lorsqu’un joueur le pilote, sans accélérer ses déplacements autonomes ; - conserve les données v3 et le protocole réseau 5, sans migration de sauvegarde ni nouvel ID. - transforme le Codex Canaplia en « Livre interdit » aux pages noires, sans page d'accueil, et fait découvrir une entrée aléatoire encore inconnue à chaque page consommée ; - garde les découvertes du Livre interdit personnelles en main et partage temporairement celles du propriétaire uniquement lorsque le livre est posé sur un pupitre ; - conserve les IDs et les anciennes données partagées, ainsi que les données v3 et le protocole réseau 4 d'It's Alive, sans migration de sauvegarde. +- ajoute le disque blanc, à fabriquer puis renommer avec une URL YouTube HTTPS avant de l’insérer dans un jukebox ; +- valide strictement l’hôte et l’identifiant vidéo côté serveur, ne transmet qu’un identifiant borné de onze caractères et limite chaque client à quatre lectures de trente minutes maximum ; +- diffuse le son en mono positionnel dans le canal des disques, sans nouvelle donnée persistante ; Ambiance conserve ses données v2 et passe son protocole réseau de 5 à 6. ## `26.2.0-alpha.209` diff --git a/README.md b/README.md index 09efab8..6dbfde0 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`) |---|---|---|---| | `anotherworld` | Another World | choses matérielles | matière, photographie, instruments météo et 108 biomes natifs | | `itsalive` | It's Alive ! | vivant | Creepers, créatures, agriculture et gameplay serveur complet de Canaplia | -| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, calendrier et composition visuelle accessible | +| `ambiance` | Ambiance | perception | atmosphère, météo, lumières dynamiques, audio positionnel, calendrier et composition visuelle accessible | | `redstoner` | Red-Stoner | calcul et signaux | ordinateurs, identité serveur, Drawer et Particuleur | | `iliketomoveit` | I Like To Move It | déplacement | portails, véhicules, grappin, poulies et waystones locaux | | `ouch` | Ouch | combat et destruction | arsenal actif (6 armes) et TNT météo | diff --git a/ambiance/WHITE_DISC.md b/ambiance/WHITE_DISC.md new file mode 100644 index 0000000..95c4223 --- /dev/null +++ b/ambiance/WHITE_DISC.md @@ -0,0 +1,31 @@ +# Disque blanc YouTube + +Le disque blanc appartient à **Ambiance**, propriétaire de l’audio positionnel. Il conserve l’ID +`ambiance:white_disc` et se fabrique avec un disque 13 entouré de huit colorants blancs. Pour le +lire, il faut le renommer dans une enclume avec une URL YouTube HTTPS puis l’insérer dans un +jukebox vanilla. + +## Contrat réseau et sécurité + +- le serveur reste l’autorité de l’insertion et refuse les noms qui ne correspondent pas à une URL + HTTPS exacte de `youtube.com`, `www.youtube.com`, `m.youtube.com`, `music.youtube.com` ou + `youtu.be` ; +- les formats `watch`, `shorts`, `embed`, `live` et le lien court sont acceptés, mais l’identifiant + doit contenir exactement onze caractères ASCII autorisés ; +- le payload S2C ne contient que la position du jukebox et cet identifiant borné, jamais une URL + libre ; il est envoyé uniquement aux clients qui suivent le chunk et annoncent le canal ; +- chaque client ouvre lui-même la source YouTube, refuse les directs et les vidéos de plus de + trente minutes, et limite les lectures simultanées à quatre ; +- la lecture utilise le canal `RECORDS`, un flux PCM mono positionnel et une atténuation à + 64 blocs. Elle s’arrête lorsque le disque est éjecté, le chunk disparaît ou la session se ferme. + +Le disque ne crée aucune donnée persistante et ne demande aucune migration de sauvegarde. +Ambiance conserve ses données v2 ; son protocole passe de 5 à 6. + +## Dépendances clientes embarquées + +Le décodage repose sur Lavaplayer `2.2.7` (Apache-2.0) et youtube-source `1.18.2` (MIT), ainsi que +leurs dépendances transitives placées en JAR imbriqués par Loom. Leurs avis de licence restent dans +les archives d’origine. L’accès YouTube est effectué uniquement par le client et peut cesser de +fonctionner si YouTube modifie ses mécanismes de lecture ; aucune clé ou URL arbitraire n’est +acceptée par le serveur. diff --git a/ambiance/build.gradle b/ambiance/build.gradle index fcd2f96..98a0d9a 100644 --- a/ambiance/build.gradle +++ b/ambiance/build.gradle @@ -1,8 +1,43 @@ import groovy.json.JsonSlurper +configurations { + whiteDiscLibraries + implementation.extendsFrom whiteDiscLibraries + include.extendsFrom whiteDiscLibraries +} + dependencies { implementation project(':sanctuary') compileOnly "maven.modrinth:YL57xq9U:oaD6KQls" + whiteDiscLibraries("dev.arbjerg:lavaplayer:2.2.7") { + exclude group: "org.slf4j", module: "slf4j-api" + exclude group: "org.jetbrains", module: "annotations" + } + whiteDiscLibraries("dev.lavalink.youtube:v2:1.18.2") { + exclude group: "org.slf4j", module: "slf4j-api" + exclude group: "org.jetbrains", module: "annotations" + } + [ + "dev.arbjerg:lavaplayer-natives:2.2.7", + "dev.arbjerg:lava-common:2.2.7", + "dev.lavalink.youtube:common:1.18.2", + "org.mozilla:rhino-engine:1.7.15", + "org.mozilla:rhino:1.7.15", + "commons-io:commons-io:2.13.0", + "org.jsoup:jsoup:1.16.1", + "net.iharder:base64:2.3.9", + "org.json:json:20240303", + "org.apache.httpcomponents:httpclient:4.5.14", + "org.apache.httpcomponents:httpcore:4.4.16", + "commons-logging:commons-logging:1.2", + "commons-codec:commons-codec:1.11", + "com.fasterxml.jackson.core:jackson-core:2.15.2", + "com.fasterxml.jackson.core:jackson-databind:2.15.2", + "com.fasterxml.jackson.core:jackson-annotations:2.15.2", + "com.grack:nanojson:1.7" + ].each { coordinates -> + whiteDiscLibraries(coordinates) { transitive = false } + } } tasks.register("ambianceVisualApiSmoke", JavaExec) { @@ -21,13 +56,21 @@ tasks.register("ambianceScheduleSmoke", JavaExec) { mainClass = "fr.koka99cab.sanctuary26.ambiance.environment.AmbianceScheduleSmoke" } +tasks.register("whiteDiscUrlSmoke", JavaExec) { + group = "verification" + description = "Checks strict and bounded YouTube URL parsing for the white disc." + dependsOn tasks.named("testClasses") + classpath = sourceSets.test.runtimeClasspath + mainClass = "fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrlSmoke" +} + tasks.named("test") { failOnNoDiscoveredTests = false } tasks.register("verifyAmbiance") { group = "verification" - description = "Checks Ambiance alpha.12, including timed weather overrides, playable night fog and weather/lunar controls." + description = "Checks Ambiance alpha.15, including the bounded YouTube white-disc playback contract." inputs.files(fileTree("src/main/java")) inputs.files(fileTree("src/main/resources")) inputs.file(rootProject.file("pack/migrations/26.2.0-alpha.125-realtime-calendar-to-alpha.126.json")) @@ -68,8 +111,17 @@ tasks.register("verifyAmbiance") { def languages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language -> [(language): new JsonSlurper().parse(file("src/main/resources/assets/realtime/lang/${language}.json"))] } + def ambianceLanguages = ["fr_fr", "en_us", "ru_ru"].collectEntries { language -> + [(language): new JsonSlurper().parse(file("src/main/resources/assets/ambiance/lang/${language}.json"))] + } + def whiteDiscTexture = javax.imageio.ImageIO.read( + file("src/main/resources/assets/ambiance/textures/item/white_disc.png")) + def whiteDiscRecipe = new JsonSlurper().parse( + file("src/main/resources/data/ambiance/recipe/white_disc.json")) + def whiteDiscSong = new JsonSlurper().parse( + file("src/main/resources/data/ambiance/jukebox_song/white_disc.json")) - if (project.version.toString() != "0.0.0-alpha.14" + if (project.version.toString() != "0.0.0-alpha.15" || migration.source?.pack_version != "26.2.0-alpha.125" || migration.target?.pack_version != "26.2.0-alpha.126" || migration.target?.modules?.ambiance != "0.0.0-alpha.1" @@ -84,7 +136,7 @@ tasks.register("verifyAmbiance") { || migration.compatibility?.ambiance_data_version != 1 || migration.compatibility?.ambiance_network_protocol != 2 || manifest.custom?.sanctuary26?.data_version != 2 - || manifest.custom?.sanctuary26?.network_protocol != 5 + || manifest.custom?.sanctuary26?.network_protocol != 6 || dailyMigration.target?.pack_version != "26.2.0-alpha.128" || dailyMigration.target?.modules?.ambiance != "0.0.0-alpha.2" || dailyMigration.lunar_schedule?.cycle != "minecraft_native_eight_days" @@ -262,6 +314,34 @@ tasks.register("verifyAmbiance") { || !javaText.contains("Mth.lerp(night, 1.0F, 0.98F)")) { throw new GradleException("Ambiance alpha.11 playable-night fog contract is incomplete") } + if (!javaText.contains('id("white_disc_playback")') + || !javaText.contains('readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)') + || !javaText.contains('PlayerLookup.tracking(level, pos)') + || !javaText.contains('ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)') + || !javaText.contains('MAXIMUM_SIMULTANEOUS_DISCS = 4') + || !javaText.contains('MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L') + || !javaText.contains('new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE') + || !javaText.contains('SoundSource.RECORDS') + || !javaText.contains('channel.linearAttenuation(ATTENUATION_DISTANCE)') + || !javaText.contains('LONG_HOSTS = Set.of(') + || !javaText.contains('"https".equalsIgnoreCase(uri.getScheme())') + || !file("src/main/resources/assets/ambiance/items/white_disc.json").isFile() + || !file("src/main/resources/assets/ambiance/models/item/white_disc.json").isFile() + || whiteDiscTexture == null || whiteDiscTexture.width != 16 || whiteDiscTexture.height != 16 + || whiteDiscRecipe.result?.id != "ambiance:white_disc" + || whiteDiscRecipe.key?.W != "minecraft:white_dye" + || whiteDiscSong.sound_event != "minecraft:intentionally_empty" + || whiteDiscSong.length_in_seconds != 1800 + || ambianceLanguages.any { locale, translations -> + !["item.ambiance.white_disc", "tooltip.ambiance.white_disc", + "message.ambiance.white_disc.invalid_url", "message.ambiance.white_disc.failed"].every { + translations.containsKey(it) + } + } + || !file("build.gradle").text.contains('dev.arbjerg:lavaplayer:2.2.7') + || !file("build.gradle").text.contains('dev.lavalink.youtube:v2:1.18.2')) { + throw new GradleException("Ambiance alpha.15 white-disc playback contract is incomplete") + } [ 'class RealtimeMod', 'class RealtimeConfig', 'class MinecraftChronology', @@ -313,4 +393,5 @@ tasks.named("check") { dependsOn tasks.named("verifyAmbiance") dependsOn tasks.named("ambianceScheduleSmoke") dependsOn tasks.named("ambianceVisualApiSmoke") + dependsOn tasks.named("whiteDiscUrlSmoke") } diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java index f5cfc57..d376349 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/AmbianceMod.java @@ -7,6 +7,7 @@ import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceBloodNightAssault; import fr.koka99cab.sanctuary26.ambiance.environment.AmbianceWeatherCommands; import fr.koka99cab.sanctuary26.ambiance.network.AmbianceNetworking; import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries; +import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscService; import net.fabricmc.api.ModInitializer; import net.minecraft.resources.Identifier; import org.slf4j.Logger; @@ -29,6 +30,7 @@ public final class AmbianceMod implements ModInitializer { public void onInitialize() { AmbianceRegistries.initialize(); AmbianceNetworking.initialize(); + WhiteDiscService.initialize(); RealtimeMod.initialize(); AmbianceEnvironmentController.initialize(); AmbianceFirstSpawnVision.initialize(); diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java index 1625c20..46ac7d2 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/AmbianceClient.java @@ -4,7 +4,10 @@ import fr.koka99cab.realtime.client.RealtimeClient; import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; import fr.koka99cab.sanctuary26.ambiance.network.AmbianceEnvironmentPayload; import fr.koka99cab.sanctuary26.ambiance.network.AmbianceCanapliaVisualPayloads; +import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload; +import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer; import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientLifecycleEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayConnectionEvents; import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; @@ -41,11 +44,15 @@ public final class AmbianceClient implements ClientModInitializer { context.client().execute(() -> AmbianceClientVisualEffects.apply(payload))); ClientPlayNetworking.registerGlobalReceiver(AmbianceCanapliaVisualPayloads.Clear.ID, (payload, context) -> context.client().execute(AmbianceClientVisualEffects::clear)); + ClientPlayNetworking.registerGlobalReceiver(WhiteDiscPlaybackPayload.ID, (payload, context) -> + context.client().execute(() -> WhiteDiscAudioPlayer.play( + context.client(), payload.pos(), payload.videoId()))); visualSettingsKey = KeyMappingHelper.registerKeyMapping(new KeyMapping( "key.ambiance.visual_settings", InputConstants.Type.KEYSYM, GLFW.GLFW_KEY_UNKNOWN, AMBIANCE_CATEGORY)); ClientTickEvents.END_CLIENT_TICK.register(client -> { AmbianceClientVisualEffects.tick(); + WhiteDiscAudioPlayer.tick(client); while (visualSettingsKey.consumeClick()) { if (client.gui.screen() == null) client.gui.setScreen(new AmbianceVisualSettingsScreen(null)); } @@ -54,7 +61,9 @@ public final class AmbianceClient implements ClientModInitializer { AmbianceClientEnvironment.reset(); DynamicTorchLights.reset(); AmbianceClientVisualEffects.clear(); + WhiteDiscAudioPlayer.clear(); }); + ClientLifecycleEvents.CLIENT_STOPPING.register(client -> WhiteDiscAudioPlayer.shutdown()); AmbianceVisualOverlay.register(); AmbianceBiomeTints.registerColorResolvers(); registerWaterFluidTint(); diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java new file mode 100644 index 0000000..0e959f8 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/LavaplayerAudioStream.java @@ -0,0 +1,87 @@ +package fr.koka99cab.sanctuary26.ambiance.client.audio; + +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.sound.sampled.AudioFormat; +import net.minecraft.client.sounds.AudioStream; + +/** Adapts bounded mono PCM frames from Lavaplayer to Minecraft's streaming OpenAL channel. */ +final class LavaplayerAudioStream implements AudioStream { + static final int SAMPLE_RATE = 48_000; + private static final int FRAME_SAMPLES = 960; + private static final int BYTES_PER_SAMPLE = 2; + private static final AudioFormat FORMAT = + new AudioFormat(SAMPLE_RATE, 16, 1, true, false); + private final AudioPlayer player; + private byte[] pending = new byte[0]; + private int pendingOffset; + private volatile boolean closed; + private volatile boolean ended; + + LavaplayerAudioStream(AudioPlayer player) { + this.player = player; + } + + @Override + public AudioFormat getFormat() { + return FORMAT; + } + + @Override + public ByteBuffer read(int maximumBytes) throws IOException { + if (closed || maximumBytes <= 0) return null; + ByteBuffer output = ByteBuffer.allocateDirect(maximumBytes); + while (output.hasRemaining() && !closed) { + if (pendingOffset < pending.length) { + int length = Math.min(output.remaining(), pending.length - pendingOffset); + output.put(pending, pendingOffset, length); + pendingOffset += length; + continue; + } + AudioFrame frame = nextFrame(); + if (frame == null) { + if (ended) break; + int silence = Math.min(output.remaining(), FRAME_SAMPLES * BYTES_PER_SAMPLE); + for (int index = 0; index < silence; index++) output.put((byte) 0); + continue; + } + if (frame.isTerminator()) { + ended = true; + break; + } + pending = frame.getData(); + pendingOffset = 0; + } + if (output.position() == 0 && ended) return null; + output.flip(); + return output; + } + + private AudioFrame nextFrame() throws IOException { + try { + AudioFrame frame = player.provide(2, TimeUnit.SECONDS); + if (frame == null && player.getPlayingTrack() == null) ended = true; + return frame; + } catch (TimeoutException ignored) { + return null; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while buffering the white disc", interrupted); + } + } + + boolean ended() { + return ended; + } + + @Override + public void close() { + if (closed) return; + closed = true; + player.destroy(); + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java new file mode 100644 index 0000000..7c91ce7 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/client/audio/WhiteDiscAudioPlayer.java @@ -0,0 +1,195 @@ +package fr.koka99cab.sanctuary26.ambiance.client.audio; + +import com.mojang.blaze3d.audio.Library; +import com.sedmelluq.discord.lavaplayer.format.Pcm16AudioDataFormat; +import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler; +import com.sedmelluq.discord.lavaplayer.player.AudioPlayer; +import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager; +import com.sedmelluq.discord.lavaplayer.tools.FriendlyException; +import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist; +import com.sedmelluq.discord.lavaplayer.track.AudioTrack; +import dev.lavalink.youtube.YoutubeAudioSourceManager; +import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; +import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundEngineAccessor; +import fr.koka99cab.sanctuary26.ambiance.mixin.client.SoundManagerAccessor; +import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import net.minecraft.client.Minecraft; +import net.minecraft.client.sounds.ChannelAccess; +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.JukeboxBlock; +import net.minecraft.world.phys.Vec3; + +/** Client-only, positional and resource-bounded YouTube playback for white discs. */ +public final class WhiteDiscAudioPlayer { + private static final int MAXIMUM_SIMULTANEOUS_DISCS = 4; + private static final long MAXIMUM_TRACK_MILLIS = 30L * 60L * 1_000L; + private static final long FINISHED_GRACE_NANOS = 5_000_000_000L; + private static final float ATTENUATION_DISTANCE = 64.0F; + private static final DefaultAudioPlayerManager MANAGER = createManager(); + private static final Map REQUESTS = new HashMap<>(); + private static final LinkedHashMap PLAYBACKS = new LinkedHashMap<>(); + private static long requestSequence; + private static boolean shutdown; + + private WhiteDiscAudioPlayer() { + } + + public static void play(Minecraft client, BlockPos pos, String videoId) { + if (shutdown || client.player == null || client.level == null || !WhiteDiscUrl.validVideoId(videoId) + || client.player.distanceToSqr(Vec3.atCenterOf(pos)) > ATTENUATION_DISTANCE * ATTENUATION_DISTANCE * 4.0D + || !client.level.getBlockState(pos).is(Blocks.JUKEBOX) + || !client.level.getBlockState(pos).getValue(JukeboxBlock.HAS_RECORD)) return; + long key = pos.asLong(); + stop(key); + long request = ++requestSequence; + REQUESTS.put(key, request); + client.player.sendOverlayMessage(Component.translatable("message.ambiance.white_disc.loading")); + MANAGER.loadItemOrdered(key, WhiteDiscUrl.canonicalUrl(videoId), new AudioLoadResultHandler() { + @Override + public void trackLoaded(AudioTrack track) { + client.execute(() -> acceptTrack(client, pos, request, track)); + } + + @Override + public void playlistLoaded(AudioPlaylist playlist) { + AudioTrack selected = playlist.getSelectedTrack(); + if (selected == null && !playlist.getTracks().isEmpty()) selected = playlist.getTracks().getFirst(); + if (selected == null) noMatches(); + else trackLoaded(selected); + } + + @Override + public void noMatches() { + client.execute(() -> fail(client, key, request)); + } + + @Override + public void loadFailed(FriendlyException exception) { + AmbianceMod.LOGGER.warn("Unable to load white-disc YouTube video {}: {}", videoId, + exception.getMessage()); + client.execute(() -> fail(client, key, request)); + } + }); + } + + private static void acceptTrack(Minecraft client, BlockPos pos, long request, AudioTrack track) { + long key = pos.asLong(); + if (REQUESTS.getOrDefault(key, -1L) != request) return; + if (track.getInfo().isStream || track.getDuration() <= 0L + || track.getDuration() > MAXIMUM_TRACK_MILLIS) { + REQUESTS.remove(key); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.too_long")); + return; + } + while (PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) { + stop(PLAYBACKS.keySet().iterator().next()); + } + ChannelAccess channels = ((SoundEngineAccessor) ((SoundManagerAccessor) client.getSoundManager()) + .ambiance$getSoundEngine()).ambiance$getChannelAccess(); + channels.createHandle(Library.Pool.STREAMING).thenAccept(handle -> client.execute(() -> { + if (handle == null || REQUESTS.getOrDefault(key, -1L) != request) { + if (handle != null) handle.release(); + return; + } + AudioPlayer player = MANAGER.createPlayer(); + player.playTrack(track); + LavaplayerAudioStream stream = new LavaplayerAudioStream(player); + Playback playback = new Playback(pos.immutable(), handle, stream); + PLAYBACKS.put(key, playback); + REQUESTS.remove(key); + float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS); + handle.execute(channel -> { + channel.setSelfPosition(Vec3.atCenterOf(pos)); + channel.setRelative(false); + channel.linearAttenuation(ATTENUATION_DISTANCE); + channel.setVolume(volume); + channel.attachBufferStream(stream); + channel.play(); + }); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.playing", track.getInfo().title)); + })); + } + + private static void fail(Minecraft client, long key, long request) { + if (REQUESTS.getOrDefault(key, -1L) != request) return; + REQUESTS.remove(key); + if (client.player != null) client.player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.failed")); + } + + public static void stop(BlockPos pos) { + stop(pos.asLong()); + } + + private static void stop(long key) { + REQUESTS.remove(key); + Playback playback = PLAYBACKS.remove(key); + if (playback == null) return; + playback.stream().close(); + playback.handle().execute(channel -> channel.stop()); + playback.handle().release(); + } + + public static void tick(Minecraft client) { + if (client.level == null) { + clear(); + return; + } + long now = System.nanoTime(); + for (Map.Entry entry : Map.copyOf(PLAYBACKS).entrySet()) { + Playback playback = entry.getValue(); + boolean missing = !client.level.hasChunkAt(playback.pos()) + || !client.level.getBlockState(playback.pos()).is(Blocks.JUKEBOX) + || !client.level.getBlockState(playback.pos()).getValue(JukeboxBlock.HAS_RECORD); + if (missing || playback.finishedFor(now) >= FINISHED_GRACE_NANOS) { + stop(entry.getKey()); + continue; + } + float volume = client.options.getFinalSoundSourceVolume(SoundSource.RECORDS); + playback.handle().execute(channel -> channel.setVolume(volume)); + } + } + + public static void clear() { + REQUESTS.clear(); + for (long key : PLAYBACKS.keySet().stream().mapToLong(Long::longValue).toArray()) stop(key); + } + + public static void shutdown() { + if (shutdown) return; + shutdown = true; + clear(); + MANAGER.shutdown(); + } + + private static DefaultAudioPlayerManager createManager() { + DefaultAudioPlayerManager manager = new DefaultAudioPlayerManager(); + manager.getConfiguration().setOutputFormat( + new Pcm16AudioDataFormat(1, LavaplayerAudioStream.SAMPLE_RATE, 960, false)); + manager.setFrameBufferDuration(5_000); + manager.setItemLoaderThreadPoolSize(2); + manager.registerSourceManager(new YoutubeAudioSourceManager(false)); + return manager; + } + + private record Playback(BlockPos pos, ChannelAccess.ChannelHandle handle, + LavaplayerAudioStream stream, long[] finishedAt) { + private Playback(BlockPos pos, ChannelAccess.ChannelHandle handle, LavaplayerAudioStream stream) { + this(pos, handle, stream, new long[1]); + } + + private long finishedFor(long now) { + if (!stream.ended()) return 0L; + if (finishedAt[0] == 0L) finishedAt[0] = now; + return now - finishedAt[0]; + } + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java new file mode 100644 index 0000000..8869266 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/item/WhiteDiscItem.java @@ -0,0 +1,23 @@ +package fr.koka99cab.sanctuary26.ambiance.item; + +import java.util.function.Consumer; +import net.minecraft.ChatFormatting; +import net.minecraft.network.chat.Component; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.TooltipFlag; +import net.minecraft.world.item.component.TooltipDisplay; + +/** Blank disc whose custom name may hold a validated YouTube URL. */ +public final class WhiteDiscItem extends Item { + public WhiteDiscItem(Properties properties) { + super(properties); + } + + @Override + public void appendHoverText(ItemStack stack, TooltipContext context, TooltipDisplay display, + Consumer tooltip, TooltipFlag flag) { + tooltip.accept(Component.translatable("tooltip.ambiance.white_disc") + .withStyle(ChatFormatting.GRAY)); + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java new file mode 100644 index 0000000..aa5465e --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/LevelRendererMixin.java @@ -0,0 +1,19 @@ +package fr.koka99cab.sanctuary26.ambiance.mixin.client; + +import fr.koka99cab.sanctuary26.ambiance.client.audio.WhiteDiscAudioPlayer; +import net.minecraft.client.renderer.LevelRenderer; +import net.minecraft.core.BlockPos; +import net.minecraft.world.entity.Entity; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.injection.At; +import org.spongepowered.asm.mixin.injection.Inject; +import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; + +@Mixin(LevelRenderer.class) +public abstract class LevelRendererMixin { + @Inject(method = "levelEvent", at = @At("HEAD")) + private void ambiance$stopWhiteDisc(Entity source, int type, BlockPos pos, int data, + CallbackInfo callback) { + if (type == 1011) WhiteDiscAudioPlayer.stop(pos); + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java new file mode 100644 index 0000000..7728f7a --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundEngineAccessor.java @@ -0,0 +1,12 @@ +package fr.koka99cab.sanctuary26.ambiance.mixin.client; + +import net.minecraft.client.sounds.ChannelAccess; +import net.minecraft.client.sounds.SoundEngine; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SoundEngine.class) +public interface SoundEngineAccessor { + @Accessor("channelAccess") + ChannelAccess ambiance$getChannelAccess(); +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java new file mode 100644 index 0000000..ef9ed00 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/mixin/client/SoundManagerAccessor.java @@ -0,0 +1,12 @@ +package fr.koka99cab.sanctuary26.ambiance.mixin.client; + +import net.minecraft.client.sounds.SoundEngine; +import net.minecraft.client.sounds.SoundManager; +import org.spongepowered.asm.mixin.Mixin; +import org.spongepowered.asm.mixin.gen.Accessor; + +@Mixin(SoundManager.class) +public interface SoundManagerAccessor { + @Accessor("soundEngine") + SoundEngine ambiance$getSoundEngine(); +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java index 3ff21c4..a46c3d5 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/AmbianceNetworking.java @@ -13,7 +13,7 @@ import net.minecraft.server.level.ServerPlayer; * Networking contract for Ambiance. Payload IDs must use the module namespace. */ public final class AmbianceNetworking { - public static final int PROTOCOL_VERSION = 5; + public static final int PROTOCOL_VERSION = 6; private static boolean initialized; private AmbianceNetworking() { @@ -34,6 +34,8 @@ public final class AmbianceNetworking { AmbianceCanapliaVisualPayloads.BadTrip.ID, AmbianceCanapliaVisualPayloads.BadTrip.CODEC); PayloadTypeRegistry.clientboundPlay().register( AmbianceCanapliaVisualPayloads.Clear.ID, AmbianceCanapliaVisualPayloads.Clear.CODEC); + PayloadTypeRegistry.clientboundPlay().register( + WhiteDiscPlaybackPayload.ID, WhiteDiscPlaybackPayload.CODEC); ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> send(handler.player, AmbianceEnvironmentController.snapshot(server)) ); diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java new file mode 100644 index 0000000..a6bb4f3 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/network/WhiteDiscPlaybackPayload.java @@ -0,0 +1,34 @@ +package fr.koka99cab.sanctuary26.ambiance.network; + +import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; +import fr.koka99cab.sanctuary26.ambiance.white_disc.WhiteDiscUrl; +import net.minecraft.core.BlockPos; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; + +/** Bounded clientbound intent: a server-resolved jukebox position and an 11-character video ID. */ +public record WhiteDiscPlaybackPayload(BlockPos pos, String videoId) implements CustomPacketPayload { + public static final Type ID = + new Type<>(AmbianceMod.id("white_disc_playback")); + public static final StreamCodec CODEC = + CustomPacketPayload.codec(WhiteDiscPlaybackPayload::write, WhiteDiscPlaybackPayload::new); + + public WhiteDiscPlaybackPayload { + if (!WhiteDiscUrl.validVideoId(videoId)) videoId = ""; + } + + private WhiteDiscPlaybackPayload(RegistryFriendlyByteBuf buffer) { + this(buffer.readBlockPos(), buffer.readUtf(WhiteDiscUrl.VIDEO_ID_LENGTH)); + } + + private void write(RegistryFriendlyByteBuf buffer) { + buffer.writeBlockPos(pos); + buffer.writeUtf(videoId, WhiteDiscUrl.VIDEO_ID_LENGTH); + } + + @Override + public Type type() { + return ID; + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java index 75e57ec..729e74c 100644 --- a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/registry/AmbianceRegistries.java @@ -1,12 +1,32 @@ package fr.koka99cab.sanctuary26.ambiance.registry; import fr.koka99cab.sanctuary26.ambiance.AmbianceMod; +import fr.koka99cab.sanctuary26.ambiance.item.WhiteDiscItem; +import net.fabricmc.fabric.api.creativetab.v1.CreativeModeTabEvents; +import net.minecraft.core.Registry; +import net.minecraft.core.registries.BuiltInRegistries; +import net.minecraft.core.registries.Registries; +import net.minecraft.resources.Identifier; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.item.CreativeModeTab; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.JukeboxSong; +import net.minecraft.world.item.Rarity; /** * Single registration entry point for the Ambiance namespace. * Feature registries must be called from here to keep initialization ordering explicit. */ public final class AmbianceRegistries { + public static final ResourceKey WHITE_DISC_SONG = ResourceKey.create( + Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc")); + private static final ResourceKey WHITE_DISC_KEY = ResourceKey.create( + Registries.ITEM, AmbianceMod.id("white_disc")); + private static final ResourceKey TOOLS_TAB = ResourceKey.create( + Registries.CREATIVE_MODE_TAB, Identifier.withDefaultNamespace("tools_and_utilities")); + public static final Item WHITE_DISC = Registry.register(BuiltInRegistries.ITEM, WHITE_DISC_KEY, + new WhiteDiscItem(new Item.Properties().stacksTo(1).rarity(Rarity.UNCOMMON) + .jukeboxPlayable(WHITE_DISC_SONG).setId(WHITE_DISC_KEY))); private static boolean initialized; private AmbianceRegistries() { @@ -17,7 +37,8 @@ public final class AmbianceRegistries { return; } initialized = true; - AmbianceMod.LOGGER.debug("[{}] Empty registry hub initialized.", AmbianceMod.DISPLAY_NAME); + CreativeModeTabEvents.modifyOutputEvent(TOOLS_TAB).register(entries -> entries.accept(WHITE_DISC)); + AmbianceMod.LOGGER.debug("[{}] Registry hub initialized with the white disc.", + AmbianceMod.DISPLAY_NAME); } } - diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java new file mode 100644 index 0000000..f79adc0 --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscService.java @@ -0,0 +1,70 @@ +package fr.koka99cab.sanctuary26.ambiance.white_disc; + +import fr.koka99cab.sanctuary26.ambiance.network.WhiteDiscPlaybackPayload; +import fr.koka99cab.sanctuary26.ambiance.registry.AmbianceRegistries; +import java.util.LinkedHashSet; +import java.util.Optional; +import java.util.Set; +import net.fabricmc.fabric.api.event.player.UseBlockCallback; +import net.fabricmc.fabric.api.networking.v1.PlayerLookup; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.JukeboxPlayable; +import net.minecraft.world.level.block.JukeboxBlock; +import net.minecraft.world.level.block.entity.JukeboxBlockEntity; + +/** Server-authoritative insertion and bounded playback dispatch for the white disc. */ +public final class WhiteDiscService { + private static boolean initialized; + + private WhiteDiscService() { + } + + public static void initialize() { + if (initialized) return; + initialized = true; + UseBlockCallback.EVENT.register((player, level, hand, hit) -> { + BlockPos pos = hit.getBlockPos(); + if (!(level.getBlockEntity(pos) instanceof JukeboxBlockEntity jukebox) + || jukebox.getBlockState().getValue(JukeboxBlock.HAS_RECORD)) return InteractionResult.PASS; + ItemStack held = player.getItemInHand(hand); + if (!held.is(AmbianceRegistries.WHITE_DISC)) return InteractionResult.PASS; + + Optional videoId = videoId(held); + if (videoId.isEmpty()) { + if (!level.isClientSide()) player.sendOverlayMessage( + Component.translatable("message.ambiance.white_disc.invalid_url")); + return InteractionResult.FAIL; + } + if (level.isClientSide()) return InteractionResult.SUCCESS; + InteractionResult result = JukeboxPlayable.tryInsertIntoJukebox(level, pos, held, player); + if (result.consumesAction() && level instanceof ServerLevel serverLevel) { + broadcast(serverLevel, pos, videoId.get(), player instanceof ServerPlayer serverPlayer + ? serverPlayer : null); + } + return result; + }); + } + + public static Optional videoId(ItemStack stack) { + Component name = stack.get(DataComponents.CUSTOM_NAME); + return name == null ? Optional.empty() : WhiteDiscUrl.videoId(name.getString()); + } + + private static void broadcast(ServerLevel level, BlockPos pos, String videoId, ServerPlayer inserter) { + WhiteDiscPlaybackPayload payload = new WhiteDiscPlaybackPayload(pos, videoId); + Set receivers = new LinkedHashSet<>(PlayerLookup.tracking(level, pos)); + if (inserter != null) receivers.add(inserter); + for (ServerPlayer receiver : receivers) { + if (ServerPlayNetworking.canSend(receiver, WhiteDiscPlaybackPayload.ID)) { + ServerPlayNetworking.send(receiver, payload); + } + } + } +} diff --git a/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java new file mode 100644 index 0000000..08436df --- /dev/null +++ b/ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/white_disc/WhiteDiscUrl.java @@ -0,0 +1,74 @@ +package fr.koka99cab.sanctuary26.ambiance.white_disc; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Pattern; + +/** Strict, side-effect-free YouTube URL parser used before any client network request. */ +public final class WhiteDiscUrl { + public static final int MAXIMUM_URL_LENGTH = 256; + public static final int VIDEO_ID_LENGTH = 11; + private static final Pattern VIDEO_ID = Pattern.compile("[A-Za-z0-9_-]{11}"); + private static final Set LONG_HOSTS = Set.of( + "youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com"); + + private WhiteDiscUrl() { + } + + public static Optional videoId(String value) { + if (value == null || value.isBlank() || value.length() > MAXIMUM_URL_LENGTH) return Optional.empty(); + try { + URI uri = new URI(value.strip()); + if (!"https".equalsIgnoreCase(uri.getScheme()) || uri.getUserInfo() != null + || (uri.getPort() != -1 && uri.getPort() != 443)) return Optional.empty(); + String host = uri.getHost(); + if (host == null) return Optional.empty(); + host = host.toLowerCase(Locale.ROOT); + if ("youtu.be".equals(host)) return valid(singlePathSegment(uri.getRawPath())); + if (!LONG_HOSTS.contains(host)) return Optional.empty(); + + String path = uri.getRawPath(); + if ("/watch".equals(path)) return valid(queryParameter(uri.getRawQuery(), "v")); + for (String prefix : new String[] {"/shorts/", "/embed/", "/live/"}) { + if (path != null && path.startsWith(prefix)) return valid(singlePathSegment(path.substring(prefix.length()))); + } + } catch (URISyntaxException ignored) { + // Invalid user text is rejected without network access. + } + return Optional.empty(); + } + + public static boolean validVideoId(String value) { + return value != null && VIDEO_ID.matcher(value).matches(); + } + + public static String canonicalUrl(String videoId) { + if (!validVideoId(videoId)) throw new IllegalArgumentException("Invalid YouTube video ID"); + return "https://www.youtube.com/watch?v=" + videoId; + } + + private static String singlePathSegment(String path) { + if (path == null || path.isEmpty()) return ""; + String segment = path.startsWith("/") ? path.substring(1) : path; + return segment.contains("/") ? "" : segment; + } + + private static String queryParameter(String query, String name) { + if (query == null) return ""; + String found = null; + for (String parameter : query.split("&", -1)) { + int equals = parameter.indexOf('='); + if (equals <= 0 || !name.equals(parameter.substring(0, equals))) continue; + if (found != null) return ""; + found = parameter.substring(equals + 1); + } + return found == null ? "" : found; + } + + private static Optional valid(String candidate) { + return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty(); + } +} diff --git a/ambiance/src/main/resources/ambiance.client.mixins.json b/ambiance/src/main/resources/ambiance.client.mixins.json index 8cf3b09..86b18c4 100644 --- a/ambiance/src/main/resources/ambiance.client.mixins.json +++ b/ambiance/src/main/resources/ambiance.client.mixins.json @@ -11,6 +11,9 @@ "GameRendererMixin", "LightCoordsUtilMixin", "LightmapRenderStateExtractorMixin", + "LevelRendererMixin", + "SoundEngineAccessor", + "SoundManagerAccessor", "SkyRendererMixin" ], "injectors": { diff --git a/ambiance/src/main/resources/assets/ambiance/items/white_disc.json b/ambiance/src/main/resources/assets/ambiance/items/white_disc.json new file mode 100644 index 0000000..e2f8f86 --- /dev/null +++ b/ambiance/src/main/resources/assets/ambiance/items/white_disc.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "ambiance:item/white_disc" + } +} diff --git a/ambiance/src/main/resources/assets/ambiance/lang/en_us.json b/ambiance/src/main/resources/assets/ambiance/lang/en_us.json index 0669c8f..b6a8ae4 100644 --- a/ambiance/src/main/resources/assets/ambiance/lang/en_us.json +++ b/ambiance/src/main/resources/assets/ambiance/lang/en_us.json @@ -24,5 +24,13 @@ "options.ambiance.visual.wobble": "Camera wobble", "options.ambiance.visual.ghosting": "Edge ghosting", "key.categories.ambiance.ambiance": "Ambiance", - "key.ambiance.visual_settings": "Visual accessibility settings" + "key.ambiance.visual_settings": "Visual accessibility settings", + "item.ambiance.white_disc": "White Disc", + "jukebox_song.ambiance.white_disc": "White Disc", + "tooltip.ambiance.white_disc": "Rename it to an HTTPS YouTube URL, then insert it into a jukebox.", + "message.ambiance.white_disc.invalid_url": "Rename the white disc to a valid HTTPS YouTube URL.", + "message.ambiance.white_disc.loading": "Loading YouTube video…", + "message.ambiance.white_disc.playing": "Now playing: %s", + "message.ambiance.white_disc.failed": "This YouTube video could not be played.", + "message.ambiance.white_disc.too_long": "This video is live or longer than 30 minutes." } diff --git a/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json b/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json index fefd521..e954668 100644 --- a/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json +++ b/ambiance/src/main/resources/assets/ambiance/lang/fr_fr.json @@ -24,5 +24,13 @@ "options.ambiance.visual.wobble": "Oscillation de la caméra", "options.ambiance.visual.ghosting": "Rémanence sur les bords", "key.categories.ambiance.ambiance": "Ambiance", - "key.ambiance.visual_settings": "Réglages d’accessibilité visuelle" + "key.ambiance.visual_settings": "Réglages d’accessibilité visuelle", + "item.ambiance.white_disc": "Disque blanc", + "jukebox_song.ambiance.white_disc": "Disque blanc", + "tooltip.ambiance.white_disc": "Renommez-le avec une URL YouTube HTTPS, puis insérez-le dans un jukebox.", + "message.ambiance.white_disc.invalid_url": "Renommez le disque blanc avec une URL YouTube HTTPS valide.", + "message.ambiance.white_disc.loading": "Chargement de la vidéo YouTube…", + "message.ambiance.white_disc.playing": "Lecture : %s", + "message.ambiance.white_disc.failed": "Impossible de lire cette vidéo YouTube.", + "message.ambiance.white_disc.too_long": "Cette vidéo est en direct ou dépasse 30 minutes." } diff --git a/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json b/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json index 0ef5232..3a84ce4 100644 --- a/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json +++ b/ambiance/src/main/resources/assets/ambiance/lang/ru_ru.json @@ -24,5 +24,13 @@ "options.ambiance.visual.wobble": "Покачивание камеры", "options.ambiance.visual.ghosting": "Шлейф по краям", "key.categories.ambiance.ambiance": "Ambiance", - "key.ambiance.visual_settings": "Настройки визуальной доступности" + "key.ambiance.visual_settings": "Настройки визуальной доступности", + "item.ambiance.white_disc": "Белая пластинка", + "jukebox_song.ambiance.white_disc": "Белая пластинка", + "tooltip.ambiance.white_disc": "Переименуйте её в HTTPS-ссылку YouTube и вставьте в проигрыватель.", + "message.ambiance.white_disc.invalid_url": "Переименуйте белую пластинку в допустимую HTTPS-ссылку YouTube.", + "message.ambiance.white_disc.loading": "Загрузка видео YouTube…", + "message.ambiance.white_disc.playing": "Сейчас играет: %s", + "message.ambiance.white_disc.failed": "Не удалось воспроизвести это видео YouTube.", + "message.ambiance.white_disc.too_long": "Это прямая трансляция или видео длиннее 30 минут." } diff --git a/ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json b/ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json new file mode 100644 index 0000000..960a335 --- /dev/null +++ b/ambiance/src/main/resources/assets/ambiance/models/item/white_disc.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/template_music_disc", + "textures": { + "layer0": "ambiance:item/white_disc" + } +} diff --git a/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png b/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png new file mode 100644 index 0000000000000000000000000000000000000000..ed199c1e7bc9d9c114aa81096886806bc3c3bf17 GIT binary patch literal 416 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!73?$#)eFPFvNcITwWnidMV_;}#VPNz5KkA!5RU7~2@Xt6O$l8mZyY@u!Ei#L z%+RdNFeM?ugt5~?n}IdPi8;2xNl8sXGhLlcR5*O|6M=MhiNl;asjrws7&ZAAHQTbp i9V9w7Fy(2AFfb%32pddXx8VoSd 128 || discovery.sources.any { it.chance_numerator < 1 || it.chance_numerator >= it.chance_denominator @@ -5487,7 +5496,7 @@ tasks.register("verifyCanapliaAlpha209Release") { itsalive: "0.0.0-alpha.33", onlyfun: "0.0.0-alpha.20"] if (rootProject.pack_version != "26.2.0-alpha.209" - || rootProject.ambiance_version != expectedTarget.ambiance + || rootProject.ambiance_version != "0.0.0-alpha.15" || rootProject.itsalive_version != "0.0.0-alpha.35" || rootProject.onlyfun_version != expectedTarget.onlyfun || migration.source?.pack_version != "26.2.0-alpha.207" diff --git a/gradle.properties b/gradle.properties index a6f07d9..8eb2ef0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -12,7 +12,7 @@ fabric_api_version=0.158.0+26.2 # Standby modules stay at zero until their migration really begins. mod_version=0.0.0-alpha.0 module_lifecycle=standby -ambiance_version=0.0.0-alpha.14 +ambiance_version=0.0.0-alpha.15 ambiance_lifecycle=active redstoner_version=0.0.0-alpha.10 redstoner_lifecycle=active -- 2.54.0 From 7ee1e5f09458863a5eb03763afbab6ae328eef14 Mon Sep 17 00:00:00 2001 From: koka Date: Sat, 29 Aug 2026 17:09:48 +0200 Subject: [PATCH 2/2] fix(ambiance): reprendre la texture vanilla du disque blanc --- .../ambiance/textures/item/white_disc.png | Bin 416 -> 393 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png b/ambiance/src/main/resources/assets/ambiance/textures/item/white_disc.png index ed199c1e7bc9d9c114aa81096886806bc3c3bf17..0914f1f0b8b8fea3b6e25b36a25be262ef69c673 100644 GIT binary patch delta 318 zcmZ3$+{rvaLW_fifq`M`FY{MGiYdw4-G$+Qd;gjJKpuOEr>`sfD-KzH4yFHZZ;4Hu zr{H92WngAyVyz5KkA!5RU7~2@Xt6O$l8mZyY@u!Ei#L z%+RdNFeM?ugt5~?n}IdPi8;2xNl8sXGhLlcR5*O|6M=MhiNl;asjrws7&ZAAHQTbp i9V9w7Fy(2AFfb%32pddXx8VoSd