feat(ambiance): ajouter le disque blanc YouTube (#62) #75
+4
-1
@@ -8,12 +8,15 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
|
||||
|
||||
## Non distribué
|
||||
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.36`** et **Sanctuary `0.0.0-alpha.119`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release.
|
||||
Modules modifiés : **It's Alive `0.0.0-alpha.36`**, **Sanctuary `0.0.0-alpha.119`** et **Ambiance `0.0.0-alpha.15`**. Le pack distribué reste en `26.2.0-alpha.210` jusqu’à la prochaine release.
|
||||
|
||||
- remplace les pages noires du Livre interdit par l’affichage clair du livre vanilla ;
|
||||
- conserve les seize entrées, les découvertes personnelles, le partage sur pupitre, les IDs, les données v3 et le protocole réseau 4.
|
||||
- rétablit l'accès au Black Market par clic molette sur un panneau, avec une intention vide et une nouvelle validation complète de la cible côté serveur ;
|
||||
- conserve les données Sanctuary v11 et porte son protocole réseau de 21 à 22, sans nouvel ID persistant.
|
||||
- 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.210`
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
+86
-3
@@ -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,36 @@ 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('REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS')
|
||||
|| !javaText.contains('client.level.dimension().equals(playback.dimension())')
|
||||
|| !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 +395,5 @@ tasks.named("check") {
|
||||
dependsOn tasks.named("verifyAmbiance")
|
||||
dependsOn tasks.named("ambianceScheduleSmoke")
|
||||
dependsOn tasks.named("ambianceVisualApiSmoke")
|
||||
dependsOn tasks.named("whiteDiscUrlSmoke")
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
+87
@@ -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();
|
||||
}
|
||||
}
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
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.resources.ResourceKey;
|
||||
import net.minecraft.sounds.SoundSource;
|
||||
import net.minecraft.world.level.Level;
|
||||
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<Long, Long> REQUESTS = new HashMap<>();
|
||||
private static final LinkedHashMap<Long, Playback> 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 || !WhiteDiscUrl.validVideoId(videoId) || !validTarget(client, pos)) return;
|
||||
long key = pos.asLong();
|
||||
if (REQUESTS.containsKey(key)) return;
|
||||
boolean replacingPlayback = PLAYBACKS.containsKey(key);
|
||||
if (!replacingPlayback
|
||||
&& REQUESTS.size() + PLAYBACKS.size() >= MAXIMUM_SIMULTANEOUS_DISCS) return;
|
||||
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 (!validTarget(client, pos)) {
|
||||
REQUESTS.remove(key);
|
||||
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(), client.level.dimension(), 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<Long, Playback> entry : Map.copyOf(PLAYBACKS).entrySet()) {
|
||||
Playback playback = entry.getValue();
|
||||
boolean missing = !client.level.dimension().equals(playback.dimension())
|
||||
|| !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 static boolean validTarget(Minecraft client, BlockPos pos) {
|
||||
return client.player != null && client.level != null
|
||||
&& 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);
|
||||
}
|
||||
|
||||
private record Playback(BlockPos pos, ResourceKey<Level> dimension,
|
||||
ChannelAccess.ChannelHandle handle,
|
||||
LavaplayerAudioStream stream, long[] finishedAt) {
|
||||
private Playback(BlockPos pos, ResourceKey<Level> dimension,
|
||||
ChannelAccess.ChannelHandle handle, LavaplayerAudioStream stream) {
|
||||
this(pos, dimension, 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Component> tooltip, TooltipFlag flag) {
|
||||
tooltip.accept(Component.translatable("tooltip.ambiance.white_disc")
|
||||
.withStyle(ChatFormatting.GRAY));
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+12
@@ -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();
|
||||
}
|
||||
+3
-1
@@ -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))
|
||||
);
|
||||
|
||||
+34
@@ -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<WhiteDiscPlaybackPayload> ID =
|
||||
new Type<>(AmbianceMod.id("white_disc_playback"));
|
||||
public static final StreamCodec<RegistryFriendlyByteBuf, WhiteDiscPlaybackPayload> 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<? extends CustomPacketPayload> type() {
|
||||
return ID;
|
||||
}
|
||||
}
|
||||
+23
-2
@@ -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<JukeboxSong> WHITE_DISC_SONG = ResourceKey.create(
|
||||
Registries.JUKEBOX_SONG, AmbianceMod.id("white_disc"));
|
||||
private static final ResourceKey<Item> WHITE_DISC_KEY = ResourceKey.create(
|
||||
Registries.ITEM, AmbianceMod.id("white_disc"));
|
||||
private static final ResourceKey<CreativeModeTab> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+70
@@ -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<String> 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<String> 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<ServerPlayer> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String> LONG_HOSTS = Set.of(
|
||||
"youtube.com", "www.youtube.com", "m.youtube.com", "music.youtube.com");
|
||||
|
||||
private WhiteDiscUrl() {
|
||||
}
|
||||
|
||||
public static Optional<String> 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<String> valid(String candidate) {
|
||||
return validVideoId(candidate) ? Optional.of(candidate) : Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@
|
||||
"GameRendererMixin",
|
||||
"LightCoordsUtilMixin",
|
||||
"LightmapRenderStateExtractorMixin",
|
||||
"LevelRendererMixin",
|
||||
"SoundEngineAccessor",
|
||||
"SoundManagerAccessor",
|
||||
"SkyRendererMixin"
|
||||
],
|
||||
"injectors": {
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"model": {
|
||||
"type": "minecraft:model",
|
||||
"model": "ambiance:item/white_disc"
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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 минут."
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"parent": "minecraft:item/template_music_disc",
|
||||
"textures": {
|
||||
"layer0": "ambiance:item/white_disc"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 393 B |
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"comparator_output": 15,
|
||||
"description": {
|
||||
"translate": "jukebox_song.ambiance.white_disc"
|
||||
},
|
||||
"length_in_seconds": 1800.0,
|
||||
"sound_event": "minecraft:intentionally_empty"
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"type": "minecraft:crafting_shaped",
|
||||
"category": "misc",
|
||||
"pattern": [
|
||||
"WWW",
|
||||
"WDW",
|
||||
"WWW"
|
||||
],
|
||||
"key": {
|
||||
"D": "minecraft:music_disc_13",
|
||||
"W": "minecraft:white_dye"
|
||||
},
|
||||
"result": {
|
||||
"id": "ambiance:white_disc"
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
"id": "ambiance",
|
||||
"version": "${version}",
|
||||
"name": "Ambiance",
|
||||
"description": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.",
|
||||
"description": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
|
||||
"authors": [
|
||||
"KOKA99CAB"
|
||||
],
|
||||
@@ -36,13 +36,13 @@
|
||||
"system_type": "gameplay-system",
|
||||
"namespace": "ambiance",
|
||||
"data_version": 2,
|
||||
"network_protocol": 5,
|
||||
"network_protocol": 6,
|
||||
"lifecycle": "${lifecycle}",
|
||||
"pack_version": "${pack_version}",
|
||||
"descriptions": {
|
||||
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
|
||||
"en_us": "Owns world atmosphere, dynamic lights, the calendar and composable accessible visual effects, including Canaplia.",
|
||||
"ru_ru": "Управляет атмосферой, динамическим освещением, календарём и доступными составными визуальными эффектами, включая Canaplia."
|
||||
"fr_fr": "Gère l'atmosphère, les lumières dynamiques, l'audio positionnel, le calendrier et les effets visuels accessibles et composables, dont Canaplia.",
|
||||
"en_us": "Owns world atmosphere, dynamic lights, positional audio, the calendar and composable accessible visual effects, including Canaplia.",
|
||||
"ru_ru": "Управляет атмосферой, динамическим освещением, позиционным звуком, календарём и доступными составными визуальными эффектами, включая Canaplia."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package fr.koka99cab.sanctuary26.ambiance.white_disc;
|
||||
|
||||
/** Executable URL-policy regression test, intentionally independent from a running game. */
|
||||
public final class WhiteDiscUrlSmoke {
|
||||
private static final String VIDEO_ID = "dQw4w9WgXcQ";
|
||||
|
||||
private WhiteDiscUrlSmoke() {
|
||||
}
|
||||
|
||||
public static void main(String[] args) {
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/watch?v=" + VIDEO_ID)), "watch URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://youtu.be/" + VIDEO_ID + "?si=abc")), "short URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://music.youtube.com/watch?v=" + VIDEO_ID)), "music URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/shorts/" + VIDEO_ID)), "shorts URL rejected");
|
||||
require(VIDEO_ID.equals(id("https://www.youtube.com/embed/" + VIDEO_ID)), "embed URL rejected");
|
||||
|
||||
reject("http://www.youtube.com/watch?v=" + VIDEO_ID);
|
||||
reject("https://youtube.example/watch?v=" + VIDEO_ID);
|
||||
reject("https://youtube.com.evil.example/watch?v=" + VIDEO_ID);
|
||||
reject("https://user@www.youtube.com/watch?v=" + VIDEO_ID);
|
||||
reject("https://www.youtube.com:8443/watch?v=" + VIDEO_ID);
|
||||
reject("https://www.youtube.com/watch?v=too_short");
|
||||
reject("https://www.youtube.com/watch?v=" + VIDEO_ID + "&v=" + VIDEO_ID);
|
||||
reject("https://youtu.be/" + VIDEO_ID + "/extra");
|
||||
reject("https://www.youtube.com/watch?v=dQw4w9WgX%63Q");
|
||||
reject("x".repeat(WhiteDiscUrl.MAXIMUM_URL_LENGTH + 1));
|
||||
}
|
||||
|
||||
private static String id(String url) {
|
||||
return WhiteDiscUrl.videoId(url).orElseThrow();
|
||||
}
|
||||
|
||||
private static void reject(String url) {
|
||||
require(WhiteDiscUrl.videoId(url).isEmpty(), "Unsafe URL accepted: " + url);
|
||||
}
|
||||
|
||||
private static void require(boolean condition, String message) {
|
||||
if (!condition) throw new AssertionError(message);
|
||||
}
|
||||
}
|
||||
+15
-6
@@ -33,6 +33,15 @@ allprojects {
|
||||
}
|
||||
filter { includeGroup "maven.modrinth" }
|
||||
}
|
||||
exclusiveContent {
|
||||
forRepository {
|
||||
maven {
|
||||
name = "Lavalink"
|
||||
url = "https://maven.lavalink.dev/releases"
|
||||
}
|
||||
}
|
||||
filter { includeGroup "dev.lavalink.youtube" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4468,7 +4477,7 @@ tasks.register("verifyWeatherTntAmbianceRelease") {
|
||||
def ouchManifest = new JsonSlurper().parse(file("ouch/src/main/resources/fabric.mod.json"))
|
||||
def rootBuild = file("build.gradle").text
|
||||
if (rootProject.pack_version != "26.2.0-alpha.210"
|
||||
|| rootProject.ambiance_version != "0.0.0-alpha.14"
|
||||
|| rootProject.ambiance_version != "0.0.0-alpha.15"
|
||||
|| rootProject.ouch_version != "0.0.0-alpha.9"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.199"
|
||||
|| migration.source?.modules != [ambiance: "0.0.0-alpha.11", ouch: "0.0.0-alpha.8"]
|
||||
@@ -4488,7 +4497,7 @@ tasks.register("verifyWeatherTntAmbianceRelease") {
|
||||
|| migration.compatibility?.ouch_network_protocol != 1
|
||||
|| migration.compatibility?.save_migration_required != false
|
||||
|| ambianceManifest.custom?.sanctuary26?.data_version != 2
|
||||
|| ambianceManifest.custom?.sanctuary26?.network_protocol != 5
|
||||
|| ambianceManifest.custom?.sanctuary26?.network_protocol != 6
|
||||
|| ouchManifest.custom?.sanctuary26?.data_version != 1
|
||||
|| ouchManifest.custom?.sanctuary26?.network_protocol != 1
|
||||
|| ouchManifest.depends?.ambiance != ">=0.0.0-alpha.12"
|
||||
@@ -5239,7 +5248,7 @@ tasks.register("verifyCanapliaFoundation") {
|
||||
"ambiance/src/main/java/fr/koka99cab/sanctuary26/ambiance/api/visual/AmbianceVisualEffectApi.java").text
|
||||
if (rootProject.pack_version != "26.2.0-alpha.210"
|
||||
|| rootProject.itsalive_version != "0.0.0-alpha.36"
|
||||
|| rootProject.ambiance_version != "0.0.0-alpha.14"
|
||||
|| rootProject.ambiance_version != "0.0.0-alpha.15"
|
||||
|| migration.issue != 35
|
||||
|| migration.source?.fabric_id != "canaplia"
|
||||
|| migration.source?.version != "2.0.0"
|
||||
@@ -5285,7 +5294,7 @@ tasks.register("verifyCanapliaFoundation") {
|
||||
|| itsAliveManifest.custom?.sanctuary26?.data_version != 3
|
||||
|| itsAliveManifest.custom?.sanctuary26?.network_protocol != 4
|
||||
|| ambianceManifest.custom?.sanctuary26?.data_version != 2
|
||||
|| ambianceManifest.custom?.sanctuary26?.network_protocol != 5
|
||||
|| ambianceManifest.custom?.sanctuary26?.network_protocol != 6
|
||||
|| !itsAliveDependencies.contains('implementation project(":ambiance")')
|
||||
|| !itsAliveDependencies.contains('implementation project(":sanctuary")')
|
||||
|| settingsText.contains('"canaplia"')
|
||||
@@ -5417,7 +5426,7 @@ tasks.register("verifyCanapliaGameplay") {
|
||||
|| migration.compatibility?.pack_release_updated != false
|
||||
|| itsAliveManifest.custom?.sanctuary26?.data_version != 3
|
||||
|| itsAliveManifest.custom?.sanctuary26?.network_protocol != 4
|
||||
|| ambianceManifest.custom?.sanctuary26?.network_protocol != 5
|
||||
|| ambianceManifest.custom?.sanctuary26?.network_protocol != 6
|
||||
|| discovery.schema_version != 1 || discovery.sources.size() > 128
|
||||
|| discovery.sources.any {
|
||||
it.chance_numerator < 1 || it.chance_numerator >= it.chance_denominator
|
||||
@@ -5488,7 +5497,7 @@ tasks.register("verifyCanapliaAlpha209Release") {
|
||||
itsalive: "0.0.0-alpha.33",
|
||||
onlyfun: "0.0.0-alpha.20"]
|
||||
if (rootProject.pack_version != "26.2.0-alpha.210"
|
||||
|| rootProject.ambiance_version != expectedTarget.ambiance
|
||||
|| rootProject.ambiance_version != "0.0.0-alpha.15"
|
||||
|| rootProject.itsalive_version != "0.0.0-alpha.36"
|
||||
|| rootProject.onlyfun_version != "0.0.0-alpha.21"
|
||||
|| migration.source?.pack_version != "26.2.0-alpha.207"
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user