UNPKG

peertube-plugin-youth-schedule

Version:

PeerTube plugin: youth protection schedule (configurable HH:mm slots and end time)

405 lines (342 loc) 15.4 kB
let __youthScheduleDecision = { decided: false, allowed: true, open: null, end: null, videoUuid: null } let __blockedVideoHandlers = new WeakMap() async function register({ registerHook }) { const config = await fetch('/plugins/youth-schedule/router/config') .then(r => r.json()) .catch(() => ({ timeSource: 'server', endTime: '06:00' })) const getNowMinutes = () => { const n = new Date() return n.getHours() * 60 + n.getMinutes() } const wait = (ms) => new Promise(r => setTimeout(r, ms)) async function checkAllowed(videoUuid) { const params = new URLSearchParams() if (config.timeSource === 'user') params.set('clientMinutes', String(getNowMinutes())) const url = `/plugins/youth-schedule/router/video/${videoUuid}/youth-allowed?${params}` const delays = [300, 800, 1500] for (let i = 0; i <= delays.length; i++) { try { const controller = new AbortController() const timeoutId = setTimeout(() => controller.abort(), 5000) const res = await fetch(url, { signal: controller.signal }) clearTimeout(timeoutId) if (res.ok) { return await res.json() } console.warn('[YouthSchedule] API error:', res.status, res.statusText) } catch (err) { console.warn(`[YouthSchedule] Network error attempt ${i + 1}:`, err) } if (i < delays.length) { await wait(delays[i]) } } // Fail-closed: если API недоступен, блокируем видео return { allowed: false, isError: true } } function getPlayerRoot() { // Приоритет: специальные контейнеры PeerTube и обёртки Video.js const candidates = [ '#plugin-selector-player-container', '#videojs-wrapper', '#video-wrapper', '#player' ] for (const sel of candidates) { const el = document.querySelector(sel) if (el) { console.log('[YouthSchedule] player root via selector:', sel) return el } } // Пробуем сам контейнер Video.js const vjsContainer = document.querySelector('.video-js.vjs-peertube-skin') || document.querySelector('[id^="vjs_video_"]') if (vjsContainer) { console.log('[YouthSchedule] player root via videojs container') // Если есть родитель-обёртка — используем её, иначе сам контейнер const wrapper = vjsContainer.closest('#videojs-wrapper, #video-wrapper') return wrapper || vjsContainer } // Пробуем найти <video> и подняться к обёртке const vid = document.querySelector('video.vjs-tech') || document.querySelector('video') if (vid) { const outer = vid.closest('#videojs-wrapper, #video-wrapper, .video-js') || vid.parentElement if (outer) { console.log('[YouthSchedule] player root via video parent') return outer } } console.warn('[YouthSchedule] player root not found; please share DOM around <video>.') return null } // CSS стили if (!document.getElementById('youth-schedule-style')) { const style = document.createElement('style') style.id = 'youth-schedule-style' style.textContent = ` .youth-schedule-init-hidden { visibility: hidden !important; } .youth-schedule-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.75); display: flex; align-items: center; justify-content: center; z-index: 2147483647; padding: 16px; text-align: center; } .youth-schedule-overlay .msg { max-width: 720px; color: #fff; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, 'Helvetica Neue', Arial, sans-serif; } .youth-schedule-overlay .msg .title { font-size: 1.1rem; margin-bottom: 8px; } .youth-schedule-overlay .msg .subtitle { font-size: .95rem; opacity: .9; } .youth-schedule-block-video video, .youth-schedule-block-video .video-js { pointer-events: none !important; } ` document.head.appendChild(style) } function blockVideoPlayback(video) { if (!video || __blockedVideoHandlers.has(video)) return const handler = (e) => { e.preventDefault() e.stopPropagation() video.pause() video.currentTime = 0 } // Block play event video.addEventListener('play', handler, { capture: true, passive: false }) // Block playing event video.addEventListener('playing', handler, { capture: true, passive: false }) // Block loadstart to prevent autoplay video.addEventListener('loadstart', handler, { capture: true, passive: false }) // Also try to pause immediately try { video.pause() video.currentTime = 0 } catch (_) {} __blockedVideoHandlers.set(video, handler) } function unblockVideoPlayback(video) { if (!video || !__blockedVideoHandlers.has(video)) return const handler = __blockedVideoHandlers.get(video) video.removeEventListener('play', handler, { capture: true }) video.removeEventListener('playing', handler, { capture: true }) video.removeEventListener('loadstart', handler, { capture: true }) __blockedVideoHandlers.delete(video) } function removeOverlay(playerRoot) { if (!playerRoot) return playerRoot.classList.remove('youth-schedule-block-video') const overlay = playerRoot.querySelector('.youth-schedule-overlay') if (overlay) overlay.remove() // Unblock video playback when overlay is removed const htmlVideo = playerRoot.querySelector('video') if (htmlVideo) unblockVideoPlayback(htmlVideo) } let __checkPromises = {} function applyGateScreen(playerRoot) { if (!playerRoot) return const prevPos = getComputedStyle(playerRoot).position if (prevPos === 'static' || !prevPos) playerRoot.style.position = 'relative' playerRoot.classList.add('youth-schedule-block-video') playerRoot.classList.remove('youth-schedule-init-hidden') let overlay = playerRoot.querySelector('.youth-schedule-overlay') if (!overlay) { overlay = document.createElement('div') overlay.className = 'youth-schedule-overlay' playerRoot.appendChild(overlay) } overlay.innerHTML = ` <div class="msg"> <div class="title">Überprüfung... / Checking...</div> </div> ` } function applyOverlay(playerRoot, open, end, isError) { if (!playerRoot) return const prevPos = getComputedStyle(playerRoot).position if (prevPos === 'static' || !prevPos) playerRoot.style.position = 'relative' playerRoot.classList.add('youth-schedule-block-video') playerRoot.classList.remove('youth-schedule-init-hidden') let overlay = playerRoot.querySelector('.youth-schedule-overlay') if (!overlay) { overlay = document.createElement('div') overlay.className = 'youth-schedule-overlay' playerRoot.appendChild(overlay) } if (isError) { overlay.innerHTML = ` <div class="msg"> <div class="title">Dieses Video ist aus Jugendschutzgründen derzeit nicht verfügbar (Fehler bei der Prüfung).</div> </div> ` } else { overlay.innerHTML = ` <div class="msg"> <div class="title">Dieses Video ist aus Jugendschutzgründen derzeit nicht verfügbar.</div> <div class="subtitle">Freigabe von ${open || '—'} bis ${end}.</div> </div> ` } // Block video playback const htmlVideo = playerRoot.querySelector('video') if (htmlVideo) { blockVideoPlayback(htmlVideo) } } // Check video availability when video is loaded (PeerTube 7.3+) registerHook({ target: 'action:video-watch.video.loaded', handler: async ({ video }) => { try { if (!video || !video.uuid) return // Reset decision for new video const playerRoot = getPlayerRoot() if (playerRoot) { // Remove overlay from previous video if UUID changed if (__youthScheduleDecision.videoUuid && __youthScheduleDecision.videoUuid !== video.uuid) { removeOverlay(playerRoot) } applyGateScreen(playerRoot) } // Reset decision state for new video __youthScheduleDecision = { decided: false, allowed: true, open: null, end: null, videoUuid: video.uuid, isError: false } const checkPromise = checkAllowed(video.uuid) __checkPromises[video.uuid] = checkPromise const data = await checkPromise __youthScheduleDecision = { decided: true, allowed: !!data.allowed, open: data.openLabel || null, end: data.endLabel || config.endTime || '06:00', videoUuid: video.uuid, isError: !!data.isError } console.log('[YouthSchedule:video.loaded] decision', __youthScheduleDecision) if (playerRoot) { if (!__youthScheduleDecision.allowed) { applyOverlay(playerRoot, __youthScheduleDecision.open, __youthScheduleDecision.end, __youthScheduleDecision.isError) setupVideoMonitor(playerRoot) } else { // Ensure overlay is removed if video is allowed removeOverlay(playerRoot) } } } catch (e) { console.warn('[YouthSchedule:video.loaded] error', e) } } }) // Fallback for older PeerTube versions registerHook({ target: 'action:video-watch.init', handler: async (ctx) => { try { const videoUuid = ctx && ctx.video && ctx.video.uuid if (!videoUuid) { const playerRoot = getPlayerRoot() if (playerRoot) playerRoot.classList.remove('youth-schedule-init-hidden') return } // Check if decision is for different video if (__youthScheduleDecision.decided && __youthScheduleDecision.videoUuid !== videoUuid) { // Reset for new video __youthScheduleDecision = { decided: false, allowed: true, open: null, end: null, videoUuid: null, isError: false } const playerRoot = getPlayerRoot() if (playerRoot) removeOverlay(playerRoot) } // Skip if already decided for this video if (__youthScheduleDecision.decided && __youthScheduleDecision.videoUuid === videoUuid) return const playerRoot = getPlayerRoot() if (playerRoot) applyGateScreen(playerRoot) const checkPromise = checkAllowed(videoUuid) __checkPromises[videoUuid] = checkPromise const data = await checkPromise __youthScheduleDecision = { decided: true, allowed: !!data.allowed, open: data.openLabel || null, end: data.endLabel || config.endTime || '06:00', videoUuid: videoUuid, isError: !!data.isError } console.log('[YouthSchedule:init] decision', __youthScheduleDecision) if (playerRoot) { if (!__youthScheduleDecision.allowed) { applyOverlay(playerRoot, __youthScheduleDecision.open, __youthScheduleDecision.end, __youthScheduleDecision.isError) setupVideoMonitor(playerRoot) } else { removeOverlay(playerRoot) } } } catch (e) { console.warn('[YouthSchedule:init] error', e) } } }) // Подстраховка: отключаем контролы при сборке опций плеера, если уже известно, что блокируем registerHook({ target: 'filter:internal.video-watch.player.build-options.result', handler: async (options, context) => { try { const currentVideoUuid = context && context.video && context.video.uuid // Wait for check result if it's currently running if (currentVideoUuid && __checkPromises[currentVideoUuid]) { await __checkPromises[currentVideoUuid] } // Only apply restrictions if decision is for current video if (__youthScheduleDecision.decided && __youthScheduleDecision.allowed === false && (!currentVideoUuid || __youthScheduleDecision.videoUuid === currentVideoUuid)) { options.controls = false options.responsive = false options.fluid = false options.autoplay = false if (!options.userActions) options.userActions = {} options.userActions.hotkeys = false } } catch (_) {} return options } }) // Monitor for video elements that appear later and block them if needed function setupVideoMonitor(playerRoot) { if (!playerRoot || playerRoot.dataset.youthScheduleMonitored) return playerRoot.dataset.youthScheduleMonitored = 'true' const observer = new MutationObserver(() => { if (!__youthScheduleDecision.decided || __youthScheduleDecision.allowed) return const htmlVideo = playerRoot.querySelector('video') if (htmlVideo && !__blockedVideoHandlers.has(htmlVideo)) { blockVideoPlayback(htmlVideo) } }) observer.observe(playerRoot, { childList: true, subtree: true }) // Also check immediately const htmlVideo = playerRoot.querySelector('video') if (htmlVideo && !__youthScheduleDecision.allowed) { blockVideoPlayback(htmlVideo) } } // Fallback for player.loaded hook (older versions) registerHook({ target: 'action:video-watch.player.loaded', handler: async ({ video }) => { try { if (!video || !video.uuid) return // Check if decision is for different video if (__youthScheduleDecision.decided && __youthScheduleDecision.videoUuid !== video.uuid) { // Reset for new video __youthScheduleDecision = { decided: false, allowed: true, open: null, end: null, videoUuid: null, isError: false } const playerRoot = getPlayerRoot() if (playerRoot) removeOverlay(playerRoot) } // Skip if already decided for this video if (__youthScheduleDecision.decided && __youthScheduleDecision.videoUuid === video.uuid) { if (!__youthScheduleDecision.allowed) { const playerRoot = getPlayerRoot() if (playerRoot) { applyOverlay(playerRoot, __youthScheduleDecision.open, __youthScheduleDecision.end, __youthScheduleDecision.isError) setupVideoMonitor(playerRoot) } } else { removeOverlay(getPlayerRoot()) } return } const checkPromise = checkAllowed(video.uuid) __checkPromises[video.uuid] = checkPromise const data = await checkPromise __youthScheduleDecision = { decided: true, allowed: !!data.allowed, open: data.openLabel || null, end: data.endLabel || config.endTime || '06:00', videoUuid: video.uuid, isError: !!data.isError } console.log('[YouthSchedule:player.loaded] decision', __youthScheduleDecision) if (__youthScheduleDecision.allowed) { removeOverlay(getPlayerRoot()) return } const playerRoot = getPlayerRoot() if (!playerRoot) return applyOverlay(playerRoot, __youthScheduleDecision.open, __youthScheduleDecision.end, __youthScheduleDecision.isError) setupVideoMonitor(playerRoot) } catch (_) { // игнорируем } } }) } export { register }