0 Votes

Wiki source code of Uncategorized Videos

Version 503.1 by Ryan C on 2025/09/10 06:18

Show last authors
1 {{velocity}}
2 #pagePicker_import ## load the native Page Picker resources once
3
4 ## 1) Collect video attachments from the current page
5 #set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
6 #set($videos = [])
7 #foreach($att in $doc.getAttachmentList())
8 #set($n = $att.getFilename())
9 #set($ln = $n.toLowerCase())
10 #foreach($e in $videoExts)
11 #if($ln.endsWith("." + $e))
12 #set($discard = $videos.add($att))
13 #break
14 #end
15 #end
16 #end
17
18 ## 2) Cache the rendered HTML (auto-bust on each save via version)
19 {{cache id="vid-list-$doc.fullName-$doc.version" timeToLive="21600"}}
20 {{html wiki="false" clean="false"}}
21 <div id="xwiki-video-manager" style="margin:20px 0;">
22 <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2>
23
24 #if($videos.size() == 0)
25 <div style="text-align:center;padding:40px;background:#f8f9fa;border-radius:8px;">
26 <h3>No Videos Found</h3>
27 <p>Attach video files to this page to see them here.</p>
28 </div>
29 #else
30 <script>
31 window.VID_CHUNK_SIZE = 48;
32 window.VID_LAZY_MARGIN = '600px';
33 window.XWIKI_WIKI = ${ jsontool.serialize($xcontext.database) }; // e.g., "xwiki"
34 window.SOURCE_SPACE = ${ jsontool.serialize($doc.space) }; // e.g., "Main" or "Main.Sub"
35 window.SOURCE_PAGE = ${ jsontool.serialize($doc.name) }; // e.g., "WebHome"
36 </script>
37
38 <div id="video-chunks">
39 #set($i = 0)
40 #set($chunkIndex = 0)
41 #foreach($att in $videos)
42 #set($i = $i + 1)
43 #set($filename = $att.getFilename())
44 #set($lname = $filename.toLowerCase())
45 #set($url = $doc.getAttachmentURL($filename))
46
47 ## MIME guess
48 #set($videoType = "video/mp4")
49 #if($lname.endsWith(".webm"))
50 #set($videoType = "video/webm")
51 #elseif($lname.endsWith(".ogg"))
52 #set($videoType = "video/ogg")
53 #elseif($lname.endsWith(".avi"))
54 #set($videoType = "video/x-msvideo")
55 #elseif($lname.endsWith(".mov"))
56 #set($videoType = "video/quicktime")
57 #elseif($lname.endsWith(".wmv"))
58 #set($videoType = "video/x-ms-wmv")
59 #elseif($lname.endsWith(".flv"))
60 #set($videoType = "video/x-flv")
61 #elseif($lname.endsWith(".m4v"))
62 #set($videoType = "video/mp4")
63 #end
64
65 #if($i == 1 || ($i - 1) % 48 == 0)
66 #set($chunkIndex = $chunkIndex + 1)
67 <div class="vid-chunk" data-chunk="$chunkIndex" style="display: #if($chunkIndex == 1) block #else none #end;">
68 <div class="video-display-grid"
69 style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;">
70 #end
71
72 <div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:12px;background:#fff;">
73 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
74 <h4 style="margin:0;flex:1;min-width:0;">${escapetool.xml($filename)}</h4>
75 </div>
76
77 <!-- Placeholder (poster drawn on-demand) -->
78 <div class="video-frame" data-src="${url}" data-type="${videoType}" data-name="${escapetool.xml($filename)}"
79 style="position:relative;width:100%;aspect-ratio:16/9;background:#111;border-radius:4px;overflow:hidden;display:flex;align-items:center;justify-content:center;cursor:pointer;">
80 <canvas class="vid-canvas" width="320" height="180"
81 style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></canvas>
82 <button class="btn btn-sm btn-primary" type="button" style="position:relative;z-index:1;">Load &amp;
83 Play</button>
84 </div>
85
86 <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;">
87 <a href="${url}" download="${escapetool.xml($filename)}" class="btn btn-sm btn-success">📥 Download</a>
88 <span class="vid-duration" style="font-size:12px;color:#666;">Duration: —</span>
89 </div>
90
91 <!-- Move-to-page (native Page Picker) -->
92 <div class="move-box" style="margin-top:10px;">
93 <label style="font-size:12px;color:#555;">Move to page:</label>
94 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
95 <input type="text" class="move-input suggest-pages" placeholder="Find a page…"
96 data-search-scope="wiki:${escapetool.xml($xcontext.database)}"
97 data-filename="${escapetool.xml($filename)}"
98 style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;">
99 </div>
100 <small style="color:#888;">Pick a page (e.g., <code>Main.SomePage</code> or a titled page). Selecting it
101 moves this file.</small>
102 </div>
103 </div>
104
105 #if(($i % 48 == 0) || $foreach.last)
106 </div>
107 #if(!$foreach.last)
108 <div style="text-align:center;margin:12px 0 28px;">
109 <button class="btn btn-secondary load-more" data-next="$mathtool.add($chunkIndex,1)">Load more</button>
110 </div>
111 #end
112 </div>
113 #end
114 #end
115 </div>
116 #end
117 </div>
118
119 <script>
120 /* replace your makePoster with this */
121 async function makePoster(frame) {
122 if (frame.getAttribute('data-poster-ready') === '1') return;
123 var src = frame.getAttribute('data-src');
124 try {
125 var v = document.createElement('video');
126 v.preload = 'metadata';
127 v.muted = true; // allow autoplay on some browsers if needed
128 v.playsInline = true;
129 v.crossOrigin = 'anonymous'; // harmless on same-origin; avoids taint if proxied
130
131 // Promise that resolves after we actually have a decodable frame
132 function once(target, evt) { return new Promise(function (res) { target.addEventListener(evt, res, { once: true }); }); }
133 v.src = src;
134
135 // Wait for dimensions
136 await once(v, 'loadedmetadata');
137
138 // Prefer rVFC when available (more reliable than seek for some codecs)
139 if (typeof v.requestVideoFrameCallback === 'function') {
140 await new Promise(function (res) { v.requestVideoFrameCallback(function () { res(); }); });
141 } else {
142 // Seek a bit into the file so we land on a keyframe
143 try { v.currentTime = Math.min(0.25, (v.seekable && v.seekable.length ? v.seekable.start(0) + 0.25 : 0.25)); } catch (e) { }
144 await once(v, 'seeked');
145 // Some browsers still need data decoded:
146 try { await once(v, 'loadeddata'); } catch (e) { }
147 }
148
149 var canvas = frame.querySelector('.vid-canvas');
150 if (canvas) {
151 var w = 320, h = Math.round(320 * (v.videoHeight || 9) / (v.videoWidth || 16));
152 canvas.width = 320; canvas.height = h > 0 ? h : 180;
153 var ctx = canvas.getContext('2d', { willReadFrequently: true });
154 ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
155
156 // cache the poster for when we mount the real <video>
157 try {
158 var dataURL = canvas.toDataURL('image/webp', 0.85);
159 frame.setAttribute('data-poster', dataURL);
160 } catch (e) { /* ignore */ }
161 }
162 frame.setAttribute('data-poster-ready', '1');
163 } catch (e) {
164 // keep dark fallback
165 }
166 }
167
168 /* replace your mountVideo with this */
169 function mountVideo(frame) {
170 if (frame.getAttribute('data-mounted') === '1') return;
171 var src = frame.getAttribute('data-src');
172 var type = frame.getAttribute('data-type') || 'video/mp4';
173 var poster = frame.getAttribute('data-poster'); // from makePoster()
174
175 var v = document.createElement('video');
176 v.setAttribute('controls', '');
177 v.setAttribute('preload', 'none'); // keep perf
178 if (poster) v.setAttribute('poster', poster); // show the captured image
179 v.style.width = '100%'; v.style.maxWidth = '100%'; v.style.borderRadius = '4px';
180
181 var s = document.createElement('source'); s.src = src; s.type = type; v.appendChild(s);
182
183 v.addEventListener('loadedmetadata', function () {
184 var d = Math.round(v.duration || 0), mm = Math.floor(d / 60), ss = String(d % 60).padStart(2, '0');
185 var dur = frame.parentElement.querySelector('.vid-duration');
186 if (dur) dur.textContent = 'Duration: ' + mm + ':' + ss;
187 });
188
189 frame.replaceChildren(v);
190 frame.setAttribute('data-mounted', '1');
191 }
192 </script>
193
194 // ---- Lazy poster
195 if('IntersectionObserver' in window){
196 var io = new IntersectionObserver(function(entries){
197 entries.forEach(function(e){
198 if(e.isIntersecting){ makePoster(e.target); io.unobserve(e.target); }
199 });
200 }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') });
201 document.querySelectorAll('.video-frame').forEach(function(el){ io.observe(el); });
202 }
203
204 // ---- Click to load & play
205 document.addEventListener('click', function(ev){
206 var frame = ev.target.closest('.video-frame');
207 if(frame){
208 mountVideo(frame);
209 var v = frame.querySelector('video'); if(v) v.play().catch(function(){});
210 }
211 });
212
213 // ---- Chunk reveal
214 document.addEventListener('click', function(ev){
215 var b = ev.target.closest('.load-more'); if(!b) return;
216 var next = b.getAttribute('data-next');
217 var nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
218 if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; }
219 });
220
221 // ---- Move: when a page is picked in the native Page Picker (fires change)
222 var wiki = window.XWIKI_WIKI;
223 function moveAttachment(opts){
224 return (async function(){
225 var srcSpace = opts.srcSpace, srcPage = opts.srcPage, filename = opts.filename, dstFull = opts.dstFull;
226 var pf = (function(full){ full = String(full||'').replace(/^[^:]+:/,''); var p=full.split('.'); var page=p.pop(); return
227 {spacePath:p.join('.'), page:page}; })(dstFull);
228 var srcSpacesPath = spacesPath(srcSpace), dstSpacesPath = spacesPath(pf.spacePath);
229
230 // Download -> Upload -> Delete original
231 var cardInputSel = '.video-container input.move-input[data-filename="' + CSS.escape(filename) + '"]';
232 var inputEl = document.querySelector(cardInputSel);
233 var card = inputEl ? inputEl.closest('.video-container') : null;
234 var frame = card ? card.querySelector('.video-frame') : null;
235 var srcURL = frame ? frame.getAttribute('data-src') : null;
236 if(!srcURL) throw new Error('Missing source URL');
237
238 var downloading = await fetch(srcURL, {credentials:'same-origin'});
239 if(!downloading.ok) throw new Error('Download failed: ' + downloading.status);
240 var blob = await downloading.blob();
241
242 var putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath +
243 '/pages/' + encodeURIComponent(pf.page) +
244 '/attachments/' + encodeURIComponent(filename) + '?media=json';
245 var uploading = await fetch(putURL, {
246 method: 'PUT',
247 body: blob,
248 headers: {'Content-Type':'application/octet-stream'},
249 credentials:'same-origin'
250 });
251 if(!(uploading.status===201 || uploading.status===202)){
252 var txt = await uploading.text().catch(function(){ return String(uploading.status); });
253 throw new Error('Upload failed: ' + txt);
254 }
255
256 var delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath +
257 '/pages/' + encodeURIComponent(srcPage) +
258 '/attachments/' + encodeURIComponent(filename);
259 var deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'});
260 if(!(deleting.status===204)){ console.warn('Delete original failed', deleting.status); }
261 return true;
262 })();
263 }
264
265 document.addEventListener('change', function(e){
266 var inp = e.target.closest('.move-input.suggest-pages'); if(!inp) return;
267 var selected = (inp.value || '').trim(); if(!selected) return;
268
269 var filename = inp.getAttribute('data-filename');
270 var notice = document.createElement('div');
271 notice.style.fontSize='12px'; notice.style.color='#666'; notice.textContent='Moving…';
272 inp.parentElement.appendChild(notice);
273
274 moveAttachment({
275 srcSpace: window.SOURCE_SPACE,
276 srcPage: window.SOURCE_PAGE,
277 filename: filename,
278 dstFull: selected
279 }).then(function(){
280 notice.textContent = 'Moved ✔ — reloading…';
281 setTimeout(function(){ location.reload(); }, 600);
282 }).catch(function(err){
283 notice.style.color = '#b00020';
284 notice.textContent = 'Move failed: ' + (err && err.message ? err.message : err);
285 });
286 });
287 })();
288 </script>
289
290 <style>
291 .video-container:hover {
292 box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
293 transition: box-shadow .25s;
294 }
295
296 .btn {
297 padding: 4px 8px;
298 border: 1px solid #ddd;
299 background: #f8f9fa;
300 border-radius: 4px;
301 cursor: pointer;
302 text-decoration: none;
303 display: inline-block;
304 }
305
306 .btn:hover {
307 background: #e9ecef;
308 }
309
310 .btn-primary {
311 background: #007bff;
312 color: #fff;
313 border-color: #007bff;
314 }
315
316 .btn-secondary {
317 background: #6c757d;
318 color: #fff;
319 border-color: #6c757d;
320 }
321
322 .btn-success {
323 background: #28a745;
324 color: #fff;
325 border-color: #28a745;
326 }
327
328 .btn-sm {
329 font-size: 12px;
330 padding: 2px 6px;
331 }
332
333 @media (max-width:768px) {
334 .video-display-grid {
335 grid-template-columns: 1fr
336 }
337 }
338 </style>
339 {{/html}}
340 {{/cache}}
341 {{/velocity}}