Init
Build and Publish / Build and Publish Docker Image (push) Successful in 10m8s

This commit is contained in:
Ultradesu
2026-08-05 12:32:52 +01:00
commit eb0d4fb1c5
46 changed files with 5623 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
(function () {
async function request(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
let message = `HTTP ${response.status}`;
try { message = (await response.json()).error || message; } catch (_) {}
throw new Error(message);
}
return response;
}
window.LanPlayApi = {
async browse(path) {
return (await request(`/api/browse?path=${encodeURIComponent(path)}`)).json();
},
async info() {
return (await request('/api/info')).json();
},
async startDash(path, startSeconds = 0) {
const query = `path=${encodeURIComponent(path)}&start=${encodeURIComponent(startSeconds)}`;
return (await request(`/api/dash?${query}`, { method: 'POST' })).json();
},
async stopDash(sessionId) {
await request(`/api/dash/${sessionId}`, { method: 'DELETE' });
},
async subtitles(path) {
return (await request(`/api/subtitles?path=${encodeURIComponent(path)}`)).json();
}
};
})();
+262
View File
@@ -0,0 +1,262 @@
document.addEventListener('alpine:init', () => {
Alpine.data('library', () => ({
currentPath: '', parentPath: null, entries: [], selected: null,
loading: false, playerLoading: false, playerLoadingCompact: false,
error: '', playerController: null, viewMode: 'grid',
infoOpen: false, infoLoading: false, infoError: '', diagnostics: null,
previewFrames: {}, previewUrls: {}, previewStates: {}, previewTimers: {},
previewActive: {}, previewImages: {}, previewGeneration: 0,
init() {
try { this.viewMode = localStorage.getItem('lan-play:view') || 'grid'; } catch (_) {}
this.locationHandler = () => { this.restoreLocation(); };
this.resizeHandler = () => { this.scheduleMarqueeCheck(); };
window.addEventListener('popstate', this.locationHandler);
window.addEventListener('resize', this.resizeHandler);
},
destroy() {
this.stopAllPreviews();
this.cancelPreviewLoads();
window.removeEventListener('popstate', this.locationHandler);
window.removeEventListener('resize', this.resizeHandler);
if (this.marqueeFrame) window.cancelAnimationFrame(this.marqueeFrame);
if (this.playerController) this.playerController.dispose();
},
async restoreLocation() {
const location = window.LanPlayLocation.read();
if (this.playerController) await this.playerController.stop();
this.selected = null;
await this.browse(location.path, false);
if (location.play) {
await this.play({
path: location.play,
name: window.LanPlayLocation.fileName(location.play),
kind: 'media'
}, false);
}
},
async browse(path, updateLocation = true) {
this.stopAllPreviews();
this.cancelPreviewLoads();
this.loading = true;
this.error = '';
try {
const data = await window.LanPlayApi.browse(path);
this.currentPath = data.path;
this.parentPath = data.parent;
this.entries = data.entries;
if (updateLocation) window.LanPlayLocation.write(data.path, null, false);
await this.$nextTick();
this.preloadPreviews(data.entries);
this.scheduleMarqueeCheck();
} catch (error) { this.error = error.message; }
finally { this.loading = false; }
},
async play(entry, updateLocation = true) {
this.stopAllPreviews();
this.enterFullscreen();
this.error = '';
this.selected = entry;
this.playerLoading = true;
if (updateLocation) window.LanPlayLocation.write(this.currentPath, entry.path, false);
await this.$nextTick();
if (!this.playerController) {
this.playerController = new window.LanPlayPlayer(
this.$refs.video,
this.$refs.videoWrapper,
(message) => { this.error = message; },
(loading, compact = false) => {
this.playerLoading = loading;
this.playerLoadingCompact = loading && compact;
},
() => { this.playNext(); }
);
}
this.playerController.setNextAvailable(Boolean(this.nextEntry()));
try { await this.playerController.play(entry); }
catch (error) { this.error = error.message; }
finally {
this.playerLoading = false;
this.playerLoadingCompact = false;
}
},
nextEntry() {
if (!this.selected) return null;
const media = this.entries.filter((entry) => entry.kind === 'media');
const index = media.findIndex((entry) => entry.path === this.selected.path);
return index >= 0 ? media[index + 1] || null : null;
},
async playNext() {
const next = this.nextEntry();
if (next) await this.play(next);
},
mediaTitle() {
if (!this.selected || !this.selected.name) return '';
return this.selected.name.replace(/\.[^.]+$/, '');
},
setViewMode(mode) {
this.viewMode = mode === 'table' ? 'table' : 'grid';
try { localStorage.setItem('lan-play:view', this.viewMode); } catch (_) {}
this.$nextTick(() => this.scheduleMarqueeCheck());
},
scheduleMarqueeCheck() {
if (this.marqueeFrame) window.cancelAnimationFrame(this.marqueeFrame);
this.marqueeFrame = window.requestAnimationFrame(() => {
this.marqueeFrame = null;
document.querySelectorAll('.entry-name').forEach((container) => {
if (container.clientWidth <= 0) return;
const text = container.querySelector('.entry-name-track');
container.classList.toggle('is-overflowing', Boolean(text) && text.scrollWidth > container.clientWidth + 1);
});
});
},
openEntry(entry) {
return entry.kind === 'directory' ? this.browse(entry.path) : this.play(entry);
},
async openInfo() {
this.infoOpen = true;
this.infoLoading = true;
this.infoError = '';
try {
const server = await window.LanPlayApi.info();
let dashVersion = 'Unknown';
try { dashVersion = dashjs.MediaPlayer().create().getVersion(); } catch (_) {}
this.diagnostics = {
...server,
interface: '20',
dashjs: dashVersion,
browser: navigator.userAgent,
display: `${window.screen.width}×${window.screen.height} @ ${window.devicePixelRatio || 1}x`,
fullscreen: Boolean(document.documentElement.requestFullscreen || document.documentElement.webkitRequestFullscreen),
mediaSource: 'MediaSource' in window
};
} catch (error) {
this.infoError = error.message;
} finally {
this.infoLoading = false;
}
},
closeInfo() {
this.infoOpen = false;
},
previewStyle(entry) {
if (this.previewStates[entry.path] !== 'ready') return '';
const url = this.previewUrls[entry.path];
if (!url) return '';
const frame = this.previewFrames[entry.path] || 0;
const column = frame % 5;
const row = Math.floor(frame / 5);
return `background-image:url("${url}");background-position:${column * 25}% ${row * 100}%`;
},
startPreview(entry) {
if (!entry || entry.kind !== 'media') return;
this.previewActive[entry.path] = true;
this.stopPreview(entry);
this.previewActive[entry.path] = true;
if (this.previewStates[entry.path] !== 'ready') return;
this.previewFrames[entry.path] = 0;
this.previewTimers[entry.path] = window.setInterval(() => {
this.previewFrames[entry.path] = ((this.previewFrames[entry.path] || 0) + 1) % 10;
}, 500);
},
stopPreview(entry) {
if (!entry) return;
this.previewActive[entry.path] = false;
const timer = this.previewTimers[entry.path];
if (timer) window.clearInterval(timer);
delete this.previewTimers[entry.path];
this.previewFrames[entry.path] = 0;
},
stopAllPreviews() {
Object.values(this.previewTimers).forEach((timer) => window.clearInterval(timer));
this.previewTimers = {};
this.previewActive = {};
},
preloadPreviews(entries) {
const generation = this.previewGeneration;
entries.filter((entry) => entry.kind === 'media').forEach((entry) => {
const path = entry.path;
const url = `/api/preview?path=${encodeURIComponent(path)}`;
const image = new Image();
this.previewUrls[path] = url;
this.previewStates[path] = 'loading';
this.previewImages[path] = image;
image.onload = () => {
if (generation !== this.previewGeneration) return;
this.previewStates[path] = 'ready';
delete this.previewImages[path];
if (this.previewActive[path]) this.startPreview(entry);
};
image.onerror = () => {
if (generation !== this.previewGeneration) return;
this.previewStates[path] = 'error';
delete this.previewImages[path];
};
image.src = url;
});
},
cancelPreviewLoads() {
this.previewGeneration += 1;
Object.values(this.previewImages).forEach((image) => {
image.onload = null;
image.onerror = null;
image.src = '';
});
this.previewImages = {};
this.previewFrames = {};
this.previewUrls = {};
this.previewStates = {};
},
enterFullscreen() {
if (document.fullscreenElement || document.webkitFullscreenElement) return;
const root = document.documentElement;
const request = root.requestFullscreen || root.webkitRequestFullscreen;
if (request) {
try {
const result = request.call(root);
if (result && result.catch) result.catch(() => {});
} catch (_) {}
}
},
exitFullscreen() {
if (!document.fullscreenElement && !document.webkitFullscreenElement) return;
const exit = document.exitFullscreen || document.webkitExitFullscreen;
if (exit) {
try {
const result = exit.call(document);
if (result && result.catch) result.catch(() => {});
} catch (_) {}
}
},
async closePlayer(updateLocation = true) {
this.exitFullscreen();
this.selected = null;
this.playerLoading = false;
this.playerLoadingCompact = false;
if (updateLocation) window.LanPlayLocation.write(this.currentPath, null, false);
await this.$nextTick();
if (this.playerController) this.playerController.stop();
}
}));
});
+24
View File
@@ -0,0 +1,24 @@
(function () {
function read() {
const params = new URLSearchParams(window.location.search);
return {
path: params.get('path') || '',
play: params.get('play') || null
};
}
function write(path, play, replace) {
const url = new URL(window.location.href);
path ? url.searchParams.set('path', path) : url.searchParams.delete('path');
play ? url.searchParams.set('play', play) : url.searchParams.delete('play');
const next = `${url.pathname}${url.search}`;
window.history[replace ? 'replaceState' : 'pushState']({}, '', next);
}
function fileName(path) {
const parts = path.split('/');
return parts[parts.length - 1] || path;
}
window.LanPlayLocation = { read, write, fileName };
})();
+265
View File
@@ -0,0 +1,265 @@
(function () {
class LanPlayPlayer {
constructor(video, wrapper, onError, onLoading, onNext) {
this.video = video;
this.wrapper = wrapper;
this.onError = onError;
this.onLoading = onLoading;
this.onNext = onNext;
this.dashPlayer = null;
this.controlBar = null;
this.sessionId = null;
this.mediaPath = null;
this.dashPreferencesApplied = false;
this.currentEntry = null;
this.nextAvailable = false;
this.restarting = false;
this.operation = 0;
this.freezeFrame = null;
this.playingHandler = null;
this.subtitleUrls = [];
}
async play(entry, startSeconds = 0, preserveFrame = false) {
const framePreserved = preserveFrame && this.captureFrame();
this.onLoading(true, framePreserved);
this.stop({ preserveFrame: framePreserved });
const operation = ++this.operation;
this.currentEntry = entry;
this.mediaPath = entry.path;
this.dashPreferencesApplied = false;
const session = await window.LanPlayApi.startDash(entry.path, startSeconds);
if (operation !== this.operation) {
window.LanPlayApi.stopDash(session.session_id).catch(() => {});
return;
}
this.sessionId = session.session_id;
this.dashPlayer = dashjs.MediaPlayer().create();
this.dashPlayer.updateSettings({
streaming: {
buffer: {
bufferTimeDefault: 8,
bufferTimeAtTopQuality: 8,
bufferTimeAtTopQualityLongForm: 8
}
}
});
this.dashPlayer.initialize(this.video, null, true);
await this.addExternalSubtitles(entry.path, session.start_seconds, operation);
if (operation !== this.operation) return;
this.controlBar = new DashControlBarModule.ControlBar(this.dashPlayer, this.video);
this.controlBar.init(this.wrapper);
this.controlBar.setVodDuration(session.duration_seconds, session.start_seconds);
if (typeof this.controlBar.onSeekRequested === 'function') {
this.controlBar.onSeekRequested((time) => this.seek(time));
}
if (typeof this.controlBar.onNextRequested === 'function') {
this.controlBar.onNextRequested(() => this.onNext());
}
if (typeof this.controlBar.setNextAvailable === 'function') {
this.controlBar.setNextAvailable(this.nextAvailable);
}
this.controlBar.onTrackSelected((type, track) => this.rememberTrack(type, track));
this.controlBar.disable();
const controlBar = this.controlBar;
this.dashPlayer.on(dashjs.MediaPlayer.events.ERROR, (event) => {
if (operation !== this.operation) return;
const message = event && event.error && event.error.message;
this.onError(message || 'DASH playback error');
});
this.dashPlayer.on(dashjs.MediaPlayer.events.STREAM_INITIALIZED, () => {
if (operation !== this.operation || this.controlBar !== controlBar) return;
this.applyDashPreferences();
controlBar.enable();
controlBar.refreshTracks();
});
this.playingHandler = () => {
if (operation !== this.operation) return;
this.playingHandler = null;
this.clearFreezeFrame();
this.onLoading(false, false);
};
this.video.addEventListener('playing', this.playingHandler, { once: true });
this.dashPlayer.attachSource(session.manifest_url);
if (!framePreserved) this.onLoading(false, false);
}
async seek(time) {
if (this.restarting || !this.currentEntry) return;
this.restarting = true;
const entry = this.currentEntry;
try { await this.play(entry, time, true); }
catch (error) {
this.onError(error.message);
this.clearFreezeFrame();
this.onLoading(false, false);
} finally { this.restarting = false; }
}
setNextAvailable(available) {
this.nextAvailable = Boolean(available);
if (this.controlBar && typeof this.controlBar.setNextAvailable === 'function') {
this.controlBar.setNextAvailable(this.nextAvailable);
}
}
applyDashPreferences() {
if (this.dashPreferencesApplied || !this.mediaPath) return;
['audio', 'video'].forEach((type) => {
const tracks = this.dashPlayer.getTracksFor(type) || [];
const preferred = window.LanPlayPreferences.find(this.mediaPath, type, tracks);
if (preferred) this.dashPlayer.setCurrentTrack(preferred);
});
const textTracks = this.dashPlayer.getTracksFor('text') || [];
const preferredText = window.LanPlayPreferences.find(this.mediaPath, 'subtitle', textTracks);
if (preferredText === null) {
this.dashPlayer.setTextTrack(-1);
} else if (preferredText) {
const index = textTracks.indexOf(preferredText);
if (index >= 0) this.dashPlayer.setTextTrack(index);
}
this.dashPreferencesApplied = true;
}
rememberTrack(type, track) {
if (!this.mediaPath) return;
if (type === 'nativeText') {
const tracks = Array.from(this.video.textTracks || []);
window.LanPlayPreferences.remember(this.mediaPath, 'subtitle', track, tracks.indexOf(track));
return;
}
const preferenceType = type === 'text' ? 'subtitle' : type;
const tracks = this.dashPlayer.getTracksFor(type) || [];
window.LanPlayPreferences.remember(this.mediaPath, preferenceType, track, tracks.indexOf(track));
}
async addExternalSubtitles(path, startSeconds = 0, operation = this.operation) {
let subtitles = [];
try { subtitles = await window.LanPlayApi.subtitles(path); } catch (_) { return; }
if (operation !== this.operation) return;
const loaded = await Promise.all(subtitles.filter((item) => item.supported).map(async (subtitle, ordinal) => {
const query = new URLSearchParams({ path, start: String(startSeconds) });
const url = subtitle.source === 'external'
? `/api/subtitles/external/${encodeURIComponent(subtitle.id)}?${query}`
: `/api/subtitles/${subtitle.index}?${query}`;
try {
const response = await fetch(url);
if (!response.ok) return null;
const contents = await response.text();
const objectUrl = URL.createObjectURL(new Blob([contents], { type: 'text/vtt' }));
return { subtitle, ordinal, objectUrl };
} catch (_) {
return null;
}
}));
if (operation !== this.operation) {
loaded.filter(Boolean).forEach((item) => URL.revokeObjectURL(item.objectUrl));
return;
}
loaded.filter(Boolean).forEach(({ subtitle, ordinal, objectUrl }) => {
const label = subtitle.title || subtitle.language || `Subtitle ${ordinal + 1}`;
const idPart = subtitle.source === 'external' ? subtitle.id : subtitle.index;
this.subtitleUrls.push(objectUrl);
this.dashPlayer.addExternalSubtitle(new dashjs.ExternalSubtitle({
id: `lan-play-${subtitle.source}-${idPart}`,
url: objectUrl,
language: label,
mimeType: 'text/vtt',
bandwidth: 256,
periodId: '0'
}));
});
}
captureFrame() {
if (this.video.readyState < 2 || !this.video.videoWidth || !this.video.videoHeight) return false;
try {
this.clearFreezeFrame();
const canvas = document.createElement('canvas');
const width = Math.max(1, this.wrapper.clientWidth);
const height = Math.max(1, this.wrapper.clientHeight);
canvas.width = Math.min(width, 1920);
canvas.height = Math.min(height, 1080);
canvas.className = 'player-freeze-frame';
const context = canvas.getContext('2d');
context.fillStyle = '#000';
context.fillRect(0, 0, canvas.width, canvas.height);
const scale = Math.min(canvas.width / this.video.videoWidth, canvas.height / this.video.videoHeight);
const drawWidth = this.video.videoWidth * scale;
const drawHeight = this.video.videoHeight * scale;
context.drawImage(
this.video,
(canvas.width - drawWidth) / 2,
(canvas.height - drawHeight) / 2,
drawWidth,
drawHeight
);
this.wrapper.appendChild(canvas);
this.freezeFrame = canvas;
return true;
} catch (_) {
this.clearFreezeFrame();
return false;
}
}
clearFreezeFrame() {
if (this.freezeFrame) this.freezeFrame.remove();
this.freezeFrame = null;
}
stop(options = {}) {
this.operation += 1;
if (this.playingHandler) {
this.video.removeEventListener('playing', this.playingHandler);
this.playingHandler = null;
}
if (!options.preserveFrame) this.clearFreezeFrame();
this.revokeSubtitleUrls();
if (this.controlBar) {
this.controlBar.destroy();
this.controlBar = null;
}
if (this.dashPlayer) {
this.dashPlayer.destroy();
this.dashPlayer = null;
}
this.video.pause();
this.video.removeAttribute('src');
while (this.video.firstChild) this.video.removeChild(this.video.firstChild);
this.video.load();
this.mediaPath = null;
this.dashPreferencesApplied = false;
this.currentEntry = null;
if (this.sessionId) {
const sessionId = this.sessionId;
this.sessionId = null;
window.LanPlayApi.stopDash(sessionId).catch(() => {});
}
}
dispose() {
this.operation += 1;
if (this.playingHandler) this.video.removeEventListener('playing', this.playingHandler);
this.playingHandler = null;
this.clearFreezeFrame();
this.revokeSubtitleUrls();
if (this.controlBar) this.controlBar.destroy();
if (this.dashPlayer) this.dashPlayer.destroy();
if (this.sessionId) {
fetch(`/api/dash/${this.sessionId}`, { method: 'DELETE', keepalive: true }).catch(() => {});
}
this.controlBar = null;
this.dashPlayer = null;
this.sessionId = null;
this.currentEntry = null;
}
revokeSubtitleUrls() {
this.subtitleUrls.forEach((url) => URL.revokeObjectURL(url));
this.subtitleUrls = [];
}
}
window.LanPlayPlayer = LanPlayPlayer;
})();
+63
View File
@@ -0,0 +1,63 @@
(function () {
const prefix = 'lan-play:release:v1:';
function folder(path) {
const index = path.lastIndexOf('/');
return index < 0 ? '' : path.slice(0, index);
}
function key(path) {
return `${prefix}${folder(path)}`;
}
function load(path) {
try { return JSON.parse(localStorage.getItem(key(path))) || {}; }
catch (_) { return {}; }
}
function store(path, value) {
try { localStorage.setItem(key(path), JSON.stringify(value)); } catch (_) {}
}
function label(track) {
const labels = track && track.labels || [];
const selected = labels.find((item) => item.text) || labels[0];
return selected && selected.text || track && track.label || '';
}
function descriptor(track, ordinal) {
if (!track) return { off: true };
return {
off: false,
lang: track.lang || track.language || '',
label: label(track),
roles: (track.roles || []).map((role) => role.value || role).filter(Boolean).sort(),
ordinal
};
}
function remember(path, type, track, ordinal) {
const profile = load(path);
profile[type] = descriptor(track, ordinal);
store(path, profile);
}
function find(path, type, tracks) {
const wanted = load(path)[type];
if (!wanted) return undefined;
if (wanted.off) return null;
const described = tracks.map((track, ordinal) => ({ track, ordinal, value: descriptor(track, ordinal) }));
let matches = described.filter((item) => wanted.lang && item.value.lang === wanted.lang);
if (wanted.label) {
const labeled = matches.filter((item) => item.value.label === wanted.label);
if (labeled.length) matches = labeled;
}
if (!matches.length && wanted.label) {
matches = described.filter((item) => item.value.label === wanted.label);
}
if (!matches.length && !wanted.lang && !wanted.label) matches = described;
return (matches.find((item) => item.ordinal === wanted.ordinal) || matches[0] || {}).track;
}
window.LanPlayPreferences = { folder, load, remember, find };
})();
+36
View File
@@ -0,0 +1,36 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const values = new Map();
global.window = global;
global.localStorage = {
getItem: (key) => values.get(key) || null,
setItem: (key, value) => values.set(key, value)
};
require('./preferences.js');
test('restores a matching track for the next file in the same folder', () => {
const firstEpisode = 'Anime/Show/episode-01.mkv';
const nextEpisode = 'Anime/Show/episode-02.mkv';
const tracks = [{ lang: 'en' }, { lang: 'ja' }];
LanPlayPreferences.remember(firstEpisode, 'audio', tracks[1], 1);
assert.equal(LanPlayPreferences.find(nextEpisode, 'audio', tracks), tracks[1]);
});
test('does not apply a profile from another folder', () => {
const tracks = [{ lang: 'ja' }];
assert.equal(LanPlayPreferences.find('Anime/Other/episode-01.mkv', 'audio', tracks), undefined);
});
test('falls back to the player default when the saved track is unavailable', () => {
const tracks = [{ lang: 'en' }];
assert.equal(LanPlayPreferences.find('Anime/Show/episode-03.mkv', 'audio', tracks), undefined);
});
test('remembers disabled subtitles for the release folder', () => {
LanPlayPreferences.remember('Series/Season/episode-01.mkv', 'subtitle', null, -1);
assert.equal(
LanPlayPreferences.find('Series/Season/episode-02.mkv', 'subtitle', [{ language: 'en' }]),
null
);
});