0 Votes

Wiki source code of Uncategorized Videos

Version 500.1 by Ryan C on 2025/09/10 05:01

Show last authors
1 {{velocity}}
2
3 #set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
4 #set($videos = [])
5
6 #foreach($att in $doc.getAttachmentList())
7 #set($n = $att.getFilename())
8 #set($ln = $n.toLowerCase())
9 #foreach($e in $videoExts)
10 #if($ln.endsWith("." + $e))
11 #set($discard = $videos.add($att))
12 #break
13 #end
14 #end
15 #end
16
17 {{cache id="vid-list-$doc.fullName-$doc.version" timeToLive="21600"}}
18
19 {{html wiki="false" clean="false"}}
20
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)}; // current wiki id (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
42 #foreach($att in $videos)
43 #set($i = $i + 1)
44 #set($filename = $att.getFilename())
45 #set($lname = $filename.toLowerCase())
46 #set($url = $doc.getAttachmentURL($filename))
47
48 ## MIME type detection
49 #set($videoType = "video/mp4")
50 #if($lname.endsWith(".webm"))
51 #set($videoType = "video/webm")
52 #elseif($lname.endsWith(".ogg"))
53 #set($videoType = "video/ogg")
54 #elseif($lname.endsWith(".avi"))
55 #set($videoType = "video/x-msvideo")
56 #elseif($lname.endsWith(".mov"))
57 #set($videoType = "video/quicktime")
58 #elseif($lname.endsWith(".wmv"))
59 #set($videoType = "video/x-ms-wmv")
60 #elseif($lname.endsWith(".flv"))
61 #set($videoType = "video/x-flv")
62 #elseif($lname.endsWith(".m4v"))
63 #set($videoType = "video/mp4")
64 #end
65
66 #if($i == 1 || ($i - 1) % 48 == 0)
67 #set($chunkIndex = $chunkIndex + 1)
68 <div class="vid-chunk" data-chunk="$chunkIndex" style="display: #if($chunkIndex == 1) block #else none #end;">
69 <div class="video-display-grid" 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 #if($xcontext.action == 'edit')
76 <input type="checkbox" class="video-selector" data-video="${escapetool.xml($filename)}" title="Select for bulk actions">
77 #end
78 </div>
79
80 <!-- Video placeholder with auto-generated poster -->
81 <div class="video-frame" data-src="${url}" data-type="${videoType}" data-name="${escapetool.xml($filename)}"
82 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;">
83 <canvas class="vid-canvas" width="320" height="180" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></canvas>
84 <button class="btn btn-sm btn-primary" type="button" style="position:relative;z-index:1;">Load &amp; Play</button>
85 </div>
86
87 <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;">
88 <a href="${url}" download="${escapetool.xml($filename)}" class="btn btn-sm btn-success">📥 Download</a>
89 <span class="vid-duration" style="font-size:12px;color:#666;">Duration: —</span>
90 </div>
91
92 <!-- Move-to-page functionality -->
93 <div class="move-box" style="margin-top:10px;">
94 <label style="font-size:12px;color:#555;">Move to page:</label>
95 <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
96 <input type="text" class="move-input" placeholder="Type page name (e.g., Main.MyPage)"
97 data-filename="${escapetool.xml($filename)}" style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;">
98 <div class="move-results" style="position:relative;min-width:220px;max-width:420px;"></div>
99 </div>
100 <small style="color:#888;">Search is wiki-wide; click a result to move this file.</small>
101 </div>
102 </div>
103
104 #if(($i % 48 == 0) || $foreach.last)
105 </div>
106
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
113 </div>
114 #end
115 #end
116 </div>
117 #end
118 </div>
119
120 <script>
121 (function(){
122 // ---- Helper: build REST path for nested spaces
123 function spacesPath(dotPath){
124 if(!dotPath) return '';
125 return dotPath.split('.').map(function(s){
126 return 'spaces/' + encodeURIComponent(s);
127 }).join('/');
128 }
129
130 // ---- On-demand poster: draw first frame into the placeholder canvas
131 async function makePoster(frame){
132 if(frame.getAttribute('data-poster-ready')==='1') return;
133
134 const src = frame.getAttribute('data-src');
135 const type = frame.getAttribute('data-type') || 'video/mp4';
136
137 try{
138 const v = document.createElement('video');
139 v.preload = 'metadata';
140 v.muted = true;
141 v.playsInline = true;
142 v.src = src;
143
144 await new Promise((res, rej)=>{
145 let done=false;
146 function finish(){
147 if(done) return;
148 done=true;
149 res();
150 }
151 v.addEventListener('loadeddata', finish, {once:true});
152 v.addEventListener('loadedmetadata', ()=>{
153 try {
154 v.currentTime = 0.04;
155 } catch(e){}
156 });
157 v.addEventListener('error', ()=>rej(new Error('metadata error')));
158 // Safety timeout
159 setTimeout(finish, 2500);
160 });
161
162 const canvas = frame.querySelector('.vid-canvas');
163 if(canvas){
164 const w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16));
165 canvas.width = 320;
166 canvas.height = h>0?h:180;
167 const ctx = canvas.getContext('2d');
168 if(ctx){
169 ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
170 }
171 }
172 frame.setAttribute('data-poster-ready','1');
173 }catch(e){
174 // Ignore; fallback stays as dark box
175 }
176 }
177
178 // ---- Mount real <video> on click / near viewport (preload="none")
179 function mountVideo(frame){
180 if(frame.getAttribute('data-mounted')==='1') return;
181
182 const src = frame.getAttribute('data-src');
183 const type = frame.getAttribute('data-type') || 'video/mp4';
184 const v = document.createElement('video');
185 v.setAttribute('controls','');
186 v.setAttribute('preload','none');
187 v.style.width='100%';
188 v.style.maxWidth='100%';
189 v.style.borderRadius='4px';
190
191 const s = document.createElement('source');
192 s.src=src;
193 s.type=type;
194 v.appendChild(s);
195
196 v.addEventListener('loadedmetadata', function(){
197 const d = Math.round(v.duration||0),
198 mm = Math.floor(d/60),
199 ss = String(d%60).padStart(2,'0');
200 const dur = frame.parentElement.querySelector('.vid-duration');
201 if(dur) dur.textContent = 'Duration: '+mm+':'+ss;
202 });
203
204 frame.replaceChildren(v);
205 frame.setAttribute('data-mounted','1');
206 }
207
208 // Observe frames for posters + optional pre-mount
209 if('IntersectionObserver' in window){
210 const io = new IntersectionObserver((entries)=>{
211 entries.forEach(e=>{
212 if(e.isIntersecting){
213 makePoster(e.target); // generate preview only
214 io.unobserve(e.target);
215 }
216 });
217 }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') });
218
219 document.querySelectorAll('.video-frame').forEach(el=>io.observe(el));
220 }
221
222 // Click to load & play
223 document.addEventListener('click', function(ev){
224 const frame = ev.target.closest('.video-frame');
225 if(frame){
226 mountVideo(frame);
227 const v = frame.querySelector('video');
228 if(v) v.play().catch(()=>{});
229 }
230 });
231
232 // Chunk reveal
233 document.addEventListener('click', function(ev){
234 const b = ev.target.closest('.load-more');
235 if(!b) return;
236 const next = b.getAttribute('data-next');
237 const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
238 if(nxt){
239 nxt.style.display = 'block';
240 b.parentElement.style.display = 'none';
241 }
242 });
243
244 // ---- Move to page: search & move
245 const wiki = window.XWIKI_WIKI;
246
247 async function searchPages(q){
248 const url = '/rest/wikis/' + encodeURIComponent(wiki) + '/search?q=' + encodeURIComponent(q) + '&scope=title,name&number=8&media=json';
249 // Title/name search is backed by Solr in recent XWiki; last token supports wildcard.
250 const r = await fetch(url, {credentials:'same-origin'});
251 if(!r.ok) return [];
252 const json = await r.json();
253 const items = (json.searchResults && json.searchResults.searchResult) || [];
254 // Return list of {fullName, title}
255 return items.map(it => ({
256 fullName: (it.pageFullName || it.fullName || '').replace(/^.*:/,''),
257 title: it.title || it.pageTitle || it.highlight || it.fullName
258 })).filter(it=>it.fullName);
259 }
260
261 function renderResults(box, results, onPick){
262 function esc(s){
263 return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;');
264 }
265
266 var wrap = document.createElement('div');
267 wrap.className = 'move-suggest';
268 wrap.style.position='absolute';
269 wrap.style.zIndex='1000';
270 wrap.style.top='0';
271 wrap.style.left='0';
272 wrap.style.right='0';
273 wrap.style.background='#fff';
274 wrap.style.border='1px solid #ddd';
275 wrap.style.borderRadius='4px';
276 wrap.style.boxShadow='0 6px 20px rgba(0,0,0,.08)';
277 wrap.style.maxHeight='240px';
278 wrap.style.overflow='auto';
279
280 var html = '';
281 if (results && results.length){
282 for (var i=0;i<results.length;i++){
283 var r = results[i];
284 var title = r.title ? esc(r.title) : '(untitled)';
285 var full = esc(r.fullName || '');
286 html += '<div class="move-item" data-full="' + full + '" ' +
287 'style="padding:6px 10px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
288 '<strong>' + title + '</strong>' +
289 '<span style="color:#777;margin-left:6px;">' + full + '</span>' +
290 '</div>';
291 }
292 } else {
293 html = '<div style="padding:8px 10px;color:#777;">No matches</div>';
294 }
295 wrap.innerHTML = html;
296 box.textContent = '';
297 box.appendChild(wrap);
298
299 box.onpointerdown = function(e){
300 var it = e.target.closest('.move-item');
301 if(!it) return;
302 var full = it.getAttribute('data-full');
303 onPick(full);
304 box.textContent = '';
305 };
306 }
307
308 function parseFullName(full){
309 // "Main.MyPage" or "Main.Sub.MyPage"
310 const parts = full.split('.');
311 const page = parts.pop();
312 const spacePath = parts.join('.');
313 return {spacePath, page};
314 }
315
316 async function moveAttachment(opts){
317 var srcSpace = opts.srcSpace;
318 var srcPage = opts.srcPage;
319 var filename = opts.filename;
320 var dstFull = opts.dstFull;
321
322 const pf = parseFullName(dstFull);
323 const dstSpace = pf.spacePath;
324 const dstPage = pf.page;
325
326 const srcSpacesPath = spacesPath(srcSpace);
327 const dstSpacesPath = spacesPath(dstSpace);
328
329 // 1) GET the file as blob from the current attachment URL we already have in the card
330 // 2) PUT to target page's attachments
331 // 3) DELETE original
332
333 var cardInputSel = '.video-container input.move-input[data-filename="' + CSS.escape(filename) + '"]';
334 var inputEl = document.querySelector(cardInputSel);
335 var card = inputEl ? inputEl.closest('.video-container') : null;
336 var frame = card ? card.querySelector('.video-frame') : null;
337 const srcURL = frame ? frame.getAttribute('data-src') : null;
338 if(!srcURL) throw new Error('Missing source URL');
339
340 const downloading = await fetch(srcURL, {credentials:'same-origin'});
341 if(!downloading.ok) throw new Error('Download failed: '+downloading.status);
342 const blob = await downloading.blob();
343
344 const putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath + '/pages/' + encodeURIComponent(dstPage) + '/attachments/' + encodeURIComponent(filename) + '?media=json';
345 const uploading = await fetch(putURL, {
346 method: 'PUT',
347 body: blob,
348 headers: {'Content-Type':'application/octet-stream'},
349 credentials:'same-origin'
350 });
351
352 if(!(uploading.status===201 || uploading.status===202)){
353 const txt = await uploading.text().catch(()=>String(uploading.status));
354 throw new Error('Upload failed: '+txt);
355 }
356
357 const delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath + '/pages/' + encodeURIComponent(srcPage) + '/attachments/' + encodeURIComponent(filename);
358 const deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'});
359
360 if(!(deleting.status===204)){
361 // Not fatal: the file exists at destination; warn but keep going.
362 console.warn('Delete original failed', deleting.status);
363 }
364
365 return true;
366 }
367
368 // Wire up per-card search boxes
369 let timer=null;
370 document.querySelectorAll('.move-box .move-input').forEach(inp=>{
371 const resultsBox = inp.parentElement.querySelector('.move-results');
372
373 inp.addEventListener('input', ()=>{
374 clearTimeout(timer);
375 const q = inp.value.trim();
376 if(!q){
377 resultsBox.textContent='';
378 return;
379 }
380 timer = setTimeout(async ()=>{
381 const res = await searchPages(q);
382 renderResults(resultsBox, res, async (full)=>{
383 inp.value = full; // Fill the input
384
385 // Kick the move
386 const filename = inp.getAttribute('data-filename');
387 const notice = document.createElement('div');
388 notice.style.fontSize='12px';
389 notice.style.color='#666';
390 notice.textContent='Moving…';
391 inp.parentElement.appendChild(notice);
392
393 try{
394 await moveAttachment({
395 srcSpace: window.SOURCE_SPACE,
396 srcPage: window.SOURCE_PAGE,
397 filename: filename,
398 dstFull: full
399 });
400 notice.textContent = 'Moved ✔ — reloading…';
401 setTimeout(()=>location.reload(), 600);
402 }catch(e){
403 notice.style.color = '#b00020';
404 notice.textContent = 'Move failed: ' + (e && e.message ? e.message : e);
405 }
406 });
407 }, 220);
408 });
409
410 // Close suggestions when clicking out
411 document.addEventListener('click', (e)=>{
412 if(!inp.parentElement.contains(e.target)) resultsBox.textContent='';
413 });
414 });
415
416 })();
417 </script>
418
419 <style>
420 .video-container:hover{
421 box-shadow:0 4px 8px rgba(0,0,0,0.08);
422 transition:box-shadow .25s;
423 }
424
425 .btn{
426 padding:4px 8px;
427 border:1px solid #ddd;
428 background:#f8f9fa;
429 border-radius:4px;
430 cursor:pointer;
431 text-decoration:none;
432 display:inline-block;
433 }
434
435 .btn:hover{
436 background:#e9ecef;
437 }
438
439 .btn-primary{
440 background:#007bff;
441 color:#fff;
442 border-color:#007bff;
443 }
444
445 .btn-secondary{
446 background:#6c757d;
447 color:#fff;
448 border-color:#6c757d;
449 }
450
451 .btn-success{
452 background:#28a745;
453 color:#fff;
454 border-color:#28a745;
455 }
456
457 .btn-sm{
458 font-size:12px;
459 padding:2px 6px;
460 }
461
462 @media (max-width:768px){
463 .video-display-grid{
464 grid-template-columns:1fr;
465 }
466 }
467
468 .move-suggest::-webkit-scrollbar{
469 width:10px;
470 height:10px;
471 }
472
473 .move-suggest::-webkit-scrollbar-thumb{
474 background:#ccc;
475 border-radius:6px;
476 }
477 </style>
478
479 {{/html}}
480
481 {{/cache}}
482
483 {{/velocity}}