Compare commits

..

3 Commits

Author SHA1 Message Date
Claude (owner)
b53f51a0b4 v0.4.34: purge() cancels in-flight download (per-URL cancellation)
Some checks failed
build / build (push) Failing after 1m16s
videoCache remove(=purge) 가 다운로드 중인 항목도 취소하도록 per-URL 세대
(DOWNLOAD_GEN) 채널 추가. 기존엔 READY/디스크 최종 파일만 지워서, 느린/큰
영상이 다운로드 중에 evict 되면 완료 후 다시 디스크+READY 에 되살아남.
이제 purge 가 세대를 제거 → download 의 cancelled() 검사(읽기 루프 + 모든
publish 지점)가 실패 → .part/승격 파일 self-clean. clearAll/wipe/clearIndex
도 DOWNLOAD_GEN 정리. 데이터팩 keep(FIFO) 메모리 관리 보장에 필요.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-04 22:40:20 +09:00
tkrmagid
e31635ef24 Fix black-screen video on unaligned widths (swscale row padding)
Some checks failed
build / build (push) Failing after 1m41s
swscale aligns the RGBA linesize to a SIMD boundary, so any video whose
width*4 isn't a multiple of the alignment (e.g. 1674-wide → 6720 B/row vs
6696) yields a padded plane larger than width*height*4. The old copy path
tripped a `need > expected → skip` guard and dropped every video frame,
leaving a black screen while audio (independent path) still played.

Read Frame.imageStride and strip the row padding with a row-by-row memCopy
into a tightly-packed slot (width*height*4), matching the texture's expected
size. Falls back to deriving the stride from buffer size when the field is
absent. Single-shot memCopy retained when stride == rowBytes (aligned widths).

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-02 00:59:44 +09:00
tkrmagid
7364e010ac v0.4.32: route video audio to RECORDS slider + make sound category configurable
Some checks failed
build / build (push) Failing after 1m18s
Previously video audio gain was gated by the PLAYERS sound category, so the
"Players" slider controlled video volume. Video playback is media, so it now
defaults to the RECORDS ("Jukebox/Note Blocks" / 음반) slider instead.

Add a `sound_category` key to config/video_player.json (auto-augmented into
existing configs) so each client can pick which volume slider gates video
audio: master, music, record, weather, block, hostile, neutral, player,
ambient, voice, ui. Invalid values fall back to record with a warning. When
the category is master itself the gain is not squared.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:40:51 +09:00
5 changed files with 177 additions and 48 deletions

View File

@@ -5,7 +5,7 @@ org.gradle.configuration-cache=false
# Mod
mod_id=video_player
mod_version=0.4.31
mod_version=0.4.34
maven_group=com.ejclaw.videoplayer
archives_base_name=video_player

View File

@@ -100,17 +100,22 @@ public class VideoPlayerClient implements ClientModInitializer {
*
* <p>Gain is also gated by the Minecraft sound options so the in-game sliders work as
* expected: vanilla {@code SoundEngine.calculateVolume} multiplies by master × category, so
* we do the same with {@link SoundSource#PLAYERS} as the category. Result: dragging the
* "Players" slider in Options → Music & Sounds attenuates video audio just like other
* player sounds, and "Master" still gates everything.
* we do the same. The category is configurable via {@code sound_category} in
* {@code config/video_player.json} (default {@link SoundSource#RECORDS} = the "Jukebox/Note
* Blocks" slider, since video playback is media). Master always gates on top; when the
* configured category is Master itself we don't square it. Result: dragging the configured
* category's slider in Options → Music &amp; Sounds attenuates video audio, and "Master"
* still gates everything.
*/
private static void updateDistanceGains(Minecraft client) {
LocalPlayer p = client.player;
if (p == null || client.level == null) return;
Vec3 eye = p.getEyePosition();
float masterVol = client.options.getSoundSourceVolume(SoundSource.MASTER);
float playersVol = client.options.getSoundSourceVolume(SoundSource.PLAYERS);
float categoryScale = masterVol * playersVol;
SoundSource category = VideoPlayerConfig.soundCategory();
float categoryScale = (category == SoundSource.MASTER)
? masterVol
: masterVol * client.options.getSoundSourceVolume(category);
for (BlockPos pos : VideoPlayback.activePositions()) {
if (!(client.level.getBlockEntity(pos) instanceof VideoAnchorBlockEntity be)) continue;
Vec3 center = be.panelCenter();

View File

@@ -7,6 +7,7 @@ import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.sounds.SoundSource;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
@@ -62,10 +63,17 @@ public final class VideoPlayerConfig {
private static final int DEFAULT_MAX_CACHE_MB = 750;
/** Default render-distance cap for video anchors, in blocks. 128 = the legacy hard-coded value. */
private static final int DEFAULT_RENDER_DISTANCE = 128;
/**
* Default Minecraft sound category the client uses to gate video audio. RECORDS = the
* "Jukebox/Note Blocks" (음반) slider — video playback is media, so it belongs there rather
* than on the player-sound slider. Master always gates on top of whatever category is set.
*/
private static final SoundSource DEFAULT_SOUND_CATEGORY = SoundSource.RECORDS;
private static volatile int maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
private static volatile int maxCacheMb = DEFAULT_MAX_CACHE_MB;
private static volatile int renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
private static volatile SoundSource soundCategory = DEFAULT_SOUND_CATEGORY;
private static volatile List<String> preloadUrls = Collections.emptyList();
/** Insertion-ordered name → url. Mutated only under the class monitor. */
private static final Map<String, String> CACHE_ENTRIES = new LinkedHashMap<>();
@@ -80,6 +88,8 @@ public final class VideoPlayerConfig {
VideoPlayerMod.MOD_ID, path);
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
return;
@@ -127,6 +137,23 @@ public final class VideoPlayerConfig {
if (rd > 2048) rd = 2048;
renderDistanceBlocks = rd;
// sound_category (client uses this to pick which volume slider gates video audio)
SoundSource cat = DEFAULT_SOUND_CATEGORY;
if (json.has("sound_category") && json.get("sound_category").isJsonPrimitive()
&& json.get("sound_category").getAsJsonPrimitive().isString()) {
SoundSource parsed = parseSoundSource(json.get("sound_category").getAsString());
if (parsed != null) {
cat = parsed;
} else {
VideoPlayerMod.LOG.warn("[{}] config: unknown sound_category '{}' — using '{}'",
VideoPlayerMod.MOD_ID, json.get("sound_category").getAsString(),
DEFAULT_SOUND_CATEGORY.getName());
}
} else {
augmented = true;
}
soundCategory = cat;
// preload_urls (legacy)
List<String> urls = new ArrayList<>();
if (json.has("preload_urls") && json.get("preload_urls").isJsonArray()) {
@@ -162,9 +189,9 @@ public final class VideoPlayerConfig {
VideoPlayerMod.LOG.info(
"[{}] config loaded: per-video={} MB, total-cache={} MB, render={} blocks, "
+ "preload_urls={}, cache_entries={}",
+ "sound_category={}, preload_urls={}, cache_entries={}",
VideoPlayerMod.MOD_ID, maxPreloadMb, maxCacheMb, renderDistanceBlocks,
urls.size(), CACHE_ENTRIES.size());
soundCategory.getName(), urls.size(), CACHE_ENTRIES.size());
// Auto-augment: rewrite the file once so missing keys appear after a mod update.
if (augmented) {
@@ -179,11 +206,23 @@ public final class VideoPlayerConfig {
maxPreloadMb = DEFAULT_MAX_PRELOAD_MB;
maxCacheMb = DEFAULT_MAX_CACHE_MB;
renderDistanceBlocks = DEFAULT_RENDER_DISTANCE;
soundCategory = DEFAULT_SOUND_CATEGORY;
preloadUrls = Collections.emptyList();
CACHE_ENTRIES.clear();
}
}
/** Match a config string against {@link SoundSource#getName()} (case-insensitive). Null = no match. */
private static SoundSource parseSoundSource(String s) {
if (s == null) return null;
String t = s.trim();
if (t.isEmpty()) return null;
for (SoundSource src : SoundSource.values()) {
if (src.getName().equalsIgnoreCase(t)) return src;
}
return null;
}
// -- accessors ---------------------------------------------------------------------------
/** Hard cap on a single client-side video download, in MB. */
@@ -201,6 +240,12 @@ public final class VideoPlayerConfig {
/** Anchor BE view-distance cap, in blocks. */
public static int renderDistanceBlocks() { return renderDistanceBlocks; }
/**
* Minecraft sound category the client gates video audio with (default {@link SoundSource#RECORDS}).
* Read client-side from the local config; not pushed from the server.
*/
public static SoundSource soundCategory() { return soundCategory; }
/** Legacy un-named preload list (still pushed at join). Never null. */
public static List<String> preloadUrls() { return preloadUrls; }
@@ -280,11 +325,15 @@ public final class VideoPlayerConfig {
"max_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. "
+ "sound_category: which Minecraft volume slider gates video audio (client-side). "
+ "One of: master, music, record, weather, block, hostile, neutral, player, "
+ "ambient, voice, ui. Default 'record' = the Jukebox/Note Blocks slider. "
+ "preload_urls: HTTP(S) videos auto-pushed to every player on join (no name). "
+ "cache_entries: named entries managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", DEFAULT_MAX_PRELOAD_MB);
root.addProperty("max_cache_mb", DEFAULT_MAX_CACHE_MB);
root.addProperty("render_distance_blocks", DEFAULT_RENDER_DISTANCE);
root.addProperty("sound_category", DEFAULT_SOUND_CATEGORY.getName());
root.add("preload_urls", new JsonArray());
root.add("cache_entries", new JsonArray());
Gson gson = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().create();
@@ -301,11 +350,15 @@ public final class VideoPlayerConfig {
"max_preload_mb: per-video download cap (each client). "
+ "max_cache_mb: total cache directory cap (each client). "
+ "render_distance_blocks: max distance at which a video anchor still renders. "
+ "sound_category: which Minecraft volume slider gates video audio (client-side). "
+ "One of: master, music, record, weather, block, hostile, neutral, player, "
+ "ambient, voice, ui. Default 'record' = the Jukebox/Note Blocks slider. "
+ "preload_urls: legacy un-named auto-preload list. "
+ "cache_entries: managed by /videoCache add|list|remove.");
root.addProperty("max_preload_mb", maxPreloadMb);
root.addProperty("max_cache_mb", maxCacheMb);
root.addProperty("render_distance_blocks", renderDistanceBlocks);
root.addProperty("sound_category", soundCategory.getName());
JsonArray legacyArr = new JsonArray();
for (String u : preloadUrls) legacyArr.add(u);
root.add("preload_urls", legacyArr);

View File

@@ -276,6 +276,14 @@ public class JavaCvBackend implements VideoBackend {
Class<?> frameCls = Class.forName(FRAME_CLASS);
Field imageField = frameCls.getField("image");
Field samplesField = frameCls.getField("samples");
// Row stride (bytes) of frame.image[0]. swscale aligns the RGBA linesize to a SIMD
// boundary (32/64 B), so for any width whose width*4 isn't already aligned the plane
// is padded: e.g. 1674-wide → linesize 6720 instead of 6696. We read this to strip
// the padding row-by-row; resolved defensively so a Frame without the field just
// falls back to deriving the stride from the buffer size below.
Field strideField;
try { strideField = frameCls.getField("imageStride"); }
catch (Throwable t) { strideField = null; }
// Java2DFrameConverter is no longer used now that we read RGBA bytes directly,
// but we still resolve its class so a future code path could fall back to it if a
// grabber refuses setPixelFormat. Keep the lookup defensive.
@@ -323,26 +331,40 @@ public class JavaCvBackend implements VideoBackend {
// primary memory-churn problem; 0.4.10 fixed that; 0.4.11 adds the ring on
// top to absorb the burst-then-stall caused by SourceDataLine backpressure
// pacing only at audio-buffer granularity.
int need = src.remaining();
// Reviewer-mandated sanity bounds: memCopy is a raw native copy with no
// fence against overrun. Validate against (a) the source buffer's own
// capacity (so a corrupt plane can't read past it) and (b) the expected
// RGBA frame size (width*height*4) (so an unexpectedly oversized plane
// can't smash the dst slot we'll allocate). If either fails, skip this
// frame and continue — the next grab() will give us a fresh one.
int expected = width * height * 4;
if (need > src.capacity()) {
VideoPlayerMod.LOG.warn("[{}] frame overruns source capacity (need={}, cap={}); skipping",
VideoPlayerMod.MOD_ID, need, src.capacity());
need = 0;
} else if (need > expected) {
VideoPlayerMod.LOG.warn("[{}] frame larger than expected RGBA size (need={}, expected={}); skipping",
VideoPlayerMod.MOD_ID, need, expected);
need = 0;
// The destination texture is tightly packed: one frame = width*height*4
// bytes with no row padding (consumeFrame copies ringBytes[idx] straight
// into a w*h*4 GPU texture and rejects anything larger). swscale, however,
// hands us an RGBA plane whose linesize is SIMD-aligned, so for any width
// where width*4 isn't a multiple of the alignment the source rows carry
// trailing padding (e.g. 1674-wide → 6720 B/row vs the 6696 B we want).
// Copying the padded buffer verbatim both overruns the tight dst slot and
// shears the image; we must strip the pad row-by-row.
int rowBytes = width * 4; // bytes of real pixels per row
int dstBytes = rowBytes * height; // tightly-packed frame size
// Source row stride: prefer Frame.imageStride (authoritative), else derive
// it from the buffer size. Guard against a stride smaller than a full row
// (bogus field) by falling back to the tight rowBytes.
int strideBytes = rowBytes;
if (strideField != null) {
try {
int s = strideField.getInt(frame);
if (s >= rowBytes) strideBytes = s;
} catch (Throwable ignored) {}
} else if (height > 0) {
int s = src.remaining() / height;
if (s >= rowBytes) strideBytes = s;
}
if (need > 0) {
// Sanity bound: the strided read must stay within the source buffer. memCopy
// is a raw native copy with no overrun fence, so if the plane is smaller than
// stride*height (corrupt/truncated) skip this frame and grab a fresh one.
boolean ok = dstBytes > 0 && (long) strideBytes * height <= src.capacity();
if (!ok && dstBytes > 0) {
VideoPlayerMod.LOG.warn("[{}] frame plane too small (stride={}, h={}, cap={}); skipping",
VideoPlayerMod.MOD_ID, strideBytes, height, src.capacity());
}
if (ok) {
int srcPos = src.position();
long srcAddr = MemoryUtil.memAddress(src) + srcPos;
long srcBase = MemoryUtil.memAddress(src) + srcPos;
synchronized (frameLock) {
// Recheck shutdown inside the lock: stopWorker() flipped running=false
// before signaling, so worker is the only writer here and grabber.close()
@@ -350,12 +372,21 @@ public class JavaCvBackend implements VideoBackend {
// the contract obvious to future readers.
if (!running.get() || closed) break;
int idx = ringTail;
if (ringBufs[idx] == null || ringBufs[idx].capacity() < need) {
ringBufs[idx] = ByteBuffer.allocateDirect(need).order(ByteOrder.nativeOrder());
if (ringBufs[idx] == null || ringBufs[idx].capacity() < dstBytes) {
ringBufs[idx] = ByteBuffer.allocateDirect(dstBytes).order(ByteOrder.nativeOrder());
}
long dstAddr = MemoryUtil.memAddress(ringBufs[idx]);
MemoryUtil.memCopy(srcAddr, dstAddr, need);
ringBytes[idx] = need;
long dstBase = MemoryUtil.memAddress(ringBufs[idx]);
if (strideBytes == rowBytes) {
MemoryUtil.memCopy(srcBase, dstBase, dstBytes);
} else {
// Strip swscale row padding: copy only the real pixels per row.
for (int y = 0; y < height; y++) {
MemoryUtil.memCopy(srcBase + (long) y * strideBytes,
dstBase + (long) y * rowBytes,
rowBytes);
}
}
ringBytes[idx] = dstBytes;
ringTail = (idx + 1) % FRAME_RING_SLOTS;
if (ringCount < FRAME_RING_SLOTS) {
ringCount++;

View File

@@ -52,6 +52,18 @@ public final class VideoCache {
*/
private static final AtomicLong CACHE_EPOCH = new AtomicLong(0);
/**
* Per-URL cancellation channel for {@link #purge(String)}. The global {@link #CACHE_EPOCH}
* can only cancel <em>all</em> in-flight downloads at once; a targeted purge needs to stop
* exactly one. Each submitted download captures a unique generation here at submit time;
* {@link #purge(String)} removes the URL's entry, which makes the running download's
* {@link #cancelled(String, long, long)} checks (read loop + every publish point) fail and
* self-clean its {@code .part}/promoted file instead of re-publishing post-purge.
*/
private static final Map<String, Long> DOWNLOAD_GEN = new ConcurrentHashMap<>();
/** Monotonic source for per-download generations stored in {@link #DOWNLOAD_GEN}. */
private static final AtomicLong GEN_SEQ = new AtomicLong(0);
/**
* Single-thread executor that serializes all downloads. Cap enforcement (the total
* cache size check) needs an authoritative view of the cache directory at the moment a
@@ -103,6 +115,12 @@ public final class VideoCache {
/** Server-driven delete of a cached URL. Removes from READY and from disk. */
public static void purge(String url) {
if (url == null || url.isEmpty()) return;
// Cancel any in-flight download of this exact URL first. Dropping its generation
// makes the download thread's cancelled() checks fail, so it deletes its own .part
// (or rolls back a just-promoted file) instead of finishing and re-publishing after
// we've purged. Without this, a slow/large download evicted mid-flight would silently
// reappear on disk + in READY once it completed.
DOWNLOAD_GEN.remove(url);
Path p = READY.remove(url);
if (p == null) {
// Not in this session's index, but the file may still be on disk from a prior run.
@@ -158,6 +176,7 @@ public final class VideoCache {
// Phase 2: drop indexes.
READY.clear();
IN_FLIGHT.clear();
DOWNLOAD_GEN.clear();
// Phase 3: delete files on disk.
int deleted = 0;
@@ -209,7 +228,11 @@ public final class VideoCache {
// CACHE_EPOCH later — any mismatch means clearAll() ran in between and this
// download must abort without publishing.
long epoch = CACHE_EPOCH.get();
DOWNLOAD_POOL.submit(() -> download(url, epoch));
// Register a unique generation for this download so a targeted purge() can cancel
// exactly this URL (see DOWNLOAD_GEN / cancelled()).
long gen = GEN_SEQ.incrementAndGet();
DOWNLOAD_GEN.put(url, gen);
DOWNLOAD_POOL.submit(() -> download(url, epoch, gen));
}
/** Return the local cached file path for this URL, or {@code null} if not yet ready. */
@@ -224,6 +247,7 @@ public final class VideoCache {
public static void clearIndex() {
READY.clear();
IN_FLIGHT.clear();
DOWNLOAD_GEN.clear();
}
/**
@@ -237,6 +261,7 @@ public final class VideoCache {
CACHE_EPOCH.incrementAndGet();
READY.clear();
IN_FLIGHT.clear();
DOWNLOAD_GEN.clear();
int deleted = 0;
int failed = 0;
try {
@@ -283,16 +308,16 @@ public final class VideoCache {
* @return {@code true} if the entry is now published; {@code false} if the
* caller's work was cancelled and {@code path} has been cleaned up.
*/
private static boolean publishIfNotCancelled(String url, Path path, long startEpoch) {
// If the wipe ran between download's pre-move epoch check and Files.move, its
// directory scan won't have seen this just-promoted file. We must delete it here,
// not just bail — otherwise the freshly-moved final file leaks across shutdown.
if (CACHE_EPOCH.get() != startEpoch) {
private static boolean publishIfNotCancelled(String url, Path path, long startEpoch, long startGen) {
// If a wipe (clearAll) or a targeted purge ran between download's pre-move check and
// Files.move, its directory scan won't have seen this just-promoted file. We must
// delete it here, not just bail — otherwise the freshly-moved final file leaks.
if (cancelled(url, startEpoch, startGen)) {
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
}
READY.put(url, path);
if (CACHE_EPOCH.get() != startEpoch) {
if (cancelled(url, startEpoch, startGen)) {
READY.remove(url, path);
try { Files.deleteIfExists(path); } catch (Throwable ignored) {}
return false;
@@ -300,7 +325,19 @@ public final class VideoCache {
return true;
}
private static void download(String url, long startEpoch) {
/**
* True if the in-flight download for {@code url} should abort and self-clean. Two channels:
* a global wipe ({@link #clearAll()}/{@link #wipeOnShutdown()} bumped {@link #CACHE_EPOCH}),
* or a targeted {@link #purge(String)} that removed/replaced this URL's generation in
* {@link #DOWNLOAD_GEN}.
*/
private static boolean cancelled(String url, long startEpoch, long startGen) {
if (CACHE_EPOCH.get() != startEpoch) return true;
Long cur = DOWNLOAD_GEN.get(url);
return cur == null || cur.longValue() != startGen;
}
private static void download(String url, long startEpoch, long startGen) {
Path cacheDir = cacheDir();
Path partPath = null;
try {
@@ -311,8 +348,8 @@ public final class VideoCache {
}
// Pre-flight cancellation check — if clearAll already ran between submit and now,
// skip the whole thing. Avoids creating directories / .part files post-clear.
if (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before start (clearAll ran) — {}",
if (cancelled(url, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before start (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
@@ -326,8 +363,8 @@ public final class VideoCache {
// Resume-friendly: if the file's already on disk from an earlier session, just
// index it without re-downloading.
if (Files.exists(finalPath) && Files.size(finalPath) > 0) {
if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll ran) — {}",
if (!publishIfNotCancelled(url, finalPath, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: reindex cancelled (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
@@ -378,7 +415,7 @@ public final class VideoCache {
// Cancellation check inside the loop. Break (not return) so the try-with
// closes the output stream first — on Windows, deleting an open .part can
// fail with AccessDeniedException, so we always close before deleting.
if (CACHE_EPOCH.get() != startEpoch) {
if (cancelled(url, startEpoch, startGen)) {
cancelled = true;
break;
}
@@ -416,8 +453,8 @@ public final class VideoCache {
// Pre-move cancellation check. If clearAll ran during the read loop, abort
// before promoting .part to final — saves a wasted move + delete.
if (CACHE_EPOCH.get() != startEpoch) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll ran) — {}",
if (cancelled(url, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled before move (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
try { Files.deleteIfExists(partPath); } catch (Throwable ignored) {}
return;
@@ -431,8 +468,8 @@ public final class VideoCache {
// the window between READY.put and the "완료" notification (clearAll wipes
// READY then we re-publish + emit stale completion chat). publishIfNotCancelled
// rolls back both READY and the on-disk file if the epoch moved at any time.
if (!publishIfNotCancelled(url, finalPath, startEpoch)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll ran) — {}",
if (!publishIfNotCancelled(url, finalPath, startEpoch, startGen)) {
VideoPlayerMod.LOG.info("[{}] preload: cancelled at publish (clearAll/purge ran) — {}",
VideoPlayerMod.MOD_ID, url);
return;
}
@@ -451,6 +488,9 @@ public final class VideoCache {
}
} finally {
IN_FLIGHT.remove(url);
// Only clear our own generation — a re-preload after a purge may have installed a
// newer gen for this URL that must survive.
DOWNLOAD_GEN.remove(url, startGen);
}
}