├── 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 |
9 |
10 |
11 |
12 |
13 | [](https://github.com/5f32797a/VimeoSPL)
14 | [](https://github.com/5f32797a/VimeoSPL/blob/main/LICENSE)
15 | [](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 |
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 |
Retry
546 |
`;
547 | console.error(e);
548 | }
549 | }
550 |
551 | init();
552 |
553 | })();
--------------------------------------------------------------------------------