0 Votes

Wiki source code of Uncategorized Videos

Version 496.1 by Ryan C on 2025/09/10 04:40

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