peertube-plugin-youth-schedule
Version:
PeerTube plugin: youth protection schedule (configurable HH:mm slots and end time)
405 lines (342 loc) • 15.4 kB
JavaScript
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 }