您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
为 Jellyfin 提供沉浸式播放控制体验:滑动进度时显示影片预览图 (Trickplay);键盘右键短按快进 10 秒 / 长按 2 倍速;移动端支持长按倍速、水平滑动调节进度、双击播放暂停;倍速显示呼吸灯动画,滑动显示时间预览和自定义进度条;智能 OSD 隐藏,打造无干扰观影环境。
// ==UserScript== // @name Jellyfin 沉浸式播放控制:Trickplay 预览 + 键盘/触控增强 // @namespace https://github.com/guiyuanyuanbao/Jellyfin-betterJellyfinWebPlayer-extension // @version 1.4 // @description 为 Jellyfin 提供沉浸式播放控制体验:滑动进度时显示影片预览图 (Trickplay);键盘右键短按快进 10 秒 / 长按 2 倍速;移动端支持长按倍速、水平滑动调节进度、双击播放暂停;倍速显示呼吸灯动画,滑动显示时间预览和自定义进度条;智能 OSD 隐藏,打造无干扰观影环境。 // @author guiyuanyuanbao // @license MIT // @match *://*/*/web/index.html // @match *://*/web/index.html // @match *://*/*/web/ // @match *://*/web/ // @run-at document-idle // @grant none // @supportURL https://github.com/guiyuanyuanbao/Jellyfin-betterJellyfinWebPlayer-extension/issues // @homepageURL https://github.com/guiyuanyuanbao/Jellyfin-betterJellyfinWebPlayer-extension // ==/UserScript== (function() { 'use strict'; console.log('[InjectScript] Jellyfin speed control script loaded'); const LONG_PRESS_MS = 300; const SHORT_SEEK_S = 10; let pressTimer = null; let originalRate = 1; let isFast = false; let osdBottomElement = null; // 添加OSD元素引用 let headerElement = null; // 添加頭部元素引用 // --- Trickplay 相關變數 --- let trickplayData = null; let lastVideoSrc = null; // 用於追蹤影片來源是否變更 function getVideo() { return document.querySelector('video'); } // --- 全新改寫:獲取 Trickplay 資訊 (兼容直接播放和轉碼) --- async function getTrickplayInfo() { console.log('[Inject] 正在嘗試獲取 Trickplay 資訊...'); const vid = getVideo(); if (!vid) return null; // --- 方案 A: 嘗試從 URL 直接解析 (適用於直接播放) --- if (vid.src && !vid.src.startsWith('blob:')) { try { const url = new URL(vid.src); const apiKey = url.searchParams.get('api_key'); const match = url.pathname.match(/\/Videos\/([a-f0-9]+)\//); if (apiKey && match && match[1]) { const info = { itemId: match[1], apiKey: apiKey, baseUrl: `${url.protocol}//${url.host}` }; console.log('[Inject] 成功從 URL 獲取資訊 (方案A):', info); return info; } } catch (e) { console.log('[Inject] 從 URL 解析失敗,將嘗試方案B。'); } } // --- 方案 B: 從 API 獲取 (適用於轉碼 'blob:' URL 或方案A失敗時) --- console.log('[Inject] URL 為 blob 或解析失敗,正在啟動 API 方案 (方案B)...'); try { if (typeof ApiClient === 'undefined') { console.log('[Inject] 找不到 ApiClient。'); return null; } const apiKey = ApiClient.accessToken(); const baseUrl = ApiClient.serverAddress(); const deviceId = ApiClient.deviceId(); const sessionUrl = `${baseUrl}/Sessions`; const response = await fetch(sessionUrl, { headers: { 'X-Emby-Token': apiKey } }); if (!response.ok) throw new Error(`API 請求失敗,狀態: ${response.status}`); const sessions = await response.json(); // 精準查找與當前裝置 ID 匹配,並且正在播放內容的 session const currentSession = sessions.find(s => s.DeviceId === deviceId && s.NowPlayingItem); if (currentSession) { const info = { itemId: currentSession.NowPlayingItem.Id, apiKey: apiKey, baseUrl: baseUrl }; console.log('[Inject] 成功從 Sessions API 獲取資訊 (方案B):', info); return info; } } catch (error) { console.error('[Inject] 透過 API 獲取資訊時出錯:', error); return null; } console.log('[Inject] 方案A和B均失敗,無法獲取 Trickplay 資訊。'); return null; } // --- 獲取並解析 M3U8 檔案 --- async function setupTrickplay() { console.log('[Inject] 正在設定 Trickplay...'); // getTrickplayInfo 現在是異步的,需要 await const trickplayInfo = await getTrickplayInfo(); if (!trickplayInfo) { console.log('[Inject] 無法獲取 Trickplay 資訊,設定中止。'); trickplayData = null; return; } const m3u8Url = `${trickplayInfo.baseUrl}/Videos/${trickplayInfo.itemId}/Trickplay/320/tiles.m3u8?api_key=${trickplayInfo.apiKey}`; try { const response = await fetch(m3u8Url); if (!response.ok) throw new Error(`HTTP 錯誤!狀態: ${response.status}`); const m3u8Text = await response.text(); const baseImageUrl = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1); console.log('[Inject] 推導出的圖片基礎路徑:', baseImageUrl); const lines = m3u8Text.split('\n'); const tilesLine = lines.find(line => line.startsWith('#EXT-X-TILES')); if (!tilesLine) throw new Error('在 M3U8 中找不到 #EXT-X-TILES 標籤。'); const resolutionMatch = tilesLine.match(/RESOLUTION=(\d+)x(\d+)/); const layoutMatch = tilesLine.match(/LAYOUT=(\d+)x(\d+)/); const durationMatch = tilesLine.match(/DURATION=([\d.]+)/); if (!resolutionMatch || !layoutMatch || !durationMatch) { throw new Error('無法解析 #EXT-X-TILES 標籤的屬性。'); } const images = lines.filter(line => line.includes('.jpg')); trickplayData = { width: parseInt(resolutionMatch[1], 10), height: parseInt(resolutionMatch[2], 10), cols: parseInt(layoutMatch[1], 10), rows: parseInt(layoutMatch[2], 10), interval: parseFloat(durationMatch[1]), images: images, thumbsPerImage: parseInt(layoutMatch[1], 10) * parseInt(layoutMatch[2], 10), baseImageUrl: baseImageUrl }; console.log('[Inject] Trickplay 數據已成功載入:', trickplayData); } catch (error) { console.error('[Inject] 獲取或解析 Trickplay M3U8 失敗:', error); trickplayData = null; } } // --- 更新並顯示 Trickplay 預覽 --- function updateTrickplay(time) { if (!trickplayData || !getVideo()?.duration) return; const previewEl = document.getElementById('trickplay-preview'); if (!previewEl) return; const timePerImage = trickplayData.thumbsPerImage * trickplayData.interval; const imageIndex = Math.floor(time / timePerImage); const timeInImage = time % timePerImage; const thumbIndex = Math.floor(timeInImage / trickplayData.interval); if (imageIndex >= trickplayData.images.length) return; const imageName = trickplayData.images[imageIndex]; const imageUrl = `${trickplayData.baseImageUrl}${imageName}`; const col = thumbIndex % trickplayData.cols; const row = Math.floor(thumbIndex / trickplayData.cols); const bgPosX = -col * trickplayData.width; const bgPosY = -row * trickplayData.height; previewEl.style.width = `${trickplayData.width}px`; previewEl.style.height = `${trickplayData.height}px`; // 添加对于油猴的兼容性支持 let backgroundImage = "url(" + imageUrl + ")"; previewEl.style.backgroundImage = `${backgroundImage}`; previewEl.style.backgroundPosition = `${bgPosX}px ${bgPosY}px`; previewEl.style.display = 'block'; } // --- 隱藏 Trickplay 預覽 --- function hideTrickplay() { const previewEl = document.getElementById('trickplay-preview'); if (previewEl) { previewEl.style.display = 'none'; } } function injectStyles() { const css = ` #speed-overlay { border-radius: 8px; position: absolute; top: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.6); color: #fff; padding: 4px 8px; font-size: 12px; display: none; align-items: center; z-index: 9999; pointer-events: none; } @keyframes breathe { 0%, 100% { opacity: 0.4; } 50% { opacity: 1; } } #speed-overlay .tri { width: 0; height: 0; border-top: 6px solid transparent; border-bottom: 6px solid transparent; border-left: 8px solid #fff; margin-right: 4px; animation: breathe 1.5s infinite ease-in-out; } #speed-overlay .tri:nth-child(2) { animation-delay: 0.5s; } #speed-overlay .tri:nth-child(3) { animation-delay: 1s; } #time-overlay { border-radius: 12px; position: fixed; bottom: 20px; right: 20px; background: rgba(0, 0, 0, 0.8); color: #fff; padding: 8px 16px; font-size: 16px; font-weight: bold; display: none; z-index: 10001; pointer-events: none; text-align: center; } #custom-progress-bar { position: fixed; bottom: 0; left: 0; width: 100%; height: 4px; background: linear-gradient(130deg,#a95bc2,#00a4db); z-index: 9997; display: none; pointer-events: none; transition: none; transform-origin: left center; border-radius: 0 2px 0 0; } #custom-progress-bar::before { content: ''; position: fixed; bottom: 0; left: 0; width: 100%; height: 4px; background: rgba(255, 255, 255, 0.3); z-index: -1; } #custom-progress-bar::after { content: ''; position: absolute; top: 0; right: 0; width: 8px; height: 100%; background: rgba(255, 255, 255, 0.8); border-radius: 0 2px 0 0; } #trickplay-preview { position: fixed; bottom: 20px; left: 20px; display: none; border: 2px solid rgba(255, 255, 255, 0.7); border-radius: 4px; background-color: #000; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5); background-repeat: no-repeat; z-index: 10000; pointer-events: none; } /* 原生進度條樣式 - 與自訂進度條顏色保持一致 */ .mdl-slider-background-lower { background: linear-gradient(130deg,#a95bc2,#00a4db) !important; } .osdPositionSlider::-webkit-slider-thumb { background: #00a4db !important; border: 2px solid #ffffff !important; box-shadow: 0 0 6px rgba(0, 164, 219, 0.5) !important; } .osdPositionSlider::-moz-range-thumb { background: #00a4db !important; border: 2px solid #ffffff !important; box-shadow: 0 0 6px rgba(0, 164, 219, 0.5) !important; } /* 圖示OSD進度條樣式 - 與其他進度條顏色保持一致 */ .iconOsdProgressInner { background: linear-gradient(130deg,#a95bc2,#00a4db) !important; } .iconOsdProgressOuter { background: rgba(255, 255, 255, 0.3) !important; } /* 腳本專屬的隱藏CSS類 */ .script-osd-hidden { display: none !important; visibility: hidden !important; opacity: 0 !important; } /* MDL載入動畫漸變樣式 - 與進度條顏色保持一致 */ .mdl-spinner__circle { border: none !important; background: conic-gradient( from 0deg, #a95bc2 0deg, #9456b5 60deg, #7c6bc9 120deg, #00a4db 180deg, #a95bc2 240deg, transparent 240deg, transparent 360deg ) !important; border-radius: 50% !important; position: relative !important; mask: radial-gradient(circle at center, transparent 44%, black 45%, black 47%, transparent 48%) !important; -webkit-mask: radial-gradient(circle at center, transparent 44%, black 45%, black 47%, transparent 48%) !important; filter: drop-shadow(0 0 3px rgba(169, 91, 194, 0.3)) !important; } .mdl-spinner__circle::before { display: none !important; } .mdl-spinner__circleLeft, .mdl-spinner__circleRight { border: none !important; background: transparent !important; } /* 移除單獨的層級樣式,使用統一的漸變圓環 */ .mdl-spinner__layer-1 .mdl-spinner__circle, .mdl-spinner__layer-2 .mdl-spinner__circle, .mdl-spinner__layer-3 .mdl-spinner__circle, .mdl-spinner__layer-4 .mdl-spinner__circle { border: none !important; background: conic-gradient( from 0deg, #a95bc2 0deg, #9456b5 60deg, #7c6bc9 120deg, #00a4db 180deg, #a95bc2 240deg, transparent 240deg, transparent 360deg ) !important; mask: radial-gradient(circle at center, transparent 44%, black 45%, black 47%, transparent 48%) !important; -webkit-mask: radial-gradient(circle at center, transparent 44%, black 45%, black 47%, transparent 48%) !important; filter: drop-shadow(0 0 3px rgba(169, 91, 194, 0.3)) !important; } /* 確保spinner圓形完整顯示 */ .mdl-spinner__circle-clipper .mdl-spinner__circle { border: none !important; background: conic-gradient( from 0deg, #a95bc2 0deg, #9456b5 60deg, #7c6bc9 120deg, #00a4db 180deg, #a95bc2 240deg, transparent 240deg, transparent 360deg ) !important; border-radius: 50% !important; mask: radial-gradient(circle at center, transparent 44%, black 45%, black 47%, transparent 48%) !important; -webkit-mask: radial-gradient(circle at center, transparent 44%, black 45%, black 47%, transparent 48%) !important; filter: drop-shadow(0 0 3px rgba(169, 91, 194, 0.3)) !important; } /* 增強動畫的流暢性 */ .mdl-spinner { animation-timing-function: cubic-bezier(0.4, 0.0, 0.2, 1) !important; } `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } function createOverlay(container) { const ov = document.createElement('div'); ov.id = 'speed-overlay'; container.style.position = container.style.position || 'relative'; container.appendChild(ov); const timeOv = document.createElement('div'); timeOv.id = 'time-overlay'; document.body.appendChild(timeOv); const progressBar = document.createElement('div'); progressBar.id = 'custom-progress-bar'; document.body.appendChild(progressBar); const trickplayPreview = document.createElement('div'); trickplayPreview.id = 'trickplay-preview'; document.body.appendChild(trickplayPreview); return ov; } function showOverlay(rate) { overlay.innerHTML = ` <span class="tri"></span> <span class="tri"></span> <span class="tri"></span> <span class="text">倍速播放中 ×${rate.toFixed(1)}</span> `; overlay.style.display = 'flex'; } function hideOverlay() { overlay.style.display = 'none'; } function showTimeOverlay(currentTime, duration) { const timeOverlay = document.getElementById('time-overlay'); if (timeOverlay) { const formatTime = (seconds) => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${secs.toString().padStart(2, '0')}`; }; timeOverlay.textContent = `${formatTime(currentTime)} / ${formatTime(duration)}`; timeOverlay.style.display = 'block'; } } function hideTimeOverlay() { const timeOverlay = document.getElementById('time-overlay'); if (timeOverlay) { timeOverlay.style.display = 'none'; } } function hideOSDControls() { if (osdBottomElement) { if (!osdBottomElement.classList.contains('videoOsdBottom-hidden')) { osdBottomElement.classList.add('videoOsdBottom-hidden'); } if (!osdBottomElement.classList.contains('hide')) { osdBottomElement.classList.add('hide'); } if (!osdBottomElement.classList.contains('script-osd-hidden')) { osdBottomElement.classList.add('script-osd-hidden'); } } if (headerElement) { if (!headerElement.classList.contains('osdHeader-hidden')) { headerElement.classList.add('osdHeader-hidden'); } if (!headerElement.classList.contains('hide')) { headerElement.classList.add('hide'); } if (!headerElement.classList.contains('script-osd-hidden')) { headerElement.classList.add('script-osd-hidden'); } } } function showOSDControls() { if (osdBottomElement) { if (osdBottomElement.classList.contains('videoOsdBottom-hidden')) { osdBottomElement.classList.remove('videoOsdBottom-hidden'); } if (osdBottomElement.classList.contains('hide')) { osdBottomElement.classList.remove('hide'); } if (osdBottomElement.classList.contains('script-osd-hidden')) { osdBottomElement.classList.remove('script-osd-hidden'); } } if (headerElement) { if (headerElement.classList.contains('osdHeader-hidden')) { headerElement.classList.remove('osdHeader-hidden'); } if (headerElement.classList.contains('hide')) { headerElement.classList.remove('hide'); } if (headerElement.classList.contains('script-osd-hidden')) { headerElement.classList.remove('script-osd-hidden'); } } } function goFast() { const vid = getVideo(); if (!vid || isFast) return; originalRate = vid.playbackRate || 1; const newRate = originalRate * 2; vid.playbackRate = newRate; isFast = true; showOverlay(newRate); } function restore() { const vid = getVideo(); if (!vid || !isFast) return; vid.playbackRate = originalRate; isFast = false; hideOverlay(); } function seekShort() { const vid = getVideo(); if (!vid) return; vid.currentTime = Math.min(vid.duration, vid.currentTime + SHORT_SEEK_S); } function findOSDElement() { const view = document.querySelector('div[data-type="video-osd"]') || document; osdBottomElement = view.querySelector('.videoOsdBottom-maincontrols'); headerElement = document.querySelector('.skinHeader'); } function removePlayControlClasses() { const playControlButtons = document.querySelector('.osdPlayControlButtons'); if (playControlButtons) { playControlButtons.classList.remove('flex', 'align-items-center', 'flex-direction-row', 'osdPlayControlButtons'); } } function togglePlay() { const vid = getVideo(); if (!vid) return; if (vid.paused) { vid.play(); findOSDElement(); hideOSDControls(); } else { vid.pause(); findOSDElement(); showOSDControls(); } } function toggleOSDVisibility() { findOSDElement(); if (osdBottomElement && osdBottomElement.classList.contains('script-osd-hidden')) { showOSDControls(); } else { hideOSDControls(); } } function isVideoOsdPageActive() { const videoOsdPage = document.getElementById('videoOsdPage'); if (!videoOsdPage) return false; const hasVideoOsdType = videoOsdPage.getAttribute('data-type') === 'video-osd'; const hasVideo = !!getVideo(); return hasVideoOsdType && hasVideo; } function isOSDControlElement(target) { if (osdBottomElement && (osdBottomElement.contains(target) || target === osdBottomElement)) return true; if (headerElement && (headerElement.contains(target) || target === headerElement)) return true; // 选集插件兼容性支持 let episodeSidebarElement = document.querySelector('.episodeSidebar'); if (episodeSidebarElement && (episodeSidebarElement.contains(target) || target === episodeSidebarElement)) return true; // 弹幕插件兼容性支持 let danmakuSidebarElement = document.querySelector('.danmakuSidebar'); if (danmakuSidebarElement && (danmakuSidebarElement.contains(target) || target === danmakuSidebarElement)) return true; let danmakuSelectDialogElement = document.querySelector('.selectDialog'); if (danmakuSelectDialogElement && (danmakuSelectDialogElement.contains(target) || target === danmakuSelectDialogElement)) return true; let danmakuInputDialogElement = document.querySelector('.inputDialog'); if (danmakuInputDialogElement && (danmakuInputDialogElement.contains(target) || target === danmakuInputDialogElement)) return true; const controlSelectors = ['.btnPause', '.btnPlay', '.btnStop', '.btnNext', '.btnPrevious', '.osdPositionSlider', '.mdl-slider', '.volumeSlider', '.btnVolume', '.btnMute', '.btnSubtitles', '.btnAudio', '.btnFullscreen', '.btnExitFullscreen', '.btnSettings', '.osdPlayControlButtons', '.videoOsdBottom-maincontrols', '#debugInfo', '.debug-close-btn', 'button', 'input[type="range"]', '.slider', '[role="button"]']; for (const selector of controlSelectors) { if (target.closest(selector)) return true; } return false; } function updateCustomProgressBar(currentTime, duration) { const customProgressBar = document.getElementById('custom-progress-bar'); if (customProgressBar && duration > 0) { const percentage = (currentTime / duration) * 100; customProgressBar.style.width = percentage + '%'; } } function showCustomProgressBar() { const customProgressBar = document.getElementById('custom-progress-bar'); if (customProgressBar) customProgressBar.style.display = 'block'; findOSDElement(); hideOSDControls(); } function hideCustomProgressBar() { const customProgressBar = document.getElementById('custom-progress-bar'); if (customProgressBar) customProgressBar.style.display = 'none'; } function init() { console.log('[Inject] init handlers'); const container = document.querySelector('#root') || document.body; injectStyles(); window.overlay = createOverlay(container); findOSDElement(); removePlayControlClasses(); const observer = new MutationObserver(() => { removePlayControlClasses(); }); observer.observe(document.body, { childList: true, subtree: true }); let lastTapTime = 0; const doubleTapDelay = 500; let startX = 0, startY = 0, startVideoTime = 0, startTarget = null, isLongPressing = false, isSliding = false, slideThreshold = 20, slideTimer = null, previewTime = 0; let keyActive = false; container.addEventListener('keydown', e => { if (e.code === 'ArrowRight') { if (!keyActive) { keyActive = true; pressTimer = setTimeout(goFast, LONG_PRESS_MS); } e.preventDefault(); e.stopImmediatePropagation(); } }, true); container.addEventListener('keyup', e => { if (e.code === 'ArrowRight') { clearTimeout(pressTimer); if (!isFast) seekShort(); else restore(); keyActive = false; e.preventDefault(); e.stopImmediatePropagation(); } }, true); container.addEventListener('touchstart', e => { if (!isVideoOsdPageActive()) return; const vid = getVideo(); if (vid && vid.src && vid.src !== lastVideoSrc) { lastVideoSrc = vid.src; trickplayData = null; setTimeout(setupTrickplay, 500); } startTarget = e.target; if (isOSDControlElement(startTarget)) return; if (e.touches.length === 1) { const touch = e.touches[0]; startX = touch.clientX; startY = touch.clientY; startVideoTime = vid ? vid.currentTime : 0; isLongPressing = false; isSliding = false; pressTimer = setTimeout(() => { findOSDElement(); goFast(); hideOSDControls(); isLongPressing = true; }, LONG_PRESS_MS); } }); container.addEventListener('touchmove', e => { if (!isVideoOsdPageActive() || (startTarget && isOSDControlElement(startTarget))) return; if (e.touches.length === 1 && !isLongPressing) { const touch = e.touches[0]; const deltaX = touch.clientX - startX; const deltaY = touch.clientY - startY; if (Math.abs(deltaX) > slideThreshold && Math.abs(deltaX) > Math.abs(deltaY)) { if (!isSliding) { clearTimeout(pressTimer); isSliding = true; showCustomProgressBar(); } findOSDElement(); hideOSDControls(); const vid = getVideo(); if (vid && vid.duration) { const timeChange = (deltaX / 100) * 10; previewTime = Math.max(0, Math.min(vid.duration, startVideoTime + timeChange)); showTimeOverlay(previewTime, vid.duration); updateCustomProgressBar(previewTime, vid.duration); updateTrickplay(previewTime); clearTimeout(slideTimer); } e.preventDefault(); } } }); container.addEventListener('touchend', (e) => { if (!isVideoOsdPageActive()) return; clearTimeout(pressTimer); if (isSliding) { hideTrickplay(); const vid = getVideo(); if (vid) vid.currentTime = previewTime; slideTimer = setTimeout(() => { hideTimeOverlay(); hideCustomProgressBar(); findOSDElement(); hideOSDControls(); }, 1000); isSliding = false; return; } if (isLongPressing) { restore(); findOSDElement(); hideOSDControls(); isLongPressing = false; return; } const currentTime = new Date().getTime(); const tapLength = currentTime - lastTapTime; if (tapLength < doubleTapDelay && tapLength > 0) { if (!isOSDControlElement(startTarget)) togglePlay(); e.preventDefault(); } else { if (!isFast) { if (!isOSDControlElement(startTarget)) toggleOSDVisibility(); } else { restore(); findOSDElement(); hideOSDControls(); } } lastTapTime = currentTime; }); container.addEventListener('touchcancel', () => { if (!isVideoOsdPageActive()) return; clearTimeout(pressTimer); clearTimeout(slideTimer); if (isLongPressing) { restore(); isLongPressing = false; } if (isSliding) { hideTimeOverlay(); hideCustomProgressBar(); hideTrickplay(); isSliding = false; } }); } if (document.readyState === 'complete' || document.readyState === 'interactive') { init(); } else { document.addEventListener('DOMContentLoaded', init); } })();