0 Votes

Changes for page Uncategorized Videos

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

From version 497.1
edited by Ryan C
on 2025/09/10 04:44
Change comment: There is no comment for this version
To version 498.1
edited by Ryan C
on 2025/09/10 04:47
Change comment: Rollback to version 494.1

Summary

Details

Page properties
Content
... ... @@ -1,11 +1,12 @@
1 1  {{velocity}}
2 -#set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
3 -#set($videos = [])
2 +## Gather video attachments on the current page
3 +#set($videoExtensions = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v'])
4 +#set($videos = [])
4 4  #foreach($att in $doc.getAttachmentList())
5 - #set($n = $att.getFilename())
6 - #set($ln = $n.toLowerCase())
7 - #foreach($e in $videoExts)
8 - #if($ln.endsWith("." + $e))
6 + #set($name = $att.getFilename())
7 + #set($lname = $name.toLowerCase())
8 + #foreach($ext in $videoExtensions)
9 + #if($lname.endsWith("." + $ext))
9 9   #set($discard = $videos.add($att))
10 10   #break
11 11   #end
... ... @@ -12,7 +12,7 @@
12 12   #end
13 13  #end
14 14  
15 -{{cache id="vid-list-$doc.fullName" timeToLive="21600"}}
16 +{{cache id="vid-list-$doc.fullName" timeToLive="21600"}}## 6h cache of rendered HTML
16 16  {{html wiki="false" clean="false"}}
17 17  <div id="xwiki-video-manager" style="margin:20px 0;">
18 18   <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2>
... ... @@ -23,12 +23,10 @@
23 23   <p>Attach video files to this page to see them here.</p>
24 24   </div>
25 25   #else
27 + ## ---- Settings
26 26   <script>
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"
29 + window.VID_CHUNK_SIZE = 48; // how many lightweight cards per chunk
30 + window.VID_LAZY_MARGIN = '600px';// start loading a bit before entering view
32 32   </script>
33 33  
34 34   <div id="video-chunks">
... ... @@ -40,7 +40,7 @@
40 40   #set($lname = $filename.toLowerCase())
41 41   #set($url = $doc.getAttachmentURL($filename))
42 42  
43 - ## MIME guess
42 + ## decide MIME type (lightweight, used later when creating <video>)
44 44   #set($videoType = "video/mp4")
45 45   #if($lname.endsWith(".webm"))
46 46   #set($videoType = "video/webm")
... ... @@ -58,6 +58,7 @@
58 58   #set($videoType = "video/mp4")
59 59   #end
60 60  
60 + ## open chunk wrapper when starting a new chunk
61 61   #if($i == 1 || ($i - 1) % 48 == 0)
62 62   #set($chunkIndex = $chunkIndex + 1)
63 63   <div class="vid-chunk" data-chunk="$chunkIndex" style="display: #if($chunkIndex == 1) block #else none #end;">
... ... @@ -64,6 +64,7 @@
64 64   <div class="video-display-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;">
65 65   #end
66 66  
67 + ## lightweight card (NO <video> yet)
67 67   <div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:12px;background:#fff;">
68 68   <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
69 69   <h4 style="margin:0;flex:1;min-width:0;">${escapetool.xml($filename)}</h4>
... ... @@ -72,34 +72,22 @@
72 72   #end
73 73   </div>
74 74  
75 - <!-- Placeholder (auto-poster generated near viewport) -->
76 + <!-- Placeholder; the real <video> is created on demand -->
76 76   <div class="video-frame"
77 77   data-src="${url}"
78 78   data-type="${videoType}"
79 79   data-name="${escapetool.xml($filename)}"
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>
81 + style="width:100%;aspect-ratio:16/9;background:#f3f3f3;border-radius:4px;display:flex;align-items:center;justify-content:center;cursor:pointer;">
82 + <button class="btn btn-sm btn-primary" type="button">Load &amp; Play</button>
83 83   </div>
84 84  
85 - <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;">
85 + <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;">
86 86   <a href="${url}" download="${escapetool.xml($filename)}" class="btn btn-sm btn-success">📥 Download</a>
87 87   <span class="vid-duration" style="font-size:12px;color:#666;">Duration: —</span>
88 88   </div>
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>
101 101   </div>
102 102  
91 + ## close chunk wrapper when reaching size or end
103 103   #if(($i % 48 == 0) || $foreach.last)
104 104   </div>
105 105   #if(!$foreach.last)
... ... @@ -116,73 +116,54 @@
116 116  
117 117  <script>
118 118  (function(){
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;
108 + // Utility: create a <video preload="none"> only when needed
109 + function mountVideo(frame){
110 + if(frame.getAttribute('data-mounted') === '1') return;
128 128   const src = frame.getAttribute('data-src');
129 129   const type = frame.getAttribute('data-type') || 'video/mp4';
130 - try{
131 - const v = document.createElement('video');
132 - v.preload = 'metadata';
133 - v.muted = true; v.playsInline = true;
134 - v.src = src;
113 + const name = frame.getAttribute('data-name') || 'video';
135 135  
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 - });
115 + const v = document.createElement('video');
116 + v.setAttribute('controls','');
117 + v.setAttribute('preload','none'); // don't fetch until user interacts or we call load()
118 + v.style.width = '100%';
119 + v.style.maxWidth = '100%';
120 + v.style.borderRadius = '4px';
147 147  
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 - }
122 + const s = document.createElement('source');
123 + s.src = src;
124 + s.type = type;
125 + v.appendChild(s);
162 162  
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);
127 + // when metadata arrives (after load()), fill duration text
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;
129 + const d = Math.round(v.duration||0);
130 + const mm = Math.floor(d/60), ss = String(d%60).padStart(2,'0');
131 + const dur = frame.parentElement.querySelector('.vid-duration');
132 + if(dur) dur.textContent = 'Duration: ' + mm + ':' + ss;
175 175   });
134 +
176 176   frame.replaceChildren(v);
177 177   frame.setAttribute('data-mounted','1');
137 + // We don't call v.load() here; it will start when the user clicks play.
178 178   }
179 179  
180 - // Observe frames for posters + optional pre-mount
140 + // Click-to-load for each placeholder
141 + document.querySelectorAll('.video-frame').forEach(function(f){
142 + f.addEventListener('click', function(){
143 + mountVideo(f);
144 + const v = f.querySelector('video');
145 + if(v) v.play().catch(()=>{});
146 + }, {once:false});
147 + });
148 +
149 + // IntersectionObserver to auto-mount when approaching viewport
181 181   if('IntersectionObserver' in window){
182 182   const io = new IntersectionObserver((entries)=>{
183 183   entries.forEach(e=>{
184 184   if(e.isIntersecting){
185 - makePoster(e.target); // generate preview only
154 + mountVideo(e.target);
155 + // don't auto-play on scroll; user can play if desired
186 186   io.unobserve(e.target);
187 187   }
188 188   });
... ... @@ -190,216 +190,16 @@
190 190   document.querySelectorAll('.video-frame').forEach(el=>io.observe(el));
191 191   }
192 192  
193 - // Click to load & play
163 + // Chunk loader (reveals next batch only when user asks)
194 194   document.addEventListener('click', function(ev){
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;
165 + const b = ev.target.closest('.load-more');
166 + if(!b) return;
205 205   const next = b.getAttribute('data-next');
206 - const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
168 + const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]');
207 207   if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; }
208 208   });
209 209  
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;
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 -
339 - const resultsBox = inp.parentElement.querySelector('.move-results');
340 - if (!resultsBox) {
341 - console.error('No results box found for input', index);
342 - return;
343 - }
344 -
345 - inp.addEventListener('input', function(){
346 - clearTimeout(timer);
347 - const q = inp.value.trim();
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 - }
393 - }, 220);
394 - });
395 -
396 - // Close suggestions when clicking out
397 - document.addEventListener('click', function(e){
398 - if(!inp.parentElement.contains(e.target)) {
399 - resultsBox.textContent='';
400 - }
401 - });
402 - });
172 + // Basic button styles (same as before)
403 403  })();
404 404  </script>
405 405  
... ... @@ -412,9 +412,8 @@
412 412  .btn-success{background:#28a745;color:#fff;border-color:#28a745;}
413 413  .btn-sm{font-size:12px;padding:2px 6px;}
414 414  @media (max-width:768px){.video-display-grid{grid-template-columns:1fr}}
415 -.move-suggest::-webkit-scrollbar{width:10px;height:10px}
416 -.move-suggest::-webkit-scrollbar-thumb{background:#ccc;border-radius:6px}
417 417  </style>
418 418  {{/html}}
419 419  {{/cache}}
420 420  {{/velocity}}
189 +