This commit is contained in:
@@ -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();
|
||||
}
|
||||
};
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
}));
|
||||
});
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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;
|
||||
})();
|
||||
@@ -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 };
|
||||
})();
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user