0 Votes

Changes for page Uncategorized Videos

Last modified by Ryan C on 2025/09/10 07:29

From version 499.1
edited by Ryan C
on 2025/09/10 04:47
Change comment: Rollback to version 495.1
To version 499.2
edited by Ryan C
on 2025/09/10 04:54
Change comment: There is no comment for this version

Summary

Details

Page properties
Content
... ... @@ -1,6 +1,8 @@
1 1  {{velocity}}
2 -#set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
3 -#set($videos = [])
2 +
3 +#set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
4 +#set($videos = [])
5 +
4 4  #foreach($att in $doc.getAttachmentList())
5 5   #set($n = $att.getFilename())
6 6   #set($ln = $n.toLowerCase())
... ... @@ -13,7 +13,9 @@
13 13  #end
14 14  
15 15  {{cache id="vid-list-$doc.fullName" timeToLive="21600"}}
18 +
16 16  {{html wiki="false" clean="false"}}
20 +
17 17  <div id="xwiki-video-manager" style="margin:20px 0;">
18 18   <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2>
19 19  
... ... @@ -26,21 +26,22 @@
26 26   <script>
27 27   window.VID_CHUNK_SIZE = 48;
28 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"
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"
32 32   </script>
33 33  
34 34   <div id="video-chunks">
35 35   #set($i = 0)
36 36   #set($chunkIndex = 0)
41 +
37 37   #foreach($att in $videos)
38 38   #set($i = $i + 1)
39 39   #set($filename = $att.getFilename())
40 - #set($lname = $filename.toLowerCase())
41 - #set($url = $doc.getAttachmentURL($filename))
45 + #set($lname = $filename.toLowerCase())
46 + #set($url = $doc.getAttachmentURL($filename))
42 42  
43 - ## MIME guess
48 + ## MIME type detection
44 44   #set($videoType = "video/mp4")
45 45   #if($lname.endsWith(".webm"))
46 46   #set($videoType = "video/webm")
... ... @@ -72,11 +72,8 @@
72 72   #end
73 73   </div>
74 74  
75 - <!-- Placeholder (auto-poster generated near viewport) -->
76 - <div class="video-frame"
77 - data-src="${url}"
78 - data-type="${videoType}"
79 - data-name="${escapetool.xml($filename)}"
80 + <!-- Video placeholder with auto-generated poster -->
81 + <div class="video-frame" data-src="${url}" data-type="${videoType}" data-name="${escapetool.xml($filename)}"
80 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 81   <canvas class="vid-canvas" width="320" height="180" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></canvas>
82 82   <button class="btn btn-sm btn-primary" type="button" style="position:relative;z-index:1;">Load &amp; Play</button>
... ... @@ -87,13 +87,12 @@
87 87   <span class="vid-duration" style="font-size:12px;color:#666;">Duration: —</span>
88 88   </div>
89 89  
90 - <!-- Move-to-page mini picker -->
92 + <!-- Move-to-page functionality -->
91 91   <div class="move-box" style="margin-top:10px;">
92 92   <label style="font-size:12px;color:#555;">Move to page:</label>
93 93   <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap;">
94 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 + data-filename="${escapetool.xml($filename)}" style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;">
97 97   <div class="move-results" style="position:relative;min-width:220px;max-width:420px;"></div>
98 98   </div>
99 99   <small style="color:#888;">Search is wiki-wide; click a result to move this file.</small>
... ... @@ -102,11 +102,13 @@
102 102  
103 103   #if(($i % 48 == 0) || $foreach.last)
104 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
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 +
110 110   </div>
111 111   #end
112 112   #end
... ... @@ -119,26 +119,37 @@
119 119   // ---- Helper: build REST path for nested spaces
120 120   function spacesPath(dotPath){
121 121   if(!dotPath) return '';
122 - return dotPath.split('.').map(function(s){ return 'spaces/' + encodeURIComponent(s); }).join('/');
125 + return dotPath.split('.').map(function(s){
126 + return 'spaces/' + encodeURIComponent(s);
127 + }).join('/');
123 123   }
124 124  
125 125   // ---- On-demand poster: draw first frame into the placeholder canvas
126 126   async function makePoster(frame){
127 127   if(frame.getAttribute('data-poster-ready')==='1') return;
128 - const src = frame.getAttribute('data-src');
133 +
134 + const src = frame.getAttribute('data-src');
129 129   const type = frame.getAttribute('data-type') || 'video/mp4';
136 +
130 130   try{
131 131   const v = document.createElement('video');
132 132   v.preload = 'metadata';
133 - v.muted = true; v.playsInline = true;
140 + v.muted = true;
141 + v.playsInline = true;
134 134   v.src = src;
135 135  
136 136   await new Promise((res, rej)=>{
137 137   let done=false;
138 - function finish(){ if(done) return; done=true; res(); }
146 + function finish(){
147 + if(done) return;
148 + done=true;
149 + res();
150 + }
139 139   v.addEventListener('loadeddata', finish, {once:true});
140 140   v.addEventListener('loadedmetadata', ()=>{
141 - try { v.currentTime = 0.04; } catch(e){}
153 + try {
154 + v.currentTime = 0.04;
155 + } catch(e){}
142 142   });
143 143   v.addEventListener('error', ()=>rej(new Error('metadata error')));
144 144   // Safety timeout
... ... @@ -148,7 +148,8 @@
148 148   const canvas = frame.querySelector('.vid-canvas');
149 149   if(canvas){
150 150   const w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16));
151 - canvas.width = 320; canvas.height = h>0?h:180;
165 + canvas.width = 320;
166 + canvas.height = h>0?h:180;
152 152   const ctx = canvas.getContext('2d');
153 153   if(ctx){
154 154   ctx.drawImage(v, 0, 0, canvas.width, canvas.height);
... ... @@ -163,16 +163,29 @@
163 163   // ---- Mount real <video> on click / near viewport (preload="none")
164 164   function mountVideo(frame){
165 165   if(frame.getAttribute('data-mounted')==='1') return;
166 - const src = frame.getAttribute('data-src');
181 +
182 + const src = frame.getAttribute('data-src');
167 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);
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 +
172 172   v.addEventListener('loadedmetadata', function(){
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;
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;
175 175   });
203 +
176 176   frame.replaceChildren(v);
177 177   frame.setAttribute('data-mounted','1');
178 178   }
... ... @@ -187,6 +187,7 @@
187 187   }
188 188   });
189 189   }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') });
218 +
190 190   document.querySelectorAll('.video-frame').forEach(el=>io.observe(el));
191 191   }
192 192  
... ... @@ -195,24 +195,28 @@
195 195   const frame = ev.target.closest('.video-frame');
196 196   if(frame){
197 197   mountVideo(frame);
198 - const v = frame.querySelector('video'); if(v) v.play().catch(()=>{});
227 + const v = frame.querySelector('video');
228 + if(v) v.play().catch(()=>{});
199 199   }
200 200   });
201 201  
202 202   // Chunk reveal
203 203   document.addEventListener('click', function(ev){
204 - const b = ev.target.closest('.load-more'); if(!b) return;
234 + const b = ev.target.closest('.load-more');
235 + if(!b) return;
205 205   const next = b.getAttribute('data-next');
206 206   const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
207 - if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; }
238 + if(nxt){
239 + nxt.style.display = 'block';
240 + b.parentElement.style.display = 'none';
241 + }
208 208   });
209 209  
210 210   // ---- Move to page: search & move
211 211   const wiki = window.XWIKI_WIKI;
246 +
212 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';
248 + const url = '/rest/wikis/' + encodeURIComponent(wiki) + '/search?q=' + encodeURIComponent(q) + '&scope=title,name&number=8&media=json';
216 216   // Title/name search is backed by Solr in recent XWiki; last token supports wildcard.
217 217   const r = await fetch(url, {credentials:'same-origin'});
218 218   if(!r.ok) return [];
... ... @@ -226,12 +226,17 @@
226 226   }
227 227  
228 228   function renderResults(box, results, onPick){
229 - function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;'); }
262 + function esc(s){
263 + return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;');
264 + }
265 +
230 230   var wrap = document.createElement('div');
231 231   wrap.className = 'move-suggest';
232 232   wrap.style.position='absolute';
233 233   wrap.style.zIndex='1000';
234 - wrap.style.top='0'; wrap.style.left='0'; wrap.style.right='0';
270 + wrap.style.top='0';
271 + wrap.style.left='0';
272 + wrap.style.right='0';
235 235   wrap.style.background='#fff';
236 236   wrap.style.border='1px solid #ddd';
237 237   wrap.style.borderRadius='4px';
... ... @@ -244,7 +244,7 @@
244 244   for (var i=0;i<results.length;i++){
245 245   var r = results[i];
246 246   var title = r.title ? esc(r.title) : '(untitled)';
247 - var full = esc(r.fullName || '');
285 + var full = esc(r.fullName || '');
248 248   html += '<div class="move-item" data-full="' + full + '" ' +
249 249   'style="padding:6px 10px;cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">' +
250 250   '<strong>' + title + '</strong>' +
... ... @@ -255,11 +255,12 @@
255 255   html = '<div style="padding:8px 10px;color:#777;">No matches</div>';
256 256   }
257 257   wrap.innerHTML = html;
258 -
259 259   box.textContent = '';
260 260   box.appendChild(wrap);
298 +
261 261   box.onpointerdown = function(e){
262 - var it = e.target.closest('.move-item'); if(!it) return;
300 + var it = e.target.closest('.move-item');
301 + if(!it) return;
263 263   var full = it.getAttribute('data-full');
264 264   onPick(full);
265 265   box.textContent = '';
... ... @@ -279,10 +279,11 @@
279 279   var srcPage = opts.srcPage;
280 280   var filename = opts.filename;
281 281   var dstFull = opts.dstFull;
282 -
321 +
283 283   const pf = parseFullName(dstFull);
284 284   const dstSpace = pf.spacePath;
285 285   const dstPage = pf.page;
325 +
286 286   const srcSpacesPath = spacesPath(srcSpace);
287 287   const dstSpacesPath = spacesPath(dstSpace);
288 288  
... ... @@ -289,12 +289,12 @@
289 289   // 1) GET the file as blob from the current attachment URL we already have in the card
290 290   // 2) PUT to target page's attachments
291 291   // 3) DELETE original
332 +
292 292   var cardInputSel = '.video-container input.move-input[data-filename="' + CSS.escape(filename) + '"]';
293 293   var inputEl = document.querySelector(cardInputSel);
294 294   var card = inputEl ? inputEl.closest('.video-container') : null;
295 295   var frame = card ? card.querySelector('.video-frame') : null;
296 296   const srcURL = frame ? frame.getAttribute('data-src') : null;
297 -
298 298   if(!srcURL) throw new Error('Missing source URL');
299 299  
300 300   const downloading = await fetch(srcURL, {credentials:'same-origin'});
... ... @@ -301,9 +301,7 @@
301 301   if(!downloading.ok) throw new Error('Download failed: '+downloading.status);
302 302   const blob = await downloading.blob();
303 303  
304 - const putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath +
305 - '/pages/' + encodeURIComponent(dstPage) +
306 - '/attachments/' + encodeURIComponent(filename) + '?media=json';
344 + const putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath + '/pages/' + encodeURIComponent(dstPage) + '/attachments/' + encodeURIComponent(filename) + '?media=json';
307 307   const uploading = await fetch(putURL, {
308 308   method: 'PUT',
309 309   body: blob,
... ... @@ -310,19 +310,20 @@
310 310   headers: {'Content-Type':'application/octet-stream'},
311 311   credentials:'same-origin'
312 312   });
351 +
313 313   if(!(uploading.status===201 || uploading.status===202)){
314 314   const txt = await uploading.text().catch(()=>String(uploading.status));
315 315   throw new Error('Upload failed: '+txt);
316 316   }
317 317  
318 - const delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath +
319 - '/pages/' + encodeURIComponent(srcPage) +
320 - '/attachments/' + encodeURIComponent(filename);
357 + const delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath + '/pages/' + encodeURIComponent(srcPage) + '/attachments/' + encodeURIComponent(filename);
321 321   const deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'});
359 +
322 322   if(!(deleting.status===204)){
323 323   // Not fatal: the file exists at destination; warn but keep going.
324 324   console.warn('Delete original failed', deleting.status);
325 325   }
364 +
326 326   return true;
327 327   }
328 328  
... ... @@ -330,25 +330,33 @@
330 330   let timer=null;
331 331   document.querySelectorAll('.move-box .move-input').forEach(inp=>{
332 332   const resultsBox = inp.parentElement.querySelector('.move-results');
372 +
333 333   inp.addEventListener('input', ()=>{
334 334   clearTimeout(timer);
335 335   const q = inp.value.trim();
336 - if(!q){ resultsBox.textContent=''; return; }
376 + if(!q){
377 + resultsBox.textContent='';
378 + return;
379 + }
337 337   timer = setTimeout(async ()=>{
338 338   const res = await searchPages(q);
339 339   renderResults(resultsBox, res, async (full)=>{
340 - inp.value = full;
383 + inp.value = full; // Fill the input
384 +
341 341   // Kick the move
342 342   const filename = inp.getAttribute('data-filename');
343 343   const notice = document.createElement('div');
344 - notice.style.fontSize='12px'; notice.style.color='#666'; notice.textContent='Moving…';
388 + notice.style.fontSize='12px';
389 + notice.style.color='#666';
390 + notice.textContent='Moving…';
345 345   inp.parentElement.appendChild(notice);
392 +
346 346   try{
347 347   await moveAttachment({
348 348   srcSpace: window.SOURCE_SPACE,
349 - srcPage: window.SOURCE_PAGE,
396 + srcPage: window.SOURCE_PAGE,
350 350   filename: filename,
351 - dstFull: full
398 + dstFull: full
352 352   });
353 353   notice.textContent = 'Moved ✔ — reloading…';
354 354   setTimeout(()=>location.reload(), 600);
... ... @@ -359,24 +359,78 @@
359 359   });
360 360   }, 220);
361 361   });
409 +
362 362   // Close suggestions when clicking out
363 - document.addEventListener('click', (e)=>{ if(!inp.parentElement.contains(e.target)) resultsBox.textContent=''; });
411 + document.addEventListener('click', (e)=>{
412 + if(!inp.parentElement.contains(e.target)) resultsBox.textContent='';
413 + });
364 364   });
415 +
365 365  })();
366 366  </script>
367 367  
368 368  <style>
369 -.video-container:hover{box-shadow:0 4px 8px rgba(0,0,0,0.08);transition:box-shadow .25s;}
370 -.btn{padding:4px 8px;border:1px solid #ddd;background:#f8f9fa;border-radius:4px;cursor:pointer;text-decoration:none;display:inline-block;}
371 -.btn:hover{background:#e9ecef;}
372 -.btn-primary{background:#007bff;color:#fff;border-color:#007bff;}
373 -.btn-secondary{background:#6c757d;color:#fff;border-color:#6c757d;}
374 -.btn-success{background:#28a745;color:#fff;border-color:#28a745;}
375 -.btn-sm{font-size:12px;padding:2px 6px;}
376 -@media (max-width:768px){.video-display-grid{grid-template-columns:1fr}}
377 -.move-suggest::-webkit-scrollbar{width:10px;height:10px}
378 -.move-suggest::-webkit-scrollbar-thumb{background:#ccc;border-radius:6px}
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 + }
379 379  </style>
478 +
380 380  {{/html}}
480 +
381 381  {{/cache}}
482 +
382 382  {{/velocity}}