fix(zevent): rétablir la vidéo Twitch et publier l’alpha.235 #161

Merged
koka merged 2 commits from fix/zevent-twitch-playback into main 2026-09-04 01:37:17 +00:00
29 changed files with 545 additions and 554 deletions
+11
View File
@@ -10,6 +10,17 @@ Ce journal suit les **versions du modpack**. Lorsqu'un seul module change, sa ve
Aucun changement non publié.
## `26.2.0-alpha.235`
Module modifié temporairement : **ZEVENT `0.0.0-alpha.3`**.
- remplace MCEF Modern, dont Chromium ne sait pas décoder les directs Twitch en H.264/AAC, par Dream Displays `1.9.6` et son pipeline vidéo natif ;
- conserve le bloc-écran 9 × 5, les chaînes enregistrées, linterface et le protocole réseau ZEVENT 1 sans migration de monde ;
- réutilise directement la texture GPU décodée dans le renderer ZEVENT, avec une limite de 720p et deux flux visibles au maximum par client ;
- maintient le volume dans la catégorie Disques, latténuation jusqu’à 64 blocs et un seul écran audible à la fois ;
- réessaie automatiquement les chaînes momentanément indisponibles et affiche désormais un message explicite lorsquune chaîne est hors ligne ;
- valide le démarrage client natif sur macOS Apple Silicon ainsi que la résolution dun direct Twitch actif en cinq qualités H.264 jusquen 1080p avec audio AAC.
## `26.2.0-alpha.234`
Module ajouté temporairement : **ZEVENT `0.0.0-alpha.2`**.
+5 -3
View File
@@ -19,11 +19,13 @@ Licence : **GNU GPL 3.0 or Later** (`GPL-3.0-or-later`)
| `sanctuary` | Sanctuary | progression et aventure | progression globale, classements publics, Atlas et monde principal flottant |
| `events` | Events | événements serveur | socle vide |
| `masterkey` | MasterKey | administration et diagnostic | laboratoire minimal |
| `zevent` | ZEVENT | diffusion Twitch temporaire | écran mural 9×5, inclus dans lalpha.234 pour le week-end |
| `zevent` | ZEVENT | diffusion Twitch temporaire | écran mural 9×5, inclus pour le week-end |
Les descriptions FR/EN/RU sont déclarées dans chaque `fabric.mod.json` et dans les fichiers de langue. Les images fournies servent d'icônes Fabric de leur module.
Version courante du pack distribué : **`26.2.0-alpha.234`**. Cette release conserve Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`**, ajoute temporairement ZEVENT **`0.0.0-alpha.2`** aux dix modules de lalpha.233 et installe MCEF Modern **`0.3.3+mc26.2.jcef146.0.10`** uniquement sur les clients.
Version courante du pack distribué : **`26.2.0-alpha.235`**. Cette release conserve notamment Another World **`0.0.0-alpha.58`**, Sanctuary **`0.0.0-alpha.139`** et Red-Stoner **`0.0.0-alpha.20`**, passe temporairement ZEVENT en **`0.0.0-alpha.3`** et remplace MCEF Modern par Dream Displays **`1.9.6`** afin de décoder réellement les directs Twitch en H.264/AAC.
Lalpha.235 corrige l’écran noir de ZEVENT. MCEF chargeait correctement linterface et les liens Twitch, mais son Chromium ne prenait pas en charge les codecs des directs. L’écran utilise désormais le résolveur HLS et le décodeur natif de Dream Displays, conserve ses options et sa synchronisation serveur, réessaie automatiquement un flux indisponible et indique clairement lorsquune chaîne est hors ligne. Le volume suit toujours la catégorie Disques, décroît jusqu’à 64 blocs et seul l’écran visible le plus proche reste audible.
Lalpha.234 ajoute pour le week-end l’écran Twitch ZEVENT de 9 × 5 blocs. Un clic droit permet de choisir une chaîne officielle ; la sélection est enregistrée par le serveur, tandis que limage et le son sont rendus localement avec MCEF Modern. Le volume suit la catégorie Disques et décroît jusqu’à 64 blocs. Les mondes et les dix modules Sanctuary existants restent compatibles ; le client et le serveur doivent toutefois recevoir le JAR ZEVENT, et seul le client reçoit MCEF.
@@ -421,7 +423,7 @@ Le menu pause est lui aussi possédé par Sanctuary. Il ne conserve que Retour a
## Versions du modpack et mises à jour Modrinth
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Pour lalpha.234, `zevent` rejoint temporairement les archives du pack ; MCEF reste une dépendance strictement cliente afin que le serveur dédié nembarque pas Chromium.
`pack_version` dans `gradle.properties` est la version du pack complet. `mod_version=0.0.0-alpha.0` et `module_lifecycle=standby` sont les valeurs par défaut des modules en veille ; les propriétés `<module>_version` et `<module>_lifecycle` nactivent et ne font avancer que le module concerné. Le contrat de distribution est dans `pack/release.json`. Pour lalpha.235, `zevent` reste temporairement dans les archives du pack et utilise Dream Displays pour la lecture native des flux Twitch.
La version globale du pack nest pas injectée dans les JAR inchangés : leur contenu et leur numéro restent immuables. Le lanceur possède la composition exacte du pack, tandis que chaque manifest conserve la version effective de son module.
+245 -235
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -30,7 +30,7 @@ onlyfun_version=0.0.0-alpha.27
onlyfun_lifecycle=active
sanctuary_version=0.0.0-alpha.139
sanctuary_lifecycle=active
zevent_version=0.0.0-alpha.2
zevent_version=0.0.0-alpha.3
zevent_lifecycle=active
pack_version=26.2.0-alpha.234
pack_version=26.2.0-alpha.235
maven_group=fr.koka99cab.sanctuary26
+1
View File
@@ -1261,6 +1261,7 @@ tasks.named("processResources") {
tasks.named("sourcesJar") {
dependsOn tasks.named("generateAgricultureResources")
dependsOn tasks.named("generateCanapliaResources")
}
tasks.register("verifyCulinaryReliability") {
@@ -0,0 +1,51 @@
{
"schema_version": 1,
"status": "released",
"released_in": "26.2.0-alpha.235",
"temporary_until": "2026-09-07",
"pack": {
"source_version": "26.2.0-alpha.234",
"target_version": "26.2.0-alpha.235"
},
"modules": {
"zevent": {
"source_module_version": "0.0.0-alpha.2",
"target_module_version": "0.0.0-alpha.3",
"data_version": 1,
"network_protocol": 1,
"client_and_server_required": true
}
},
"playback": {
"mcef_removed": true,
"native_hls_decoder": true,
"h264_video": true,
"aac_audio": true,
"maximum_quality_height": 720,
"automatic_retry_seconds": 30,
"offline_feedback": true
},
"dependencies": {
"dream_displays": {
"version": "1.9.6",
"modrinth_project_id": "4IIKQyBu",
"modrinth_version_id": "GxyW2ksW",
"environment": "client_and_server"
}
},
"compatibility": {
"existing_saves_supported": true,
"new_world_required": false,
"save_reset_required": false,
"data_version_changed": false,
"network_protocol_changed": false,
"zevent_client_server_update_required": true
},
"distribution": {
"packwiz_index_refreshed": true,
"prism_modrinth_index_updated": true,
"shared_client_server_mrpack": true,
"mcef_removed": true,
"dream_displays_required": true
}
}
+7 -7
View File
@@ -20,6 +20,11 @@ hash = "960322cd387c47588f6fd76202c0abf2c9993cf0effa82e19f8bb31d86ab07a4"
file = "mods/anotherworld-0.0.0-alpha.58.jar"
hash = "8936b0a5bf3f122afc752cba5e8d96ac92cfb1458785c3fac13d25f51cb4814c"
[[files]]
file = "mods/dreamdisplays.pw.toml"
hash = "b444a117085376d96a0964131ce0e2f2b86f7996314c27288f45f0b8117a36f5"
metafile = true
[[files]]
file = "mods/events-0.0.0-alpha.0.jar"
hash = "1fe3fc2f6ca371f8dde44e0bb535b1e70df0e9c4de3f56002f9f3ec6d5826745"
@@ -51,11 +56,6 @@ metafile = true
file = "mods/masterkey-0.0.0-alpha.9.jar"
hash = "95265c4aa4ebb89d5e89e3245917f47a4044db192f88995cfee2bdf63f348b79"
[[files]]
file = "mods/mcef-modern.pw.toml"
hash = "8a2d7d5c6aae5e1c954bfb18c59b201a08b33b948dd95ed6b2a0e4004b69b49b"
metafile = true
[[files]]
file = "mods/onlyfun-0.0.0-alpha.27.jar"
hash = "dcd5003390c132f3421c12550f851846548b4d1cc541d3ada0deb35c65bb3f54"
@@ -83,5 +83,5 @@ hash = "f06c0b9150da9877b99cb265d037c067e509340d9bf347e02cef2043c110e963"
metafile = true
[[files]]
file = "mods/zevent-0.0.0-alpha.2.jar"
hash = "5123ad255381a783dafdb7b8ba66df56196d30e85f1d507d9c2758bd7ed4f0c5"
file = "mods/zevent-0.0.0-alpha.3.jar"
hash = "98f1af94549e95fabf99141130c2a56b650165bb405cae57af488c2811b907be"
+14
View File
@@ -0,0 +1,14 @@
name = "Dream Displays"
filename = "dreamdisplays-fabric-26.2-1.9.6.jar"
side = "both"
pin = true
[download]
url = "https://cdn.modrinth.com/data/4IIKQyBu/versions/GxyW2ksW/dreamdisplays-fabric-26.2-1.9.6.jar"
hash-format = "sha512"
hash = "c57f7d85e85ac0a65811863dca4b433bd94d4f3c7a6a3e6d08998f5be7d54fd06242afee2f6307b7698535640be1374a425f9452fe68e43710a2a93ff9907e53"
[update]
[update.modrinth]
mod-id = "4IIKQyBu"
version = "GxyW2ksW"
-14
View File
@@ -1,14 +0,0 @@
name = "MCEF Modern"
filename = "mcef-modern-0.3.3+mc26.2.jcef146.0.10.jar"
side = "client"
pin = true
[download]
url = "https://cdn.modrinth.com/data/YJFfu5pH/versions/FOXbUPNd/mcef-modern-0.3.3%2Bmc26.2.jcef146.0.10.jar"
hash-format = "sha512"
hash = "1249687814d906fef8a84d42e3bec0bab7c0e1db8f7cdad3fc03af19668107777e1683e373d29e5ffc4479558d11513d2ae4f40f8f59fd74ee261ff4e69574d7"
[update]
[update.modrinth]
mod-id = "YJFfu5pH"
version = "FOXbUPNd"
+2 -2
View File
@@ -1,12 +1,12 @@
name = "Sanctuary 26.2"
author = "KOKA99CAB"
version = "26.2.0-alpha.234"
version = "26.2.0-alpha.235"
pack-format = "packwiz:1.1.0"
[index]
file = "index.toml"
hash-format = "sha256"
hash = "07587749b1bd7fc7be7bfbab7ae251fccb2936eba203af22dce13e08fbaffc2a"
hash = "4b92999f44991f7d4cbcbf221949ec2fd50602156da16d0655a6e89a554dfda2"
[versions]
fabric = "0.19.3"
+7 -7
View File
@@ -3,7 +3,7 @@
"game": "minecraft",
"name": "Sanctuary 26.2",
"summary": "Sanctuary 26.2 — progression, exploration, worldgen and multiplayer systems by KOKA99CAB.",
"versionId": "26.2.0-alpha.234",
"versionId": "26.2.0-alpha.235",
"files": [
{
"path": "mods/fabric-api-0.159.0+26.2.jar",
@@ -81,19 +81,19 @@
"fileSize": 2820763
},
{
"path": "mods/mcef-modern-0.3.3+mc26.2.jcef146.0.10.jar",
"path": "mods/dreamdisplays-fabric-26.2-1.9.6.jar",
"hashes": {
"sha1": "a90404e9072f918b7029bb03f4a04da240b32986",
"sha512": "1249687814d906fef8a84d42e3bec0bab7c0e1db8f7cdad3fc03af19668107777e1683e373d29e5ffc4479558d11513d2ae4f40f8f59fd74ee261ff4e69574d7"
"sha1": "2f040162d833ced2cd62d9cab459dbb2ee1a7e35",
"sha512": "c57f7d85e85ac0a65811863dca4b433bd94d4f3c7a6a3e6d08998f5be7d54fd06242afee2f6307b7698535640be1374a425f9452fe68e43710a2a93ff9907e53"
},
"env": {
"client": "required",
"server": "unsupported"
"server": "required"
},
"downloads": [
"https://cdn.modrinth.com/data/YJFfu5pH/versions/FOXbUPNd/mcef-modern-0.3.3%2Bmc26.2.jcef146.0.10.jar"
"https://cdn.modrinth.com/data/4IIKQyBu/versions/GxyW2ksW/dreamdisplays-fabric-26.2-1.9.6.jar"
],
"fileSize": 248099
"fileSize": 26232108
}
],
"dependencies": {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"schema_version": 1,
"pack_version": "26.2.0-alpha.234",
"pack_version": "26.2.0-alpha.235",
"release_channel": "alpha",
"minecraft_version": "26.2",
"fabric_loader_version": "0.19.3",
@@ -9,7 +9,7 @@
"project_id": "FgOqyAXd",
"project_slug": "sanctuary-koka99",
"project_url": "https://modrinth.com/modpack/sanctuary-koka99",
"primary_file": "sanctuary-26.2.0-alpha.234.mrpack",
"primary_file": "sanctuary-26.2.0-alpha.235.mrpack",
"shared_client_server_pack": true,
"update_owner": "packwiz-installer",
"check_on_launch": true,
+4 -4
View File
@@ -8,12 +8,12 @@ Mod Fabric événementiel distribué séparément du pack Sanctuary. Il ajoute u
2. Faire un clic droit sur n'importe laquelle de ses 45 cases.
3. Saisir un nom de chaîne (`zeventfr`) ou coller une URL Twitch, puis valider.
La chaîne est enregistrée par le serveur et synchronisée aux joueurs. Le client rend le lecteur Twitch officiel dans la surface. Son volume utilise la catégorie **Disques** de Minecraft et diminue jusqu'au silence à 64 blocs. Seul l'écran le plus proche est audible et deux flux visibles au maximum sont décodés par client.
La chaîne est enregistrée par le serveur et synchronisée aux joueurs. Le client résout le flux Twitch puis le décode nativement dans la surface. Son volume utilise la catégorie **Disques** de Minecraft et diminue jusqu'au silence à 64 blocs. Seul l'écran le plus proche est audible et deux flux visibles au maximum sont décodés par client.
## Dépendance et limites
Le client exige [MCEF Modern](https://modrinth.com/mod/mcef-modern) `0.3.3+mc26.2.jcef146.0.10`. MCEF télécharge son runtime Chromium natif lors de la première utilisation ; une connexion Internet est donc nécessaire. Le lecteur utilise le backend OpenGL pris en charge par cette version de MCEF.
Le client exige [Dream Displays](https://modrinth.com/mod/dream-displays) `1.9.6` pour la résolution HLS Twitch, le décodage H.264/AAC et l'envoi des images vers le GPU. Une connexion Internet est nécessaire. Cette version est épinglée : l'adaptateur ZEVENT utilise `DisplayScreen` pour posséder un lecteur local sans l'enregistrer comme écran Dream Displays, puis réutilise uniquement son `RenderType` dans le renderer du multibloc ZEVENT. Toute mise à jour de Dream Displays doit donc être validée avec `:zevent:check` et un essai de flux Twitch réel.
Le serveur ne reçoit jamais d'URL arbitraire : les entrées sont ramenées à un identifiant Twitch de 1 à 25 caractères (`a-z`, `0-9`, `_`). La page locale est liée uniquement à l'interface loopback et n'expose que le lecteur officiel `player.twitch.tv`.
Le serveur ne reçoit jamais d'URL arbitraire : les entrées sont ramenées à un identifiant Twitch de 1 à 25 caractères (`a-z`, `0-9`, `_`). Le client ne construit ensuite que l'URL canonique `https://www.twitch.tv/<chaine>` remise au résolveur Twitch de Dream Displays.
Documentation du lecteur : [Twitch Embedded Video & Clips](https://dev.twitch.tv/docs/embed/video-and-clips/).
Sources du lecteur : [Dream Displays](https://github.com/arnodoelinger/dreamdisplays).
+1 -2
View File
@@ -3,8 +3,7 @@ repositories {
}
dependencies {
implementation "maven.modrinth:mcef-modern:FOXbUPNd"
compileOnly "me.friwi:jcef-api:jcef-d3de827+cef-146.0.10+g8219561+chromium-146.0.7680.179"
implementation "maven.modrinth:dreamdisplays:GxyW2ksW"
testImplementation "org.junit.jupiter:junit-jupiter:5.11.4"
testRuntimeOnly "org.junit.platform:junit-platform-launcher:1.11.4"
}
@@ -29,6 +29,16 @@ public final class ZeventScreenLayout {
);
}
/** Returns the minimum world-space corner used by rectangular media renderers. */
public static BlockPos anchor(BlockPos controller, Direction facing) {
BlockPos first = partPos(controller, facing, 0, 0);
BlockPos last = partPos(controller, facing, WIDTH - 1, HEIGHT - 1);
return new BlockPos(
Math.min(first.getX(), last.getX()),
Math.min(first.getY(), last.getY()),
Math.min(first.getZ(), last.getZ()));
}
public static BlockPos controllerPos(BlockPos part, BlockState state) {
Direction facing = state.getValue(ZeventScreenBlock.FACING);
int column = state.getValue(ZeventScreenBlock.COLUMN);
@@ -24,6 +24,6 @@ public final class ZeventClient implements ClientModInitializer {
ClientTickEvents.END_CLIENT_TICK.register(TwitchBrowserManager::tick);
ClientPlayConnectionEvents.DISCONNECT.register((handler, client) -> TwitchBrowserManager.clear(client));
ClientLifecycleEvents.CLIENT_STOPPING.register(TwitchBrowserManager::shutdown);
ZeventMod.LOGGER.info("[{}] Twitch browser renderer registered.", ZeventMod.DISPLAY_NAME);
ZeventMod.LOGGER.info("[{}] Native Twitch renderer registered.", ZeventMod.DISPLAY_NAME);
}
}
@@ -11,14 +11,11 @@ import net.minecraft.client.renderer.SubmitNodeCollector;
import net.minecraft.client.renderer.blockentity.BlockEntityRenderer;
import net.minecraft.client.renderer.blockentity.BlockEntityRendererProvider;
import net.minecraft.client.renderer.feature.ModelFeatureRenderer;
import net.minecraft.client.renderer.rendertype.RenderTypes;
import net.minecraft.client.renderer.state.level.CameraRenderState;
import net.minecraft.client.renderer.texture.OverlayTexture;
import net.minecraft.core.Direction;
import net.minecraft.util.LightCoordsUtil;
import net.minecraft.world.phys.Vec3;
/** Projects one browser texture over the central block entity's complete 9x5 surface. */
/** Projects one decoded video texture over the central block entity's complete 9x5 surface. */
public final class ZeventScreenBlockEntityRenderer
implements BlockEntityRenderer<ZeventScreenBlockEntity, ZeventScreenRenderState> {
private static final int VIEW_DISTANCE = 128;
@@ -39,18 +36,19 @@ public final class ZeventScreenBlockEntityRenderer
public void extractRenderState(ZeventScreenBlockEntity screen, ZeventScreenRenderState state,
float tickProgress, Vec3 cameraPos, ModelFeatureRenderer.CrumblingOverlay crumblingOverlay) {
BlockEntityRenderer.super.extractRenderState(screen, state, tickProgress, cameraPos, crumblingOverlay);
state.texture = null;
state.renderType = null;
if (!ZeventScreenLayout.isController(screen.getBlockState()) || screen.getLevel() == null) return;
state.facing = screen.getBlockState().getValue(ZeventScreenBlock.FACING);
state.texture = TwitchBrowserManager.markVisible(screen.getLevel(), screen.getBlockPos(), screen.channel());
state.renderType = TwitchBrowserManager.markVisible(
screen.getLevel(), screen.getBlockPos(), state.facing, screen.channel());
}
@Override
public void submit(ZeventScreenRenderState state, PoseStack poseStack,
SubmitNodeCollector submitNodeCollector, CameraRenderState cameraRenderState) {
if (state.texture == null) return;
if (state.renderType == null) return;
submitNodeCollector.submitCustomGeometry(poseStack,
RenderTypes.entityTranslucentEmissive(state.texture),
state.renderType,
(pose, consumer) -> drawScreen(pose, consumer, state.facing));
}
@@ -74,22 +72,19 @@ public final class ZeventScreenBlockEntityRenderer
Direction right = ZeventScreenLayout.right(facing);
float centerX = 0.5F - facing.getStepX() * CENTER_TO_FRONT;
float centerZ = 0.5F - facing.getStepZ() * CENTER_TO_FRONT;
vertex(consumer, pose, centerX, centerZ, right, facing, -HALF_WIDTH, -HALF_HEIGHT, 0.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, facing, HALF_WIDTH, -HALF_HEIGHT, 1.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, facing, HALF_WIDTH, HALF_HEIGHT, 1.0F, 0.0F);
vertex(consumer, pose, centerX, centerZ, right, facing, -HALF_WIDTH, HALF_HEIGHT, 0.0F, 0.0F);
vertex(consumer, pose, centerX, centerZ, right, -HALF_WIDTH, -HALF_HEIGHT, 0.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, HALF_WIDTH, -HALF_HEIGHT, 1.0F, 1.0F);
vertex(consumer, pose, centerX, centerZ, right, HALF_WIDTH, HALF_HEIGHT, 1.0F, 0.0F);
vertex(consumer, pose, centerX, centerZ, right, -HALF_WIDTH, HALF_HEIGHT, 0.0F, 0.0F);
}
private static void vertex(VertexConsumer consumer, PoseStack.Pose pose, float centerX, float centerZ,
Direction right, Direction normal, float horizontal, float vertical, float u, float v) {
Direction right, float horizontal, float vertical, float u, float v) {
consumer.addVertex(pose,
centerX + right.getStepX() * horizontal,
0.5F + vertical,
centerZ + right.getStepZ() * horizontal)
.setColor(0xFFFFFFFF)
.setUv(u, v)
.setOverlay(OverlayTexture.NO_OVERLAY)
.setLight(LightCoordsUtil.FULL_BRIGHT)
.setNormal(pose, normal.getStepX(), 0.0F, normal.getStepZ());
.setColor(0xFFFFFFFF);
}
}
@@ -1,10 +1,10 @@
package fr.koka99cab.sanctuary26.zevent.client.render.state;
import net.minecraft.client.renderer.blockentity.state.BlockEntityRenderState;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.core.Direction;
import net.minecraft.resources.Identifier;
public final class ZeventScreenRenderState extends BlockEntityRenderState {
public Direction facing = Direction.NORTH;
public Identifier texture;
public RenderType renderType;
}
@@ -1,34 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import com.mojang.blaze3d.textures.GpuTexture;
import com.mojang.blaze3d.textures.GpuTextureView;
import net.dimaskama.mcef.api.MCEFBrowser;
import net.minecraft.client.renderer.texture.AbstractTexture;
/** Texture-manager bridge; MCEF remains the sole owner of the GPU allocation. */
final class BrowserTexture extends AbstractTexture {
private final MCEFBrowser browser;
BrowserTexture(MCEFBrowser browser) {
this.browser = browser;
}
@Override
public GpuTexture getTexture() {
GpuTexture current = browser.getTexture();
if (current == null) throw new IllegalStateException("MCEF browser texture is not ready");
return current;
}
@Override
public GpuTextureView getTextureView() {
GpuTextureView current = browser.getTextureView();
if (current == null) throw new IllegalStateException("MCEF browser texture view is not ready");
return current;
}
@Override
public void close() {
// The browser closes its changing texture and view; the texture manager only drops this bridge.
}
}
@@ -1,56 +1,72 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import com.dreamdisplays.api.display.model.property.DisplayFacing;
import com.dreamdisplays.api.display.model.property.DisplayRotation;
import com.dreamdisplays.api.media.audio.service.AudioAcousticsService;
import com.dreamdisplays.api.media.audio.service.keys.AudioAcousticsServices;
import com.dreamdisplays.api.playback.model.PlaybackMode;
import com.dreamdisplays.platform.client.core.DreamServices;
import com.dreamdisplays.platform.client.displays.DisplayScreen;
import fr.koka99cab.sanctuary26.zevent.ZeventMod;
import fr.koka99cab.sanctuary26.zevent.block.ZeventScreenLayout;
import fr.koka99cab.sanctuary26.zevent.block.entity.ZeventScreenBlockEntity;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.Locale;
import java.util.Map;
import net.dimaskama.mcef.api.MCEFApi;
import net.dimaskama.mcef.api.MCEFBrowser;
import java.util.UUID;
import net.minecraft.client.Minecraft;
import net.minecraft.client.renderer.rendertype.RenderType;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.resources.Identifier;
import net.minecraft.resources.ResourceKey;
import net.minecraft.sounds.SoundSource;
import net.minecraft.world.level.Level;
import net.minecraft.world.phys.Vec3;
/** Owns a small, distance-bounded set of official Twitch browser players. */
/** Owns a small, distance-bounded set of native Twitch players supplied by Dream Displays. */
public final class TwitchBrowserManager {
private static final int BROWSER_WIDTH = 900;
private static final int BROWSER_HEIGHT = 500;
private static final int MAXIMUM_BROWSERS = 2;
private static final int MAXIMUM_PLAYERS = 2;
private static final int STALE_TICKS = 40;
private static final int AUDIBLE_FRESH_TICKS = 10;
private static final int RETRY_TICKS = 20 * 30;
private static final double AUDIO_DISTANCE = 64.0D;
private static final double AUDIO_DISTANCE_SQR = AUDIO_DISTANCE * AUDIO_DISTANCE;
private static final double FULL_VOLUME_DISTANCE = 4.0D;
private static final TwitchPlayerPageServer PAGE_SERVER = new TwitchPlayerPageServer();
private static final Map<ScreenKey, Session> SESSIONS = new LinkedHashMap<>();
private static boolean initializationNoticeShown;
private TwitchBrowserManager() {
}
public static Identifier markVisible(Level level, BlockPos pos, String requestedChannel) {
public static RenderType markVisible(Level level, BlockPos pos, Direction facing, String requestedChannel) {
Minecraft client = Minecraft.getInstance();
if (client.level != level || client.player == null) return null;
String channel = TwitchChannel.normalize(requestedChannel).orElse(TwitchChannel.DEFAULT);
ScreenKey key = new ScreenKey(level.dimension(), pos.immutable());
Session session = SESSIONS.get(key);
if (session != null && session.facing != facing) {
remove(session);
session = null;
}
if (session == null) {
if (!admit(client, pos)) return null;
session = new Session(key, channel, textureId(key));
session = start(client, key, facing, channel);
SESSIONS.put(key, session);
}
session.lastSeenTick = level.getGameTime();
if (!session.channel.equals(channel)) session.changeChannel(channel);
if (session.browser == null && !session.starting && !session.failed) start(client, session);
return session.browser != null && session.browser.getTextureView() != null ? session.textureId : null;
if (session.player == null) return null;
try {
session.player.fitTexture();
return session.player.isVideoStarted() ? session.player.getRenderType() : null;
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.warn("Unable to upload a ZEVENT Twitch frame", exception);
return null;
}
}
public static void tick(Minecraft client) {
@@ -65,68 +81,100 @@ public final class TwitchBrowserManager {
boolean missing = !client.level.hasChunkAt(session.key.pos)
|| !(client.level.getBlockEntity(session.key.pos) instanceof ZeventScreenBlockEntity screen)
|| !ZeventScreenLayout.isController(screen.getBlockState());
if (wrongLevel || stale || missing) remove(client, session);
if (wrongLevel || stale || missing) {
remove(session);
continue;
}
if (session.player != null) session.player.tick(session.key.pos);
if (session.player != null) {
if (session.player.isVideoStarted()) session.errorReported = false;
if (session.player.getMediaError() != null) {
if (!session.errorReported) {
session.errorReported = true;
client.player.sendOverlayMessage(Component.translatable(
"message.zevent.player.unavailable", session.channel));
ZeventMod.LOGGER.warn("Twitch channel {} could not be played: {}",
session.channel, session.player.getMediaError().getMessage());
}
if (now - session.lastRetryTick >= RETRY_TICKS) {
session.lastRetryTick = now;
session.player.retryVideo();
}
}
}
}
if (now % 5L == 0L) updateVolumes(client, now);
updateVolumes(client, now);
}
public static void clear(Minecraft client) {
for (Session session : SESSIONS.values().toArray(Session[]::new)) remove(client, session);
for (Session session : SESSIONS.values().toArray(Session[]::new)) remove(session);
SESSIONS.clear();
initializationNoticeShown = false;
}
public static void shutdown(Minecraft client) {
clear(client);
PAGE_SERVER.stop();
}
private static boolean admit(Minecraft client, BlockPos requested) {
if (SESSIONS.size() < MAXIMUM_BROWSERS) return true;
if (SESSIONS.size() < MAXIMUM_PLAYERS) return true;
Session farthest = SESSIONS.values().stream().max(Comparator.comparingDouble(
session -> distanceSqr(client, session.key.pos))).orElse(null);
if (farthest == null || distanceSqr(client, requested) >= distanceSqr(client, farthest.key.pos)) return false;
remove(client, farthest);
remove(farthest);
return true;
}
private static void start(Minecraft client, Session session) {
session.starting = true;
private static Session start(Minecraft client, ScreenKey key, Direction facing, String channel) {
Session session = new Session(key, facing, channel);
if (!initializationNoticeShown && client.player != null) {
initializationNoticeShown = true;
client.player.sendOverlayMessage(Component.translatable("message.zevent.player.initializing"));
}
MCEFApi.getInstanceFuture().whenComplete((api, error) -> client.execute(() -> {
if (SESSIONS.get(session.key) != session) return;
session.starting = false;
if (error != null) {
session.failed = true;
ZeventMod.LOGGER.error("Unable to initialize MCEF for the ZEVENT screen", error);
return;
DisplayScreen player = null;
try {
BlockPos anchor = ZeventScreenLayout.anchor(key.pos, facing);
UUID displayId = UUID.nameUUIDFromBytes(("zevent:" + key.dimension.identifier() + ":" + key.pos.asLong())
.getBytes(StandardCharsets.UTF_8));
player = new DisplayScreen(
displayId,
client.player.getUUID(),
anchor.getX(), anchor.getY(), anchor.getZ(),
DisplayFacing.valueOf(facing.getName().toUpperCase(Locale.ROOT)),
ZeventScreenLayout.WIDTH, ZeventScreenLayout.HEIGHT,
PlaybackMode.LOCAL, 720, DisplayRotation.NONE,
key.dimension.identifier().toString());
session.player = player;
// ZEVENT owns distance and the Minecraft Records slider; keep Dream's DSP on its legacy-gain path.
player.setAcousticsEnabled(false);
player.createTexture();
player.loadVideo(TwitchChannel.streamUrl(channel), "");
} catch (RuntimeException | LinkageError exception) {
if (player != null) {
try {
unregisterPlayer(player);
} catch (RuntimeException | LinkageError cleanupException) {
exception.addSuppressed(cleanupException);
}
}
try {
MCEFBrowser browser = api.createBrowser(PAGE_SERVER.playerUrl(session.channel), false);
browser.resize(BROWSER_WIDTH, BROWSER_HEIGHT);
browser.setFocus(false);
session.browser = browser;
client.getTextureManager().register(session.textureId, new BrowserTexture(browser));
session.textureRegistered = true;
} catch (IOException | RuntimeException | LinkageError exception) {
session.failed = true;
ZeventMod.LOGGER.error("Unable to create the ZEVENT Twitch player", exception);
}
}));
session.player = null;
ZeventMod.LOGGER.error("Unable to create the native ZEVENT Twitch player", exception);
if (client.player != null) client.player.sendOverlayMessage(Component.translatable(
"message.zevent.player.unavailable", channel));
}
return session;
}
private static void updateVolumes(Minecraft client, long now) {
Session nearest = SESSIONS.values().stream()
.filter(session -> session.browser != null && now - session.lastSeenTick <= AUDIBLE_FRESH_TICKS)
.filter(session -> session.player != null && now - session.lastSeenTick <= AUDIBLE_FRESH_TICKS)
.filter(session -> distanceSqr(client, session.key.pos) <= AUDIO_DISTANCE_SQR)
.min(Comparator.comparingDouble(session -> distanceSqr(client, session.key.pos)))
.orElse(null);
for (Session session : SESSIONS.values()) {
if (session.player == null) continue;
float volume = session == nearest ? attenuatedVolume(client, session.key.pos) : 0.0F;
session.updateVolume(volume);
session.player.setVideoVolume(volume);
}
}
@@ -143,23 +191,28 @@ public final class TwitchBrowserManager {
: client.player.distanceToSqr(Vec3.atCenterOf(pos));
}
private static void remove(Minecraft client, Session session) {
private static void remove(Session session) {
if (SESSIONS.remove(session.key) == null) return;
if (session.browser != null) session.updateVolume(0.0F);
if (session.textureRegistered) client.getTextureManager().release(session.textureId);
if (session.browser != null) {
if (session.player != null) {
try {
session.browser.close();
unregisterPlayer(session.player);
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.warn("Unable to close a ZEVENT browser cleanly", exception);
ZeventMod.LOGGER.warn("Unable to close a ZEVENT Twitch player cleanly", exception);
}
session.browser = null;
session.player = null;
}
}
private static Identifier textureId(ScreenKey key) {
String dimension = Integer.toUnsignedString(key.dimension.identifier().hashCode(), 36);
return ZeventMod.id("live/" + dimension + "/" + Long.toUnsignedString(key.pos.asLong(), 36));
private static void unregisterPlayer(DisplayScreen player) {
player.setVideoVolume(0.0F);
UUID displayId = player.getUuid();
try {
player.unregister();
} finally {
AudioAcousticsService acoustics = DreamServices.INSTANCE.getRegistry()
.getOrNull(AudioAcousticsServices.INSTANCE.getACOUSTICS());
if (acoustics != null) acoustics.unregisterSource(displayId);
}
}
private record ScreenKey(ResourceKey<Level> dimension, BlockPos pos) {
@@ -167,40 +220,24 @@ public final class TwitchBrowserManager {
private static final class Session {
private final ScreenKey key;
private final Identifier textureId;
private final Direction facing;
private String channel;
private MCEFBrowser browser;
private DisplayScreen player;
private long lastSeenTick;
private float lastVolume = -1.0F;
private boolean starting;
private boolean failed;
private boolean textureRegistered;
private long lastRetryTick;
private boolean errorReported;
private Session(ScreenKey key, String channel, Identifier textureId) {
private Session(ScreenKey key, Direction facing, String channel) {
this.key = key;
this.facing = facing;
this.channel = channel;
this.textureId = textureId;
}
private void changeChannel(String next) {
channel = next;
if (browser == null) return;
execute("window.zeventSetChannel&&window.zeventSetChannel('" + next + "');");
}
private void updateVolume(float volume) {
if (browser == null || Math.abs(volume - lastVolume) < 0.01F) return;
lastVolume = volume;
execute("window.zeventSetVolume&&window.zeventSetVolume(" + Float.toString(volume) + ");");
}
private void execute(String script) {
try {
String url = browser.getCefBrowser().getURL();
browser.getCefBrowser().executeJavaScript(script, url == null ? "" : url, 0);
} catch (RuntimeException | LinkageError exception) {
ZeventMod.LOGGER.debug("ZEVENT browser command failed: {}", exception.getMessage());
}
lastRetryTick = 0L;
errorReported = false;
if (player != null) player.loadVideo(TwitchChannel.streamUrl(next), "");
}
}
}
@@ -1,108 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import fr.koka99cab.sanctuary26.zevent.twitch.TwitchChannel;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/** Loopback-only origin required by Twitch's official interactive embed. */
final class TwitchPlayerPageServer {
private static final String PLAYER_PATH = "/player/";
private HttpServer server;
private ExecutorService executor;
synchronized String playerUrl(String channel) throws IOException {
if (!TwitchChannel.valid(channel)) throw new IllegalArgumentException("Invalid Twitch channel");
ensureStarted();
return "http://localhost:" + server.getAddress().getPort() + PLAYER_PATH + channel;
}
synchronized void stop() {
if (server != null) {
server.stop(0);
server = null;
}
if (executor != null) {
executor.shutdownNow();
executor = null;
}
}
private void ensureStarted() throws IOException {
if (server != null) return;
server = HttpServer.create(new InetSocketAddress(InetAddress.getLoopbackAddress(), 0), 0);
executor = Executors.newSingleThreadExecutor(runnable -> {
Thread thread = new Thread(runnable, "ZEVENT Twitch page");
thread.setDaemon(true);
return thread;
});
server.setExecutor(executor);
server.createContext(PLAYER_PATH, this::servePlayer);
server.start();
}
private void servePlayer(HttpExchange exchange) throws IOException {
try (exchange) {
if (!"GET".equals(exchange.getRequestMethod())) {
exchange.sendResponseHeaders(405, -1L);
return;
}
String path = exchange.getRequestURI().getPath();
String channel = path.startsWith(PLAYER_PATH)
? TwitchChannel.normalize(path.substring(PLAYER_PATH.length())).orElse(null)
: null;
if (channel == null) {
exchange.sendResponseHeaders(404, -1L);
return;
}
byte[] body = page(channel).getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.getResponseHeaders().set("Cache-Control", "no-store");
exchange.getResponseHeaders().set("X-Content-Type-Options", "nosniff");
exchange.getResponseHeaders().set("Referrer-Policy", "no-referrer");
exchange.getResponseHeaders().set("Content-Security-Policy",
"default-src 'self' https: wss: blob: data:; script-src 'unsafe-inline' https://player.twitch.tv; "
+ "frame-src https://player.twitch.tv; style-src 'unsafe-inline'");
exchange.sendResponseHeaders(200, body.length);
exchange.getResponseBody().write(body);
}
}
private static String page(String channel) {
return """
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body,#player{margin:0;width:100%;height:100%;overflow:hidden;background:#000}
iframe{display:block;width:100%;height:100%;border:0}
</style>
<script src="https://player.twitch.tv/js/embed/v1.js"></script>
</head>
<body><div id="player"></div>
<script>
const initialChannel="__CHANNEL__";
let desiredVolume=0;
let player=new Twitch.Player("player",{
width:"100%",height:"100%",channel:initialChannel,
autoplay:true,muted:true,parent:["localhost"]
});
window.zeventSetVolume=function(value){
desiredVolume=Math.max(0,Math.min(1,Number(value)||0));
if(!player)return;
player.setVolume(desiredVolume);
player.setMuted(desiredVolume<=0.001);
};
window.zeventSetChannel=function(channel){if(player)player.setChannel(channel);};
player.addEventListener(Twitch.Player.READY,function(){window.zeventSetVolume(desiredVolume);});
</script></body></html>
""".replace("__CHANNEL__", channel);
}
}
@@ -39,6 +39,11 @@ public final class TwitchChannel {
return channel != null && NAME.matcher(channel).matches();
}
public static String streamUrl(String channel) {
if (!valid(channel)) throw new IllegalArgumentException("Invalid Twitch channel");
return "https://www.twitch.tv/" + channel;
}
private static Optional<String> channelFromUrl(String input) {
try {
URI uri = URI.create(input);
@@ -13,5 +13,6 @@
"message.zevent.screen.too_far": "You are too far away from the screen.",
"message.zevent.screen.missing": "The ZEVENT screen no longer exists.",
"message.zevent.screen.newer_data": "This screen uses newer data and is read-only.",
"message.zevent.player.initializing": "Initializing the Twitch player…"
"message.zevent.player.initializing": "Initializing the Twitch decoder…",
"message.zevent.player.unavailable": "Channel twitch.tv/%s is offline or temporarily unavailable."
}
@@ -13,5 +13,6 @@
"message.zevent.screen.too_far": "Tu es trop loin de l'écran.",
"message.zevent.screen.missing": "L'écran ZEVENT n'existe plus.",
"message.zevent.screen.newer_data": "Cet écran utilise des données plus récentes et reste en lecture seule.",
"message.zevent.player.initializing": "Initialisation du lecteur Twitch…"
"message.zevent.player.initializing": "Initialisation du décodeur Twitch…",
"message.zevent.player.unavailable": "La chaîne twitch.tv/%s est hors ligne ou momentanément inaccessible."
}
@@ -13,5 +13,6 @@
"message.zevent.screen.too_far": "Вы слишком далеко от экрана.",
"message.zevent.screen.missing": "Экран ZEVENT больше не существует.",
"message.zevent.screen.newer_data": "Этот экран использует более новые данны и доступен только для чтения.",
"message.zevent.player.initializing": "Инициализация плеера Twitch…"
"message.zevent.player.initializing": "Инициализация декодера Twitch…",
"message.zevent.player.unavailable": "Канал twitch.tv/%s не в сети или временно недоступен."
}
+2 -4
View File
@@ -22,10 +22,8 @@
"fabricloader": ">=0.19.3",
"minecraft": "~26.2",
"java": ">=25",
"fabric-api": ">=0.159.0+26.2"
},
"suggests": {
"mcef-modern": ">=0.3.3"
"fabric-api": ">=0.159.0+26.2",
"dreamdisplays": ">=1.9.6"
},
"custom": {
"sanctuary26": {
@@ -0,0 +1,34 @@
package fr.koka99cab.sanctuary26.zevent.block;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import org.junit.jupiter.api.Test;
class ZeventScreenLayoutTest {
@Test
void mediaAnchorAlwaysCoversTheCompleteNineByFiveScreen() {
BlockPos controller = new BlockPos(20, 70, -10);
for (Direction facing : Direction.Plane.HORIZONTAL) {
BlockPos anchor = ZeventScreenLayout.anchor(controller, facing);
for (int column = 0; column < ZeventScreenLayout.WIDTH; column++) {
for (int row = 0; row < ZeventScreenLayout.HEIGHT; row++) {
BlockPos part = ZeventScreenLayout.partPos(controller, facing, column, row);
assertTrue(part.getY() >= anchor.getY());
assertTrue(part.getY() < anchor.getY() + ZeventScreenLayout.HEIGHT);
if (facing.getAxis() == Direction.Axis.Z) {
assertTrue(part.getX() >= anchor.getX());
assertTrue(part.getX() < anchor.getX() + ZeventScreenLayout.WIDTH);
assertEquals(anchor.getZ(), part.getZ());
} else {
assertTrue(part.getZ() >= anchor.getZ());
assertTrue(part.getZ() < anchor.getZ() + ZeventScreenLayout.WIDTH);
assertEquals(anchor.getX(), part.getX());
}
}
}
}
}
}
@@ -1,30 +0,0 @@
package fr.koka99cab.sanctuary26.zevent.client.twitch;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import org.junit.jupiter.api.Test;
class TwitchPlayerPageServerTest {
@Test
void servesOnlyTheBoundedOfficialPlayerPage() throws Exception {
TwitchPlayerPageServer server = new TwitchPlayerPageServer();
try (HttpClient client = HttpClient.newHttpClient()) {
String url = server.playerUrl("zeventfr");
HttpResponse<String> response = client.send(
HttpRequest.newBuilder(URI.create(url)).GET().build(), HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("https://player.twitch.tv/js/embed/v1.js"));
assertTrue(response.body().contains("const initialChannel=\"zeventfr\""));
assertTrue(response.headers().firstValue("Content-Security-Policy").isPresent());
assertThrows(IllegalArgumentException.class, () -> server.playerUrl("bad-channel!"));
} finally {
server.stop();
}
}
}
@@ -19,4 +19,11 @@ class TwitchChannelTest {
assertTrue(TwitchChannel.normalize("bad-channel!").isEmpty());
assertTrue(TwitchChannel.normalize("a".repeat(TwitchChannel.MAX_NAME_LENGTH + 1)).isEmpty());
}
@Test
void buildsOnlyValidatedPlaybackUrls() {
assertEquals("https://www.twitch.tv/zeventfr", TwitchChannel.streamUrl("zeventfr"));
org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class,
() -> TwitchChannel.streamUrl("bad-channel!"));
}
}