├── LICENSE ├── README.md └── spl-vimeo-loader.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 5f32797a 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 | 5 |
6 | 7 | 8 | Typing SVG 9 | 10 | 11 |
12 | 13 | [![Version](https://img.shields.io/badge/Version-4.0-00d2ff?style=for-the-badge&logo=vimeo&logoColor=white)](https://github.com/5f32797a/VimeoSPL) 14 | [![License](https://img.shields.io/badge/License-MIT-0077B5?style=for-the-badge)](https://github.com/5f32797a/VimeoSPL/blob/main/LICENSE) 15 | [![Maintenance](https://img.shields.io/badge/Maintained-Yes-004E7A?style=for-the-badge)](https://github.com/5f32797a/VimeoSPL/commits/main) 16 | 17 |
18 | 19 | > **"Experience the stream as it was meant to be seen."** 20 | >
A sophisticated userscript that injects a high-performance HLS player into restricted Vimeo & Patreon embeds. 21 | 22 |
23 | 24 | --- 25 | 26 | ## 💠 Core Architecture 27 | 28 | VimeoSPL v4.0 is rebuilt from the ground up to prioritize **stability** and **aesthetics**. 29 | 30 | | Component | Status | Description | 31 | | :--- | :---: | :--- | 32 | | **Visual Core** | 🎨 | **Glassmorphism UI**: Blur backdrops (`backdrop-filter`), smooth fade transitions, and a deep blue dark mode. | 33 | | **Access Node** | 🔓 | **Header Injection**: Bypasses privacy settings by emulating valid referrer headers. | 34 | | **Download Engine** | 🚀 | **Separate Streams**: Downloads Raw Video (`.mp4`) and Audio (`.mp4`) independently for 100% success rate. | 35 | | **Input System** | ⌨️ | **Keyboard Driven**: Full hotkey support for power users (Seek, Volume, Fullscreen). | 36 | 37 | --- 38 | 39 | ## ⚡ Installation Protocol 40 | 41 | ### 1. Initialize Environment 42 | You need a userscript manager to inject the core. 43 | 44 | | Browser | Recommended Agent | 45 | | :--- | :--- | 46 | | **Chrome / Brave** | [Violentmonkey](https://chromewebstore.google.com/detail/violentmonkey/jinjaccalgkegednnccohejagnlnfdag) | 47 | | **Firefox** | [Violentmonkey](https://addons.mozilla.org/en-US/firefox/addon/violentmonkey/) | 48 | 49 | ### 2. Deploy Script 50 | Click the terminal button below to install directly. 51 | 52 |
53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 |

61 |
62 | 63 | --- 64 | 65 | ## 🎮 Interface & Controls 66 | 67 | The UI is designed to disappear when you don't need it and provide granular control when you do. 68 | 69 | | Command | Key / Action | 70 | | :--- | :--- | 71 | | **Playback** | Space or Click Center | 72 | | **Fullscreen** | F | 73 | | **Seek** | / (5s increments) | 74 | | **Volume** | / or Hover Slider | 75 | | **Download** | Click Icon in Bar | 76 | 77 | --- 78 | 79 | ## 📥 Stream Extraction Logic 80 | 81 | To ensure maximum quality and zero corruption, we do not mux in the browser. 82 | 83 | ```mermaid 84 | graph TD 85 | A[User Initiates Download] --> B{Select Target} 86 | B -->|High Quality| C[Video Stream .mp4] 87 | B -->|AAC Audio| D[Audio Stream .mp4] 88 | C --> E[Save to Disk] 89 | D --> E 90 | E --> F[Merge via VLC / FFmpeg] 91 | 92 | style A fill:#0077B5,color:white,stroke:#00d2ff 93 | style C fill:#004E7A,color:white,stroke:none 94 | style D fill:#004E7A,color:white,stroke:none 95 | style F fill:#00d2ff,color:black,stroke:none 96 | ``` 97 | 98 | > **Note:** Browser-based merging is unstable for large 4K files. Downloading streams separately guarantees you get the raw data directly from the CDN. 99 | 100 | --- 101 | 102 | ## 🛡️ Disclaimer 103 | 104 | > This tool is engineered for **educational purposes** and personal archiving of content you legally access. The code interacts with Vimeo's player API and HLS manifests. 105 | 106 |
107 | 108 | 109 |
110 | -------------------------------------------------------------------------------- /spl-vimeo-loader.js: -------------------------------------------------------------------------------- 1 | // ==UserScript== 2 | // @name Vimeo SPL: v4.1 (Modern UI) 3 | // @namespace https://github.com/5f32797a 4 | // @version 4.1 5 | // @description VimeoSPL: Replaces embedded player with a modern HLS interface, allowing separate high-quality video/audio downloads. 6 | // @match https://vimeo.com/* 7 | // @match https://player.vimeo.com/* 8 | // @grant GM_xmlhttpRequest 9 | // @require https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.5.17/hls.min.js 10 | // @run-at document-idle 11 | // @updateURL https://github.com/5f32797a/VimeoSPL/raw/main/spl-vimeo-loader.js 12 | // @downloadURL https://github.com/5f32797a/VimeoSPL/raw/main/spl-vimeo-loader.js 13 | // @license MIT 14 | // ==/UserScript== 15 | 16 | (function () { 17 | 'use strict'; 18 | 19 | /* ========================================================================== 20 | 1. ICONS & STYLES 21 | ========================================================================== */ 22 | const ICONS = { 23 | play: '', 24 | pause: '', 25 | volumeHigh: '', 26 | volumeMute: '', 27 | download: '', 28 | fullscreen: '', 29 | video: '', 30 | audio: '', 31 | spinner: '' 32 | }; 33 | 34 | const css = ` 35 | :root { --spl-primary: #00adef; --spl-bg: #121212; --spl-surface: #1e1e1e; --spl-text: #ffffff; } 36 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap'); 37 | 38 | .spl-wrap * { box-sizing: border-box; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; } 39 | .spl-wrap { position: fixed; inset: 0; background: #000; z-index: 99999; color: var(--spl-text); overflow: hidden; user-select: none; } 40 | 41 | /* Video Element */ 42 | .spl-video { width: 100%; height: 100%; object-fit: contain; outline: none; } 43 | 44 | /* Overlay & Center Controls */ 45 | .spl-layer { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: flex-end; background: linear-gradient(to top, rgba(0,0,0,0.8) 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0) 100%); transition: opacity 0.3s ease; pointer-events: none; } 46 | .spl-layer.fade-out { opacity: 0; } 47 | .spl-center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; pointer-events: auto; cursor: pointer; } 48 | .spl-big-play { width: 70px; height: 70px; background: rgba(0,0,0,0.6); border-radius: 50%; display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); opacity: 0; transform: scale(0.8); transition: 0.2s; pointer-events: none; } 49 | .spl-big-play svg { width: 36px; height: 36px; margin-left: 4px; } /* center play icon visually */ 50 | .spl-spinner { animation: spl-spin 1s linear infinite; display: none; } 51 | @keyframes spl-spin { 100% { transform: rotate(360deg); } } 52 | 53 | /* Controls Bar */ 54 | .spl-controls { padding: 0 24px 24px; pointer-events: auto; display: flex; flex-direction: column; gap: 12px; width: 100%; max-width: 1280px; margin: 0 auto; } 55 | 56 | /* Progress Bar */ 57 | .spl-prog-container { height: 14px; display: flex; align-items: center; cursor: pointer; position: relative; group: true; } 58 | .spl-prog-bg { width: 100%; height: 4px; background: rgba(255,255,255,0.2); border-radius: 2px; position: relative; transition: height 0.1s; overflow: hidden; } 59 | .spl-prog-container:hover .spl-prog-bg { height: 6px; } 60 | .spl-prog-buf { position: absolute; left: 0; top: 0; bottom: 0; background: rgba(255,255,255,0.3); width: 0; transition: width 0.2s; } 61 | .spl-prog-fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--spl-primary); width: 0; transition: width 0.1s linear; box-shadow: 0 0 10px rgba(0,173,239,0.5); } 62 | 63 | /* Bottom Bar Buttons */ 64 | .spl-bar { display: flex; justify-content: space-between; align-items: center; height: 40px; } 65 | .spl-grp { display: flex; align-items: center; gap: 10px; } 66 | 67 | .spl-btn { background: none; border: none; color: #eee; cursor: pointer; width: 36px; height: 36px; border-radius: 8px; display: flex; align-items: center; justify-content: center; transition: 0.2s; position: relative; } 68 | .spl-btn:hover { background: rgba(255,255,255,0.1); color: #fff; } 69 | .spl-btn svg { width: 22px; height: 22px; } 70 | 71 | /* Volume Slider */ 72 | .spl-vol-wrap { display: flex; align-items: center; width: 36px; transition: width 0.3s ease; overflow: hidden; background: rgba(255,255,255,0); border-radius: 20px; margin-right: 8px; } 73 | .spl-vol-wrap:hover, .spl-vol-wrap.active { width: 140px; background: rgba(255,255,255,0.1); } 74 | .spl-vol-slider { width: 0; opacity: 0; margin: 0; height: 4px; appearance: none; background: rgba(255,255,255,0.3); border-radius: 2px; outline: none; transition: 0.2s; cursor: pointer; margin-left: 8px; flex-grow: 1; margin-right: 12px; } 75 | .spl-vol-wrap:hover .spl-vol-slider, .spl-vol-wrap.active .spl-vol-slider { width: 80px; opacity: 1; } 76 | .spl-vol-slider::-webkit-slider-thumb { appearance: none; width: 12px; height: 12px; background: #fff; border-radius: 50%; cursor: pointer; } 77 | 78 | /* Time Display */ 79 | .spl-time { font-size: 13px; font-weight: 500; color: #ddd; margin-left: 4px; font-variant-numeric: tabular-nums; } 80 | 81 | /* Modal (Modern) */ 82 | .spl-modal-bg { position: fixed; inset: 0; background: rgba(0,0,0,0.8); z-index: 100000; backdrop-filter: blur(8px); display: none; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.2s; } 83 | .spl-modal-bg.open { opacity: 1; } 84 | .spl-modal { background: var(--spl-surface); width: 90%; max-width: 400px; border-radius: 16px; border: 1px solid rgba(255,255,255,0.08); box-shadow: 0 20px 40px rgba(0,0,0,0.6); display: flex; flex-direction: column; max-height: 80vh; transform: scale(0.95); transition: transform 0.2s; overflow: hidden; } 85 | .spl-modal-bg.open .spl-modal { transform: scale(1); } 86 | 87 | .spl-modal-header { padding: 18px 20px; border-bottom: 1px solid rgba(255,255,255,0.08); display: flex; justify-content: space-between; align-items: center; } 88 | .spl-modal-title { font-weight: 600; font-size: 16px; } 89 | .spl-close-btn { background: none; border: none; color: #888; cursor: pointer; font-size: 20px; line-height: 1; padding: 4px; } 90 | .spl-close-btn:hover { color: #fff; } 91 | 92 | .spl-list { overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px; } 93 | .spl-section-title { font-size: 11px; text-transform: uppercase; color: #666; font-weight: 700; letter-spacing: 0.5px; margin: 10px 10px 4px; } 94 | 95 | .spl-item { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-radius: 10px; cursor: pointer; transition: 0.2s; background: rgba(255,255,255,0.03); border: 1px solid transparent; } 96 | .spl-item:hover { background: rgba(255,255,255,0.08); border-color: rgba(255,255,255,0.1); transform: translateX(2px); } 97 | .spl-item-left { display: flex; align-items: center; gap: 12px; } 98 | .spl-res-badge { font-weight: 700; font-size: 13px; background: #333; padding: 2px 6px; border-radius: 4px; color: #eee; min-width: 50px; text-align: center; } 99 | .spl-item-info { display: flex; flex-direction: column; gap: 2px; } 100 | .spl-meta { font-size: 12px; color: #888; } 101 | 102 | /* Download Progress State */ 103 | .spl-dl-state { padding: 40px 30px; text-align: center; display: none; flex-direction: column; align-items: center; gap: 15px; } 104 | .spl-dl-bar-bg { width: 100%; height: 8px; background: #333; border-radius: 4px; overflow: hidden; margin-top: 10px; } 105 | .spl-dl-bar-fill { height: 100%; width: 0%; background: var(--spl-primary); transition: width 0.2s; border-radius: 4px; } 106 | .spl-status-text { font-size: 13px; color: #aaa; font-family: monospace; } 107 | 108 | /* Toast Notification */ 109 | .spl-toast { position: fixed; bottom: 30px; left: 50%; transform: translateX(-50%) translateY(20px); background: rgba(20,20,20,0.9); backdrop-filter: blur(5px); border: 1px solid rgba(255,255,255,0.1); padding: 10px 20px; border-radius: 30px; font-size: 13px; font-weight: 500; opacity: 0; transition: 0.3s; pointer-events: none; z-index: 100001; box-shadow: 0 10px 20px rgba(0,0,0,0.3); } 110 | .spl-toast.show { transform: translateX(-50%) translateY(0); opacity: 1; } 111 | `; 112 | document.head.appendChild(document.createElement('style')).textContent = css; 113 | 114 | /* ========================================================================== 115 | 2. HELPER FUNCTIONS 116 | ========================================================================== */ 117 | function formatTime(seconds) { 118 | if (isNaN(seconds)) return "0:00"; 119 | const m = Math.floor(seconds / 60); 120 | const s = Math.floor(seconds % 60); 121 | return `${m}:${s < 10 ? '0' : ''}${s}`; 122 | } 123 | 124 | function extractVimeoVideoId(path) { 125 | const match = path.match(/^\/(?:[a-zA-Z0-9]+\/)?(\d+)/); 126 | return match ? match[1] : null; 127 | } 128 | 129 | function extractVimeoConfig(html) { 130 | const start = html.indexOf('window.playerConfig = '); 131 | if (start === -1) throw new Error('playerConfig not found.'); 132 | const jsonStart = html.indexOf('{', start); 133 | let brace = 1, i = jsonStart + 1; 134 | for (; i < html.length && brace > 0; i++) { 135 | if (html[i] === '{') brace++; 136 | else if (html[i] === '}') brace--; 137 | } 138 | return JSON.parse(html.substring(jsonStart, i)); 139 | } 140 | 141 | /* ========================================================================== 142 | 3. DOWNLOAD LOGIC (Preserved) 143 | ========================================================================== */ 144 | const DownloadLogic = { 145 | fetch(url, type) { 146 | return new Promise((resolve, reject) => { 147 | GM_xmlhttpRequest({ 148 | method: 'GET', url, responseType: type, 149 | headers: { 'Referer': 'https://www.patreon.com' }, 150 | onload: r => (r.status >= 200 && r.status < 300) ? resolve(r.response) : reject(new Error(`HTTP ${r.status}`)), 151 | onerror: reject 152 | }); 153 | }); 154 | }, 155 | 156 | async downloadStream(streamUrl, onProgress) { 157 | const manifest = await this.fetch(streamUrl, 'text'); 158 | const baseUrl = streamUrl.substring(0, streamUrl.lastIndexOf('/') + 1); 159 | const segments = manifest.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.startsWith('http') ? l : baseUrl + l); 160 | 161 | if (!segments.length) throw new Error('No segments found in playlist.'); 162 | 163 | const chunks = []; 164 | for (let i = 0; i < segments.length; i++) { 165 | const chunk = await this.fetch(segments[i], 'arraybuffer'); 166 | chunks.push(chunk); 167 | onProgress((i + 1) / segments.length * 100, `Segment ${i + 1}/${segments.length}`); 168 | } 169 | 170 | return new Blob(chunks, { type: 'video/mp2t' }); 171 | }, 172 | 173 | saveBlob(blob, filename) { 174 | const a = document.createElement('a'); 175 | a.href = URL.createObjectURL(blob); 176 | a.download = filename; 177 | document.body.appendChild(a); 178 | a.click(); 179 | setTimeout(() => URL.revokeObjectURL(a.href), 5000); 180 | UI.toast(`Saved: ${filename}`); 181 | } 182 | }; 183 | 184 | /* ========================================================================== 185 | 4. UI COMPONENTS 186 | ========================================================================== */ 187 | const UI = { 188 | el(tag, cls, html) { 189 | const d = document.createElement(tag); 190 | if (cls) d.className = cls; 191 | if (html) d.innerHTML = html; 192 | return d; 193 | }, 194 | 195 | toast(msg) { 196 | if (!this.toastEl) { 197 | this.toastEl = this.el('div', 'spl-toast'); 198 | document.body.appendChild(this.toastEl); 199 | } 200 | this.toastEl.textContent = msg; 201 | this.toastEl.classList.add('show'); 202 | clearTimeout(this.toastTimer); 203 | this.toastTimer = setTimeout(() => this.toastEl.classList.remove('show'), 3000); 204 | }, 205 | 206 | createModal() { 207 | const overlay = this.el('div', 'spl-modal-bg'); 208 | const box = this.el('div', 'spl-modal'); 209 | 210 | const header = this.el('div', 'spl-modal-header'); 211 | header.innerHTML = `Download Media`; 212 | const closeBtn = this.el('button', 'spl-close-btn', '×'); 213 | closeBtn.onclick = () => this.closeModal(); 214 | header.appendChild(closeBtn); 215 | 216 | const list = this.el('div', 'spl-list'); 217 | 218 | const progView = this.el('div', 'spl-dl-state'); 219 | progView.innerHTML = ` 220 |
Processing Stream...
221 |
222 |
Initializing
223 | `; 224 | 225 | box.append(header, list, progView); 226 | overlay.appendChild(box); 227 | document.body.appendChild(overlay); 228 | 229 | overlay.onclick = (e) => { if(e.target === overlay) this.closeModal(); }; 230 | return { overlay, list, progView }; 231 | }, 232 | 233 | openDownloadMenu(title, hls) { 234 | if (!this.modal) this.modal = this.createModal(); 235 | this.resetModal(); 236 | this.modal.overlay.style.display = 'flex'; 237 | // Trigger reflow 238 | setTimeout(() => this.modal.overlay.classList.add('open'), 10); 239 | this.modal.list.innerHTML = ''; 240 | 241 | const videoStreams = hls.levels || []; 242 | const audioStreams = hls.audioTracks || []; 243 | 244 | // Sort highest quality first 245 | videoStreams.sort((a, b) => b.height - a.height); 246 | 247 | const addItem = (badge, mainText, subText, callback) => { 248 | const item = this.el('div', 'spl-item'); 249 | item.innerHTML = ` 250 |
251 | ${badge} 252 |
253 | ${mainText} 254 | ${subText} 255 |
256 |
257 |
${ICONS.download}
258 | `; 259 | item.onclick = callback; 260 | this.modal.list.appendChild(item); 261 | }; 262 | 263 | if (videoStreams.length > 0) { 264 | this.modal.list.appendChild(this.el('div', 'spl-section-title', 'Video Streams (No Audio)')); 265 | videoStreams.forEach(v => { 266 | addItem(`${v.height}p`, `Video Stream`, `High Bitrate • .mp4`, () => { 267 | this.startDownloadProcess(`${title}_${v.height}p_video.mp4`, (cb) => DownloadLogic.downloadStream(v.url[0], cb)); 268 | }); 269 | }); 270 | } 271 | 272 | if (audioStreams.length > 0) { 273 | this.modal.list.appendChild(this.el('div', 'spl-section-title', 'Audio Streams')); 274 | audioStreams.forEach(a => { 275 | addItem('AUDIO', a.name, 'AAC Audio • .mp4', () => { 276 | this.startDownloadProcess(`${title}_${a.name}_audio.mp4`, (cb) => DownloadLogic.downloadStream(a.url, cb)); 277 | }); 278 | }); 279 | } 280 | }, 281 | 282 | startDownloadProcess(filename, downloadFn) { 283 | this.modal.list.style.display = 'none'; 284 | this.modal.progView.style.display = 'flex'; 285 | const bar = this.modal.progView.querySelector('.spl-dl-bar-fill'); 286 | const txt = document.getElementById('spl-status-txt'); 287 | 288 | downloadFn((pct, statusText) => { 289 | bar.style.width = pct + '%'; 290 | txt.textContent = statusText || `${Math.round(pct)}%`; 291 | }).then((blob) => { 292 | DownloadLogic.saveBlob(blob, filename); 293 | this.closeModal(); 294 | }).catch(e => { 295 | alert('Error: ' + e.message); 296 | this.resetModal(); 297 | }); 298 | }, 299 | 300 | resetModal() { 301 | if (!this.modal) return; 302 | this.modal.list.style.display = 'flex'; 303 | this.modal.progView.style.display = 'none'; 304 | }, 305 | 306 | closeModal() { 307 | if (this.modal) { 308 | this.modal.overlay.classList.remove('open'); 309 | setTimeout(() => this.modal.overlay.style.display = 'none', 200); 310 | } 311 | this.resetModal(); 312 | } 313 | }; 314 | 315 | /* ========================================================================== 316 | 5. PLAYER BUILDER 317 | ========================================================================== */ 318 | function buildPlayer(hlsUrl, title) { 319 | document.body.innerHTML = ''; 320 | const wrap = UI.el('div', 'spl-wrap'); 321 | const video = UI.el('video', 'spl-video'); 322 | 323 | // --- Structure --- 324 | const centerOverlay = UI.el('div', 'spl-center-overlay'); 325 | const bigPlay = UI.el('div', 'spl-big-play', ICONS.play); 326 | const spinner = UI.el('div', 'spl-big-play spl-spinner', ICONS.spinner); 327 | centerOverlay.append(bigPlay, spinner); 328 | 329 | const uiLayer = UI.el('div', 'spl-layer'); 330 | const controls = UI.el('div', 'spl-controls'); 331 | 332 | // Progress 333 | const progCont = UI.el('div', 'spl-prog-container'); 334 | const progBg = UI.el('div', 'spl-prog-bg'); 335 | const progBuf = UI.el('div', 'spl-prog-buf'); 336 | const progFill = UI.el('div', 'spl-prog-fill'); 337 | progBg.append(progBuf, progFill); 338 | progCont.appendChild(progBg); 339 | 340 | // Buttons 341 | const bar = UI.el('div', 'spl-bar'); 342 | const left = UI.el('div', 'spl-grp'); 343 | const right = UI.el('div', 'spl-grp'); 344 | 345 | const btnPlay = UI.el('button', 'spl-btn', ICONS.play); 346 | 347 | // Volume Group 348 | const volWrap = UI.el('div', 'spl-vol-wrap'); 349 | const btnVol = UI.el('button', 'spl-btn', ICONS.volumeHigh); 350 | const volSlider = UI.el('input', 'spl-vol-slider'); 351 | volSlider.type = 'range'; volSlider.min = 0; volSlider.max = 1; volSlider.step = 0.05; volSlider.value = 1; 352 | volWrap.append(btnVol, volSlider); 353 | 354 | const timeDisp = UI.el('div', 'spl-time', '0:00 / 0:00'); 355 | 356 | const btnDL = UI.el('button', 'spl-btn', ICONS.download); 357 | btnDL.title = "Download Streams"; 358 | const btnFS = UI.el('button', 'spl-btn', ICONS.fullscreen); 359 | 360 | left.append(btnPlay, volWrap, timeDisp); 361 | right.append(btnDL, btnFS); 362 | bar.append(left, right); 363 | 364 | controls.append(progCont, bar); 365 | uiLayer.append(controls); 366 | wrap.append(video, centerOverlay, uiLayer); 367 | document.body.appendChild(wrap); 368 | 369 | // --- HLS Init --- 370 | let hls; 371 | if (Hls.isSupported()) { 372 | hls = new Hls({ enableWorker: true }); 373 | hls.loadSource(hlsUrl); 374 | hls.attachMedia(video); 375 | hls.on(Hls.Events.MANIFEST_PARSED, () => { 376 | // Auto play if preferred, otherwise wait 377 | video.play().catch(()=>{}); 378 | }); 379 | } else video.src = hlsUrl; 380 | 381 | // --- Logic --- 382 | 383 | const updatePlayState = () => { 384 | if (video.paused) { 385 | btnPlay.innerHTML = ICONS.play; 386 | bigPlay.innerHTML = ICONS.play; 387 | bigPlay.style.opacity = 1; 388 | bigPlay.style.transform = 'scale(1)'; 389 | } else { 390 | btnPlay.innerHTML = ICONS.pause; 391 | bigPlay.innerHTML = ICONS.pause; 392 | bigPlay.style.opacity = 0; 393 | bigPlay.style.transform = 'scale(1.5)'; 394 | } 395 | }; 396 | 397 | const togglePlay = () => { 398 | if(video.paused) video.play(); 399 | else video.pause(); 400 | }; 401 | 402 | btnPlay.onclick = togglePlay; 403 | centerOverlay.onclick = (e) => { 404 | if(e.target === centerOverlay || e.target === bigPlay) togglePlay(); 405 | }; 406 | 407 | video.onplay = updatePlayState; 408 | video.onpause = updatePlayState; 409 | video.onwaiting = () => { spinner.style.display = 'flex'; bigPlay.style.display = 'none'; }; 410 | video.onplaying = () => { spinner.style.display = 'none'; bigPlay.style.display = 'flex'; updatePlayState(); }; 411 | 412 | // Volume 413 | const updateVolume = () => { 414 | video.volume = volSlider.value; 415 | if(video.volume === 0) btnVol.innerHTML = ICONS.volumeMute; 416 | else btnVol.innerHTML = ICONS.volumeHigh; 417 | }; 418 | volSlider.oninput = updateVolume; 419 | btnVol.onclick = () => { 420 | if (video.volume > 0) { 421 | video.dataset.prevVol = video.volume; 422 | video.volume = 0; 423 | volSlider.value = 0; 424 | } else { 425 | video.volume = video.dataset.prevVol || 1; 426 | volSlider.value = video.volume; 427 | } 428 | updateVolume(); 429 | }; 430 | 431 | // Time & Progress 432 | video.ontimeupdate = () => { 433 | const pct = (video.currentTime / video.duration) * 100; 434 | progFill.style.width = pct + '%'; 435 | timeDisp.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`; 436 | 437 | // Buffer 438 | if (video.buffered.length) { 439 | const bufEnd = video.buffered.end(video.buffered.length - 1); 440 | const bufPct = (bufEnd / video.duration) * 100; 441 | progBuf.style.width = bufPct + '%'; 442 | } 443 | }; 444 | 445 | progCont.onclick = (e) => { 446 | const rect = progCont.getBoundingClientRect(); 447 | const pos = (e.clientX - rect.left) / rect.width; 448 | video.currentTime = pos * video.duration; 449 | }; 450 | 451 | // Fullscreen 452 | btnFS.onclick = () => { 453 | if(!document.fullscreenElement) wrap.requestFullscreen(); 454 | else document.exitFullscreen(); 455 | }; 456 | 457 | // Download 458 | btnDL.onclick = () => { 459 | video.pause(); 460 | if (!hls || !hls.levels) return alert("Stream data not ready."); 461 | UI.openDownloadMenu(title, hls); 462 | }; 463 | 464 | // UI Hiding 465 | let timer; 466 | const showUI = () => { 467 | uiLayer.classList.remove('fade-out'); 468 | wrap.style.cursor = 'default'; 469 | clearTimeout(timer); 470 | if(!video.paused) timer = setTimeout(() => { 471 | uiLayer.classList.add('fade-out'); 472 | wrap.style.cursor = 'none'; 473 | }, 2500); 474 | }; 475 | wrap.onmousemove = showUI; 476 | wrap.onclick = showUI; 477 | 478 | // Keyboard Shortcuts 479 | document.addEventListener('keydown', (e) => { 480 | // Prevent page scrolling 481 | if(["Space","ArrowUp","ArrowDown","ArrowLeft","ArrowRight"].indexOf(e.code) > -1) { 482 | e.preventDefault(); 483 | } 484 | 485 | switch(e.code) { 486 | case 'Space': togglePlay(); showUI(); break; 487 | case 'ArrowRight': video.currentTime += 5; showUI(); break; 488 | case 'ArrowLeft': video.currentTime -= 5; showUI(); break; 489 | case 'ArrowUp': 490 | video.volume = Math.min(1, video.volume + 0.1); 491 | volSlider.value = video.volume; 492 | updateVolume(); 493 | showUI(); 494 | break; 495 | case 'ArrowDown': 496 | video.volume = Math.max(0, video.volume - 0.1); 497 | volSlider.value = video.volume; 498 | updateVolume(); 499 | showUI(); 500 | break; 501 | case 'KeyF': btnFS.click(); break; 502 | } 503 | }); 504 | } 505 | 506 | /* ========================================================================== 507 | 6. BOOTSTRAP 508 | ========================================================================== */ 509 | async function init() { 510 | // Clean URL if it has a hash suffix (e.g. /12345/abcde -> /12345) 511 | const hashMatch = location.pathname.match(/^\/(\d+)\/([a-zA-Z0-9]+)$/); 512 | if (hashMatch) { 513 | const cleanPath = `/${hashMatch[1]}`; 514 | window.history.replaceState(null, '', cleanPath); 515 | } 516 | 517 | const videoId = hashMatch ? hashMatch[1] : extractVimeoVideoId(location.pathname); 518 | if (!videoId) return; 519 | 520 | // Nice Loading Screen 521 | document.documentElement.style.background = '#121212'; 522 | document.body.innerHTML = ` 523 |
524 |
525 |
Loading Stream...
526 |
527 | 528 | `; 529 | 530 | try { 531 | const playerPageHtml = await DownloadLogic.fetch(`https://player.vimeo.com/video/${videoId}`, 'text'); 532 | const cfg = extractVimeoConfig(playerPageHtml); 533 | if (!cfg) throw new Error('Could not parse Vimeo config.'); 534 | 535 | const hlsConfig = cfg.request.files.hls; 536 | const masterUrl = hlsConfig.cdns[hlsConfig.default_cdn].url; 537 | const title = (cfg.video.title || `vimeo-${videoId}`).replace(/[^a-z0-9]/gi, '_'); 538 | 539 | buildPlayer(masterUrl, title); 540 | 541 | } catch (e) { 542 | document.body.innerHTML = `
543 |

Unable to Load Video

544 |

${e.message}

545 | 546 |
`; 547 | console.error(e); 548 | } 549 | } 550 | 551 | init(); 552 | 553 | })(); --------------------------------------------------------------------------------