Instancia transcoder t1: cambios locales
This commit is contained in:
+1
-1
@@ -136,7 +136,7 @@ const API = {
|
||||
seriesInfo: (sourceId, seriesId) =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/series_info?series_id=${seriesId}`),
|
||||
shortEpg: (sourceId, streamId) => API.request('GET', `/proxy/xtream/${sourceId}/short_epg?stream_id=${streamId}`),
|
||||
getStreamUrl: (sourceId, streamId, type = 'live', container = 'm3u8') =>
|
||||
getStreamUrl: (sourceId, streamId, type = 'live', container = 'ts') =>
|
||||
API.request('GET', `/proxy/xtream/${sourceId}/stream/${streamId}/${type}?container=${container}`)
|
||||
},
|
||||
|
||||
|
||||
+7
-2
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* NodeCast TV Application Entry Point
|
||||
* KiraTV Application Entry Point
|
||||
*/
|
||||
|
||||
class App {
|
||||
@@ -150,6 +150,11 @@ class App {
|
||||
// Initialize home page first (it's needed for channel list)
|
||||
await this.pages.home.init();
|
||||
|
||||
// La barra lateral es global: antes solo se cargaban canales al abrir la página «Live»
|
||||
// (LivePage.init no se llama en ningún sitio). Sin esto, en Inicio parece que «no hay canales».
|
||||
await this.channelList.loadSources();
|
||||
await this.channelList.loadChannels();
|
||||
|
||||
// Preload EPG data in background (non-blocking)
|
||||
// This ensures EPG info is available on Live TV page without visiting Guide first
|
||||
this.epgGuide.loadEpg().catch(err => {
|
||||
@@ -161,7 +166,7 @@ class App {
|
||||
const initialPage = hash && this.pages[hash] ? hash : 'home';
|
||||
this.navigateTo(initialPage, true); // true = replace history (don't add)
|
||||
|
||||
console.log('NodeCast TV initialized');
|
||||
console.log('KiraTV initialized');
|
||||
}
|
||||
|
||||
async checkAuth() {
|
||||
|
||||
@@ -46,7 +46,7 @@ class ChannelList {
|
||||
*/
|
||||
loadCollapsedState() {
|
||||
try {
|
||||
const saved = localStorage.getItem('nodecast_tv_collapsed_groups');
|
||||
const saved = localStorage.getItem('kiratv_collapsed_groups');
|
||||
if (saved) {
|
||||
this.collapsedGroups = new Set(JSON.parse(saved));
|
||||
this._hasCollapsedState = true;
|
||||
@@ -64,7 +64,7 @@ class ChannelList {
|
||||
*/
|
||||
saveCollapsedState() {
|
||||
try {
|
||||
localStorage.setItem('nodecast_tv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
|
||||
localStorage.setItem('kiratv_collapsed_groups', JSON.stringify([...this.collapsedGroups]));
|
||||
} catch (err) {
|
||||
console.error('Error saving collapsed state:', err);
|
||||
}
|
||||
@@ -1159,7 +1159,7 @@ class ChannelList {
|
||||
let streamUrl;
|
||||
if (channel.sourceType === 'xtream') {
|
||||
// Get stream format from player settings (server-side) or fallback
|
||||
const streamFormat = window.app?.player?.settings?.streamFormat || 'm3u8';
|
||||
const streamFormat = window.app?.player?.settings?.streamFormat || 'ts';
|
||||
const result = await API.proxy.xtream.getStreamUrl(channel.sourceId, channel.streamId, 'live', streamFormat);
|
||||
streamUrl = result.url;
|
||||
} else {
|
||||
|
||||
@@ -8,6 +8,8 @@ class SourceManager {
|
||||
this.xtreamList = document.getElementById('xtream-list');
|
||||
this.m3uList = document.getElementById('m3u-list');
|
||||
this.epgList = document.getElementById('epg-list');
|
||||
/** @type {Record<number, string>} id → username (admin, for labels) */
|
||||
this.userNamesById = {};
|
||||
|
||||
// Content browser state
|
||||
this.contentType = 'channels'; // 'channels' or 'movies'
|
||||
@@ -20,6 +22,29 @@ class SourceManager {
|
||||
this.init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Comprueba si la sesión es admin. Usa /api/auth/me si window.app.currentUser
|
||||
* aún no está (carrera: el modal se abre antes de que termine checkAuth).
|
||||
*/
|
||||
async isAdminSession() {
|
||||
let role = window.app?.currentUser?.role;
|
||||
if (!role && typeof localStorage !== 'undefined') {
|
||||
const token = localStorage.getItem('authToken');
|
||||
if (token) {
|
||||
try {
|
||||
const r = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
});
|
||||
if (r.ok) {
|
||||
const u = await r.json();
|
||||
role = u.role;
|
||||
}
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
return String(role || '').toLowerCase() === 'admin';
|
||||
}
|
||||
|
||||
init() {
|
||||
// Add source buttons
|
||||
document.getElementById('add-xtream').addEventListener('click', () => this.showAddModal('xtream'));
|
||||
@@ -112,9 +137,20 @@ class SourceManager {
|
||||
try {
|
||||
const sources = await API.sources.getAll();
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream');
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u');
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg');
|
||||
const admin = await this.isAdminSession();
|
||||
this.userNamesById = {};
|
||||
if (admin) {
|
||||
try {
|
||||
const users = await API.users.getAll();
|
||||
users.forEach(u => { this.userNamesById[u.id] = u.username; });
|
||||
} catch (e) {
|
||||
console.warn('[SourceManager] Could not load users for labels:', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.renderSourceList(this.xtreamList, sources.filter(s => s.type === 'xtream'), 'xtream', admin);
|
||||
this.renderSourceList(this.m3uList, sources.filter(s => s.type === 'm3u'), 'm3u', admin);
|
||||
this.renderSourceList(this.epgList, sources.filter(s => s.type === 'epg'), 'epg', admin);
|
||||
} catch (err) {
|
||||
console.error('Error loading sources:', err);
|
||||
}
|
||||
@@ -123,7 +159,7 @@ class SourceManager {
|
||||
/**
|
||||
* Render source list
|
||||
*/
|
||||
renderSourceList(container, sources, type) {
|
||||
renderSourceList(container, sources, type, isAdmin = false) {
|
||||
if (sources.length === 0) {
|
||||
container.innerHTML = `<p class="hint">No ${type.toUpperCase()} sources configured</p>`;
|
||||
return;
|
||||
@@ -131,12 +167,21 @@ class SourceManager {
|
||||
|
||||
const icons = { xtream: Icons.live, m3u: Icons.guide, epg: Icons.series };
|
||||
|
||||
const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
const admin = isAdmin;
|
||||
const ownerLine = admin
|
||||
? `<div class="source-meta hint">${source.ownerId != null
|
||||
? `Propietario: ${esc(this.userNamesById[source.ownerId] || ('#' + source.ownerId))}`
|
||||
: 'Fuente compartida (todos los usuarios)'}</div>`
|
||||
: '';
|
||||
|
||||
container.innerHTML = sources.map(source => `
|
||||
<div class="source-item ${source.enabled ? '' : 'disabled'}" data-id="${source.id}">
|
||||
<span class="source-icon">${icons[type]}</span>
|
||||
<div class="source-info">
|
||||
<div class="source-name">${source.name}</div>
|
||||
<div class="source-url">${source.url}</div>
|
||||
<div class="source-name">${esc(source.name)}</div>
|
||||
<div class="source-url">${esc(source.url)}</div>
|
||||
${ownerLine}
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="btn btn-sm btn-secondary" data-action="refresh" title="Refresh Data">${Icons.refresh}</button>
|
||||
@@ -165,7 +210,7 @@ class SourceManager {
|
||||
/**
|
||||
* Show add source modal
|
||||
*/
|
||||
showAddModal(type) {
|
||||
async showAddModal(type) {
|
||||
const modal = document.getElementById('modal');
|
||||
const title = document.getElementById('modal-title');
|
||||
const body = document.getElementById('modal-body');
|
||||
@@ -174,7 +219,7 @@ class SourceManager {
|
||||
const titles = { xtream: 'Add Xtream Connection', m3u: 'Add M3U Playlist', epg: 'Add EPG Source' };
|
||||
title.textContent = titles[type];
|
||||
|
||||
body.innerHTML = this.getSourceForm(type);
|
||||
body.innerHTML = await this.getSourceForm(type);
|
||||
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
@@ -202,7 +247,7 @@ class SourceManager {
|
||||
const footer = document.getElementById('modal-footer');
|
||||
|
||||
title.textContent = `Edit ${type.toUpperCase()} Source`;
|
||||
body.innerHTML = this.getSourceForm(type, source);
|
||||
body.innerHTML = await this.getSourceForm(type, source);
|
||||
|
||||
footer.innerHTML = `
|
||||
<button class="btn btn-secondary" id="modal-cancel">Cancel</button>
|
||||
@@ -222,11 +267,32 @@ class SourceManager {
|
||||
/**
|
||||
* Get source form HTML
|
||||
*/
|
||||
getSourceForm(type, source = {}) {
|
||||
async getSourceForm(type, source = {}) {
|
||||
const esc = (t) => String(t ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/"/g, '"');
|
||||
|
||||
let ownerField = '';
|
||||
if (await this.isAdminSession()) {
|
||||
let users = [];
|
||||
try {
|
||||
users = await API.users.getAll();
|
||||
} catch (e) {
|
||||
console.warn('[SourceManager] users list:', e);
|
||||
}
|
||||
const opts = [`<option value="">Compartida (todos los usuarios autenticados)</option>`].concat(
|
||||
users.map(u => `<option value="${u.id}" ${Number(source.ownerId) === Number(u.id) ? 'selected' : ''}>${esc(u.username)}</option>`)
|
||||
);
|
||||
ownerField = `
|
||||
<div class="form-group">
|
||||
<label for="source-owner">Propietario</label>
|
||||
<select id="source-owner" class="form-input">${opts.join('')}</select>
|
||||
<p class="hint">Si asignas un usuario, solo él podrá usar esta fuente en el proxy (evita doble consumo de conexiones XC).</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const nameField = `
|
||||
<div class="form-group">
|
||||
<label for="source-name">Name</label>
|
||||
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${source.name || ''}">
|
||||
<input type="text" id="source-name" class="form-input" placeholder="My Source" value="${esc(source.name)}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -235,27 +301,28 @@ class SourceManager {
|
||||
<label for="source-url">${type === 'xtream' ? 'Server URL' : 'URL'}</label>
|
||||
<input type="text" id="source-url" class="form-input"
|
||||
placeholder="${type === 'xtream' ? 'http://server.com:port' : 'https://example.com/playlist.m3u'}"
|
||||
value="${source.url || ''}">
|
||||
value="${esc(source.url)}">
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (type === 'xtream') {
|
||||
const pwVal = source.password && !String(source.password).includes('•') ? esc(source.password) : '';
|
||||
return `
|
||||
${ownerField}
|
||||
${nameField}
|
||||
${urlField}
|
||||
<div class="form-group">
|
||||
<label for="source-username">Username</label>
|
||||
<input type="text" id="source-username" class="form-input" value="${source.username || ''}">
|
||||
<input type="text" id="source-username" class="form-input" value="${esc(source.username)}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="source-password">Password</label>
|
||||
<input type="password" id="source-password" class="form-input"
|
||||
value="${source.password && !source.password.includes('•') ? source.password : ''}">
|
||||
<input type="password" id="source-password" class="form-input" value="${pwVal}">
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return nameField + urlField;
|
||||
return ownerField + nameField + urlField;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -295,7 +362,15 @@ class SourceManager {
|
||||
}
|
||||
}
|
||||
|
||||
await API.sources.create({ type, name, url, username, password });
|
||||
const payload = { type, name, url, username, password };
|
||||
if (await this.isAdminSession()) {
|
||||
const sel = document.getElementById('source-owner');
|
||||
if (sel) {
|
||||
const v = sel.value;
|
||||
payload.ownerId = v === '' ? null : parseInt(v, 10);
|
||||
}
|
||||
}
|
||||
await API.sources.create(payload);
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
await this.loadSources();
|
||||
|
||||
@@ -329,6 +404,13 @@ class SourceManager {
|
||||
data.username = username;
|
||||
if (password) data.password = password;
|
||||
}
|
||||
if (await this.isAdminSession()) {
|
||||
const sel = document.getElementById('source-owner');
|
||||
if (sel) {
|
||||
const v = sel.value;
|
||||
data.ownerId = v === '' ? null : parseInt(v, 10);
|
||||
}
|
||||
}
|
||||
|
||||
await API.sources.update(id, data);
|
||||
document.getElementById('modal').classList.remove('active');
|
||||
|
||||
@@ -28,6 +28,9 @@ class VideoPlayer {
|
||||
this.isUsingProxy = false;
|
||||
this.currentUrl = null;
|
||||
this.settingsLoaded = false;
|
||||
this.switchInProgress = false;
|
||||
this.queuedSwitch = null;
|
||||
this.lastSwitchAt = 0;
|
||||
|
||||
// Settings - start with defaults, load from server async
|
||||
this.settings = this.getDefaultSettings();
|
||||
@@ -53,7 +56,7 @@ class VideoPlayer {
|
||||
forceTranscode: false,
|
||||
forceRemux: false,
|
||||
autoTranscode: true,
|
||||
streamFormat: 'm3u8',
|
||||
streamFormat: 'ts',
|
||||
epgRefreshInterval: '24'
|
||||
};
|
||||
}
|
||||
@@ -71,7 +74,7 @@ class VideoPlayer {
|
||||
console.warn('[Player] Failed to load settings from server, using defaults:', err.message);
|
||||
// Fall back to localStorage for backwards compatibility
|
||||
try {
|
||||
const saved = localStorage.getItem('nodecast_tv_player_settings');
|
||||
const saved = localStorage.getItem('kiratv_player_settings');
|
||||
if (saved) {
|
||||
this.settings = { ...this.getDefaultSettings(), ...JSON.parse(saved) };
|
||||
console.log('[Player] Settings loaded from localStorage (fallback)');
|
||||
@@ -93,7 +96,7 @@ class VideoPlayer {
|
||||
console.error('[Player] Error saving settings to server:', err);
|
||||
// Also save to localStorage as backup
|
||||
try {
|
||||
localStorage.setItem('nodecast_tv_player_settings', JSON.stringify(this.settings));
|
||||
localStorage.setItem('kiratv_player_settings', JSON.stringify(this.settings));
|
||||
} catch (localErr) {
|
||||
console.error('[Player] Error saving to localStorage:', localErr);
|
||||
}
|
||||
@@ -813,37 +816,58 @@ class VideoPlayer {
|
||||
* Start a HLS transcode session
|
||||
*/
|
||||
async startTranscodeSession(url, options = {}) {
|
||||
try {
|
||||
console.log('[Player] Starting HLS transcode session...', options);
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, ...options })
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to start session');
|
||||
const session = await res.json();
|
||||
this.currentSessionId = session.sessionId;
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
console.error('[Player] Session start failed:', err);
|
||||
// Fallback to direct transcode if session fails
|
||||
return `/api/transcode?url=${encodeURIComponent(url)}`;
|
||||
console.log('[Player] Starting HLS transcode session...', options);
|
||||
let lastError = null;
|
||||
|
||||
// Retry a couple of times to ride out provider-side 5xx/rate-limit spikes.
|
||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||
try {
|
||||
const res = await fetch('/api/transcode/session', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url, ...options })
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to start session (${res.status})`);
|
||||
}
|
||||
const session = await res.json();
|
||||
this.currentSessionId = session.sessionId;
|
||||
return session.playlistUrl;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
console.warn(`[Player] Session start failed (attempt ${attempt}/3):`, err?.message || err);
|
||||
if (attempt < 3) {
|
||||
await new Promise(resolve => setTimeout(resolve, 900));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Avoid fallback to /api/transcode direct stream: it tends to overlap sessions
|
||||
// and breaks providers with strict single-connection limits.
|
||||
throw lastError || new Error('Failed to start transcode session');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop and cleanup current transcode session
|
||||
*/
|
||||
async stopTranscodeSession() {
|
||||
if (this.currentSessionId) {
|
||||
console.log('[Player] Stopping transcode session:', this.currentSessionId);
|
||||
try {
|
||||
// Fire and forget cleanup
|
||||
fetch(`/api/transcode/${this.currentSessionId}`, { method: 'DELETE' });
|
||||
} catch (err) {
|
||||
if (!this.currentSessionId) return;
|
||||
|
||||
const sessionId = this.currentSessionId;
|
||||
this.currentSessionId = null;
|
||||
console.log('[Player] Stopping transcode session:', sessionId);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1500);
|
||||
await fetch(`/api/transcode/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
signal: controller.signal
|
||||
});
|
||||
clearTimeout(timer);
|
||||
} catch (err) {
|
||||
if (err?.name !== 'AbortError') {
|
||||
console.error('Failed to stop session:', err);
|
||||
}
|
||||
this.currentSessionId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -851,14 +875,26 @@ class VideoPlayer {
|
||||
* Play a channel
|
||||
*/
|
||||
async play(channel, streamUrl) {
|
||||
// Avoid overlapping channel changes; keep only the latest zap request.
|
||||
if (this.switchInProgress) {
|
||||
this.queuedSwitch = { channel, streamUrl };
|
||||
return;
|
||||
}
|
||||
this.switchInProgress = true;
|
||||
this.currentChannel = channel;
|
||||
|
||||
try {
|
||||
// Stop any WatchPage playback (movies/series) before starting Live TV
|
||||
window.app?.pages?.watch?.stop?.();
|
||||
|
||||
// Stop current playback
|
||||
this.stop();
|
||||
// Stop current playback (sends DELETE to worker → FFmpeg dies → Dispatcharr frees the slot)
|
||||
await this.stop();
|
||||
|
||||
// Give Dispatcharr enough time to release the stream slot before probing the new channel.
|
||||
// 2500ms covers most providers; retries in the probe endpoint handle the edge cases.
|
||||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||||
|
||||
this.lastSwitchAt = Date.now();
|
||||
this.updateTranscodeStatus('hidden');
|
||||
|
||||
// Hide "select a channel" overlay
|
||||
@@ -932,15 +968,18 @@ class VideoPlayer {
|
||||
window.dispatchEvent(new CustomEvent('channelChanged', { detail: channel }));
|
||||
return;
|
||||
} else if (info.needsRemux) {
|
||||
// Raw .ts container - use remux
|
||||
console.log('[Player] Auto: Using remux (.ts container)');
|
||||
this.updateTranscodeStatus('remuxing', 'Remux (Auto)');
|
||||
const remuxUrl = `/api/remux?url=${encodeURIComponent(streamUrl)}`;
|
||||
this.currentUrl = remuxUrl;
|
||||
this.video.src = remuxUrl;
|
||||
this.video.play().catch(e => {
|
||||
if (e.name !== 'AbortError') console.log('[Player] Autoplay prevented:', e);
|
||||
// Algunos streams TS fallan en remux directo (ffmpeg mp4 mux errors).
|
||||
// En ese caso es mas estable usar sesion HLS con video copy.
|
||||
console.log('[Player] Auto: Using HLS transcode session (copy for remux-needed stream)');
|
||||
this.updateTranscodeStatus('transcoding', 'Transcoding (Auto)');
|
||||
const playlistUrl = await this.startTranscodeSession(streamUrl, {
|
||||
videoMode: 'copy',
|
||||
videoCodec: info.video,
|
||||
audioCodec: info.audio,
|
||||
audioChannels: info.audioChannels
|
||||
});
|
||||
this.currentUrl = playlistUrl;
|
||||
this.playHls(playlistUrl);
|
||||
this.updateNowPlaying(channel);
|
||||
this.showNowPlayingOverlay();
|
||||
this.fetchEpgData(channel);
|
||||
@@ -1183,6 +1222,13 @@ class VideoPlayer {
|
||||
} catch (err) {
|
||||
console.error('Error playing channel:', err);
|
||||
this.showError('Failed to play channel');
|
||||
} finally {
|
||||
this.switchInProgress = false;
|
||||
if (this.queuedSwitch) {
|
||||
const next = this.queuedSwitch;
|
||||
this.queuedSwitch = null;
|
||||
this.play(next.channel, next.streamUrl);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1387,9 +1433,9 @@ class VideoPlayer {
|
||||
/**
|
||||
* Stop playback
|
||||
*/
|
||||
stop() {
|
||||
async stop() {
|
||||
// Stop any running transcode session first
|
||||
this.stopTranscodeSession();
|
||||
await this.stopTranscodeSession();
|
||||
|
||||
if (this.hls) {
|
||||
this.hls.destroy();
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* KiraTV — helpers para escritorio (Electron o shell WebView2 + panel Win32).
|
||||
*/
|
||||
(function () {
|
||||
function showToast(msg, type, durationMs) {
|
||||
let container = document.getElementById('kiratv-desktop-toast');
|
||||
if (!container) {
|
||||
container = document.createElement('div');
|
||||
container.id = 'kiratv-desktop-toast';
|
||||
Object.assign(container.style, {
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: '99999',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
pointerEvents: 'none'
|
||||
});
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
Object.assign(toast.style, {
|
||||
background: type === 'error' ? '#c0392b' : '#1a7f4a',
|
||||
color: '#fff',
|
||||
padding: '10px 20px',
|
||||
borderRadius: '8px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'sans-serif',
|
||||
boxShadow: '0 4px 16px rgba(0,0,0,.5)',
|
||||
opacity: '1',
|
||||
transition: 'opacity 0.4s'
|
||||
});
|
||||
toast.textContent = msg;
|
||||
container.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.style.opacity = '0';
|
||||
setTimeout(() => toast.remove(), 450);
|
||||
}, durationMs || 3500);
|
||||
}
|
||||
|
||||
function unwrapDesktopWrapperUrl(urlLike) {
|
||||
try {
|
||||
const u = new URL(String(urlLike), window.location.origin);
|
||||
const wrappedPath = u.pathname;
|
||||
if (
|
||||
wrappedPath === '/api/transcode' ||
|
||||
wrappedPath === '/api/remux' ||
|
||||
wrappedPath === '/api/proxy/stream'
|
||||
) {
|
||||
const original = u.searchParams.get('url');
|
||||
if (original) return original;
|
||||
}
|
||||
} catch (_) {
|
||||
/* */
|
||||
}
|
||||
return urlLike;
|
||||
}
|
||||
|
||||
function absolutePlaybackUrl(streamUrl) {
|
||||
if (!streamUrl) return '';
|
||||
const raw = String(streamUrl).trim();
|
||||
const s = String(unwrapDesktopWrapperUrl(raw)).trim();
|
||||
if (/^https?:\/\//i.test(s)) return s;
|
||||
return window.location.origin + (s.startsWith('/') ? s : '/' + s);
|
||||
}
|
||||
|
||||
async function resolveNativePlaybackUrl(streamUrl) {
|
||||
const abs = absolutePlaybackUrl(streamUrl);
|
||||
try {
|
||||
const u = new URL(abs);
|
||||
if (/\/api\/proxy\/xtream\/[^/]+\/stream\/[^/]+/i.test(u.pathname)) {
|
||||
const r = await fetch(abs, { credentials: 'include' });
|
||||
if (r.ok) {
|
||||
const j = await r.json();
|
||||
if (j && typeof j.url === 'string' && j.url.trim()) {
|
||||
return j.url.trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
/* usar abs */
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
function authBearerPayload() {
|
||||
try {
|
||||
const t = localStorage.getItem('authToken');
|
||||
return t && t.length > 0 ? { authorizationBearer: t } : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function authBearerPayloadFor(url) {
|
||||
try {
|
||||
const u = new URL(url, window.location.origin);
|
||||
if (u.origin !== window.location.origin) return {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
return authBearerPayload();
|
||||
}
|
||||
|
||||
function elementToBounds(el) {
|
||||
if (!el) return null;
|
||||
const r = el.getBoundingClientRect();
|
||||
return {
|
||||
left: Math.round(r.left),
|
||||
top: Math.round(r.top),
|
||||
width: Math.max(1, Math.round(r.width)),
|
||||
height: Math.max(1, Math.round(r.height))
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Rect para Electron (--wid sobre Chromium). En WebView2+panel Win32 no se usa.
|
||||
*/
|
||||
function getEmbedBounds() {
|
||||
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return null;
|
||||
if (window.kiratvDesktop.embedUsesNativePanel) return null;
|
||||
|
||||
const watchPage = document.getElementById('page-watch');
|
||||
if (watchPage && watchPage.classList.contains('active')) {
|
||||
const videoEl = document.getElementById('watch-video');
|
||||
if (videoEl) return elementToBounds(videoEl);
|
||||
const section = document.querySelector('.watch-video-section');
|
||||
if (section) return elementToBounds(section);
|
||||
}
|
||||
|
||||
const livePage = document.getElementById('page-live');
|
||||
if (livePage && livePage.classList.contains('active')) {
|
||||
const ps = document.querySelector('.player-section');
|
||||
if (ps) return elementToBounds(ps);
|
||||
}
|
||||
|
||||
const ps = document.querySelector('.player-section');
|
||||
if (ps && ps.offsetParent !== null) return elementToBounds(ps);
|
||||
const wv = document.getElementById('watch-video');
|
||||
if (wv && wv.offsetParent !== null) return elementToBounds(wv);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function embedUsesNativePanel() {
|
||||
return !!(window.kiratvDesktop && window.kiratvDesktop.embedUsesNativePanel);
|
||||
}
|
||||
|
||||
function setEmbeddedUiClass(active) {
|
||||
const native = embedUsesNativePanel();
|
||||
document.body?.classList.toggle('kiratv-win-embedded', !!(active && !native));
|
||||
document.body?.classList.toggle('kiratv-shell-wv2-playing', !!(active && native));
|
||||
}
|
||||
|
||||
/**
|
||||
* Modo cromo ventana: WebView2 oculta panel vídeo en rutas sin reproductor nativo.
|
||||
*/
|
||||
function shellChromeModeFromHash() {
|
||||
const raw = location.hash || '#home';
|
||||
const h = raw.replace(/^#\//, '#').replace(/^#/, '').split(/[/?&]/)[0];
|
||||
if (h === 'live') return 'liveSplit';
|
||||
if (h === 'watch') return 'vodSplit';
|
||||
return 'fullWeb';
|
||||
}
|
||||
|
||||
function syncShellChrome() {
|
||||
const d = window.kiratvDesktop;
|
||||
if (typeof d?.setShellChromeMode !== 'function') return;
|
||||
const mode = shellChromeModeFromHash();
|
||||
d.setShellChromeMode({ mode }).catch(() => {});
|
||||
|
||||
if (embedUsesNativePanel() && mode === 'fullWeb') {
|
||||
d.stopExternalMpv?.().catch(() => {});
|
||||
if (typeof window.kiratvDesktopClearEmbeddedChrome === 'function') {
|
||||
window.kiratvDesktopClearEmbeddedChrome();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function initEmbedBoundsSync() {
|
||||
if (!window.kiratvDesktop?.supportsEmbeddedMpv) return;
|
||||
if (embedUsesNativePanel()) return;
|
||||
if (typeof window.kiratvDesktop?.setVideoHostBounds !== 'function') return;
|
||||
|
||||
let t = null;
|
||||
function flushBounds() {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => {
|
||||
const b = getEmbedBounds();
|
||||
if (!b) return;
|
||||
window.kiratvDesktop.setVideoHostBounds(b).catch(() => {});
|
||||
}, 72);
|
||||
}
|
||||
|
||||
window.addEventListener('resize', flushBounds);
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
const ro = new ResizeObserver(flushBounds);
|
||||
document.querySelectorAll('.player-section, .watch-video-section, #watch-video').forEach((el) => {
|
||||
try {
|
||||
ro.observe(el);
|
||||
} catch (_) {
|
||||
/* */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', flushBounds);
|
||||
|
||||
flushBounds();
|
||||
}
|
||||
|
||||
function useEmbeddedPlayback() {
|
||||
const d = window.kiratvDesktop;
|
||||
return !!(d?.supportsEmbeddedMpv && typeof d.playEmbeddedMpv === 'function');
|
||||
}
|
||||
|
||||
async function playInMpv(absUrl, extraArgs) {
|
||||
const d = window.kiratvDesktop;
|
||||
if (!d?.playExternalMpv) {
|
||||
showToast('Error: sin puente escritorio', 'error');
|
||||
return { ok: false, error: 'Sin puente escritorio' };
|
||||
}
|
||||
|
||||
const nativeUrl = await resolveNativePlaybackUrl(absUrl);
|
||||
|
||||
if (useEmbeddedPlayback()) {
|
||||
const native = embedUsesNativePanel();
|
||||
|
||||
let bounds = null;
|
||||
if (!native) {
|
||||
bounds = getEmbedBounds();
|
||||
if (!bounds || bounds.width < 2 || bounds.height < 2) {
|
||||
showToast('No hay área de vídeo visible — cambia de página o redimensiona', 'error');
|
||||
return { ok: false, error: 'bounds vídeo vacíos' };
|
||||
}
|
||||
showToast('▶ Reproduciendo en la misma ventana (mpv)', 'ok', 2800);
|
||||
try {
|
||||
await d.setVideoHostBounds(bounds);
|
||||
} catch (_) {
|
||||
/* */
|
||||
}
|
||||
} else {
|
||||
showToast('▶ Reproduciendo en panel nativo (mpv)', 'ok', 2800);
|
||||
}
|
||||
|
||||
setEmbeddedUiClass(true);
|
||||
|
||||
const payload = {
|
||||
url: nativeUrl,
|
||||
extraArgs: extraArgs || [],
|
||||
...authBearerPayloadFor(nativeUrl)
|
||||
};
|
||||
if (!native && bounds) payload.bounds = bounds;
|
||||
|
||||
const result = await d.playEmbeddedMpv(payload);
|
||||
|
||||
if (!result?.ok) {
|
||||
setEmbeddedUiClass(false);
|
||||
const msg = result?.error || 'mpv no pudo arrancar';
|
||||
console.error('[KiraTV Desktop]', msg);
|
||||
showToast('⚠ ' + msg, 'error', 6000);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
showToast('▶ Abriendo en reproductor nativo…', 'ok', 2500);
|
||||
|
||||
const result = await d.playExternalMpv({
|
||||
url: nativeUrl,
|
||||
extraArgs: extraArgs || [],
|
||||
...authBearerPayloadFor(nativeUrl)
|
||||
});
|
||||
|
||||
if (!result?.ok) {
|
||||
const msg = result?.error || 'mpv no pudo arrancar';
|
||||
console.error('[KiraTV Desktop]', msg);
|
||||
showToast(
|
||||
'⚠ ' + msg + (msg.includes('mpv') ? '' : ' — ¿mpv instalado?'),
|
||||
'error',
|
||||
6000
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function whenReady(cb) {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', cb, { once: true });
|
||||
} else {
|
||||
cb();
|
||||
}
|
||||
}
|
||||
|
||||
whenReady(() => {
|
||||
if (window.kiratvDesktop?.isDesktopApp) {
|
||||
document.body.classList.add('kiratv-desktop-app');
|
||||
}
|
||||
if (window.kiratvDesktop?.supportsEmbeddedMpv) {
|
||||
initEmbedBoundsSync();
|
||||
}
|
||||
window.addEventListener('hashchange', syncShellChrome);
|
||||
setTimeout(syncShellChrome, 0);
|
||||
});
|
||||
|
||||
window.kiratvDesktopClearEmbeddedChrome = function () {
|
||||
setEmbeddedUiClass(false);
|
||||
};
|
||||
|
||||
window.kiratvAbsolutePlaybackUrl = absolutePlaybackUrl;
|
||||
window.kiratvEmbedBoundsFlush = () => getEmbedBounds();
|
||||
|
||||
window.kiratvPlayLiveInNativePlayer = function (absoluteUrl) {
|
||||
return playInMpv(absoluteUrl);
|
||||
};
|
||||
|
||||
window.kiratvPlayVodInNativePlayer = function (absoluteUrl, extraArgs) {
|
||||
return playInMpv(absoluteUrl, extraArgs);
|
||||
};
|
||||
})();
|
||||
@@ -130,7 +130,7 @@ class SettingsPage {
|
||||
if (forceTranscodeToggle) forceTranscodeToggle.checked = s.forceTranscode === true;
|
||||
if (forceVideoTranscodeToggle) forceVideoTranscodeToggle.checked = s.forceVideoTranscode === true;
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
|
||||
if (userAgentSelect) userAgentSelect.value = s.userAgentPreset || 'chrome';
|
||||
if (userAgentCustomInput) userAgentCustomInput.value = s.userAgentCustom || '';
|
||||
if (customUaContainer) {
|
||||
@@ -575,7 +575,7 @@ class SettingsPage {
|
||||
if (forceRemuxToggle) forceRemuxToggle.checked = s.forceRemux || false;
|
||||
if (autoTranscodeToggle) autoTranscodeToggle.checked = s.autoTranscode || false;
|
||||
if (epgRefreshSelect) epgRefreshSelect.value = s.epgRefreshInterval || '24';
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'm3u8';
|
||||
if (streamFormatSelect) streamFormatSelect.value = s.streamFormat || 'ts';
|
||||
|
||||
// User-Agent settings
|
||||
const userAgentSelect = document.getElementById('setting-user-agent');
|
||||
|
||||
@@ -611,7 +611,7 @@ class WatchPage {
|
||||
}
|
||||
|
||||
setVolumeFromStorage() {
|
||||
const savedVolume = localStorage.getItem('nodecast-volume') || '80';
|
||||
const savedVolume = localStorage.getItem('kiratv-volume') || '80';
|
||||
this.video.volume = parseInt(savedVolume) / 100;
|
||||
if (this.volumeSlider) this.volumeSlider.value = savedVolume;
|
||||
}
|
||||
@@ -677,7 +677,7 @@ class WatchPage {
|
||||
if (this.video) {
|
||||
this.video.volume = value / 100;
|
||||
this.video.muted = false;
|
||||
localStorage.setItem('nodecast-volume', value);
|
||||
localStorage.setItem('kiratv-volume', value);
|
||||
this.updateVolumeUI();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user