Wiki source code of Uncategorized Videos
Hide last authors
author | version | line-number | content |
---|---|---|---|
![]() |
493.1 | 1 | {{velocity}} |
![]() |
503.1 | 2 | #pagePicker_import ## load the native Page Picker resources once |
![]() |
499.2 | 3 | |
![]() |
501.1 | 4 | ## 1) Collect video attachments from the current page |
![]() |
503.1 | 5 | #set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v']) |
6 | #set($videos = []) | ||
![]() |
493.1 | 7 | #foreach($att in $doc.getAttachmentList()) |
![]() |
503.1 | 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 | ||
![]() |
493.1 | 14 | #end |
![]() |
503.1 | 15 | #end |
16 | #end | ||
![]() |
493.1 | 17 | |
![]() |
501.1 | 18 | ## 2) Cache the rendered HTML (auto-bust on each save via version) |
![]() |
500.1 | 19 | {{cache id="vid-list-$doc.fullName-$doc.version" timeToLive="21600"}} |
![]() |
493.1 | 20 | {{html wiki="false" clean="false"}} |
21 | <div id="xwiki-video-manager" style="margin:20px 0;"> | ||
![]() |
494.1 | 22 | <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2> |
![]() |
493.1 | 23 | |
![]() |
494.1 | 24 | #if($videos.size() == 0) |
![]() |
503.1 | 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> | ||
![]() |
494.1 | 29 | #else |
![]() |
503.1 | 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> | ||
![]() |
493.1 | 37 | |
![]() |
503.1 | 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)) | ||
![]() |
493.1 | 46 | |
![]() |
503.1 | 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 | ||
![]() |
493.1 | 64 | |
![]() |
503.1 | 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;"> | ||
![]() |
494.1 | 70 | #end |
![]() |
493.1 | 71 | |
![]() |
494.1 | 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> | ||
![]() |
493.1 | 76 | |
![]() |
501.1 | 77 | <!-- Placeholder (poster drawn on-demand) --> |
![]() |
503.1 | 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 & | ||
83 | Play</button> | ||
![]() |
494.1 | 84 | </div> |
85 | |||
![]() |
499.1 | 86 | <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;"> |
![]() |
494.1 | 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> | ||
![]() |
499.1 | 90 | |
![]() |
505.1 | 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" | ||
96 | class="move-input suggest-pages" | ||
97 | placeholder="Find a page…" | ||
98 | data-search-scope="wiki:${escapetool.xml($xcontext.database)}" | ||
99 | data-filename="${escapetool.xml($filename)}" | ||
100 | style="flex:1;min-width:220px;padding:4px;border:1px solid #ccc;border-radius:4px;"> | ||
101 | <button type="button" | ||
102 | class="btn btn-sm btn-warning move-go" | ||
103 | data-filename="${escapetool.xml($filename)}">Move</button> | ||
104 | </div> | ||
105 | <small style="color:#888;">Pick a page (e.g., <code>Main.SomePage</code>). Click <b>Move</b> to relocate this file.</small> | ||
106 | </div> | ||
![]() |
493.1 | 107 | |
![]() |
505.1 | 108 | |
![]() |
494.1 | 109 | #if(($i % 48 == 0) || $foreach.last) |
![]() |
503.1 | 110 | </div> |
111 | #if(!$foreach.last) | ||
112 | <div style="text-align:center;margin:12px 0 28px;"> | ||
113 | <button class="btn btn-secondary load-more" data-next="$mathtool.add($chunkIndex,1)">Load more</button> | ||
114 | </div> | ||
![]() |
494.1 | 115 | #end |
116 | </div> | ||
![]() |
503.1 | 117 | #end |
118 | #end | ||
119 | </div> | ||
![]() |
494.1 | 120 | #end |
![]() |
493.1 | 121 | </div> |
122 | |||
123 | <script> | ||
![]() |
504.1 | 124 | (function(){ |
![]() |
505.1 | 125 | /* -------- helpers -------- */ |
![]() |
504.1 | 126 | function spacesPath(dotPath){ |
127 | if(!dotPath) return ''; | ||
![]() |
505.1 | 128 | return dotPath.split('.').map(function(s){ return 'spaces/'+encodeURIComponent(s); }).join('/'); |
![]() |
504.1 | 129 | } |
130 | function parseFullName(full){ | ||
![]() |
505.1 | 131 | full = String(full||'').replace(/^[^:]+:/,''); // drop wiki: if present |
![]() |
504.1 | 132 | var parts = full.split('.'); |
133 | var page = parts.pop(); | ||
![]() |
505.1 | 134 | return { spacePath: parts.join('.'), page: page }; |
![]() |
504.1 | 135 | } |
136 | |||
![]() |
505.1 | 137 | /* -------- thumbnail (poster) generation, unchanged idea -------- */ |
![]() |
504.1 | 138 | async function makePoster(frame){ |
139 | if(frame.getAttribute('data-poster-ready')==='1') return; | ||
![]() |
503.1 | 140 | var src = frame.getAttribute('data-src'); |
![]() |
504.1 | 141 | try{ |
![]() |
503.1 | 142 | var v = document.createElement('video'); |
143 | v.preload = 'metadata'; | ||
![]() |
504.1 | 144 | v.muted = true; v.playsInline = true; |
![]() |
503.1 | 145 | v.src = src; |
![]() |
494.1 | 146 | |
![]() |
505.1 | 147 | function once(t,e){return new Promise(function(res){t.addEventListener(e,res,{once:true});});} |
148 | await once(v,'loadedmetadata'); | ||
![]() |
503.1 | 149 | if (typeof v.requestVideoFrameCallback === 'function') { |
![]() |
505.1 | 150 | await new Promise(function(res){ v.requestVideoFrameCallback(function(){res();}); }); |
![]() |
503.1 | 151 | } else { |
![]() |
505.1 | 152 | try{ v.currentTime = 0.25; }catch(e){} |
153 | await once(v,'seeked').catch(function(){}); | ||
154 | await once(v,'loadeddata').catch(function(){}); | ||
![]() |
503.1 | 155 | } |
![]() |
494.1 | 156 | |
![]() |
503.1 | 157 | var canvas = frame.querySelector('.vid-canvas'); |
![]() |
504.1 | 158 | if(canvas){ |
159 | var w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16)); | ||
160 | canvas.width = 320; canvas.height = h>0?h:180; | ||
![]() |
505.1 | 161 | var ctx = canvas.getContext('2d', { willReadFrequently:true }); |
![]() |
503.1 | 162 | ctx.drawImage(v, 0, 0, canvas.width, canvas.height); |
![]() |
505.1 | 163 | try{ frame.setAttribute('data-poster', canvas.toDataURL('image/webp', 0.85)); }catch(e){} |
![]() |
503.1 | 164 | } |
![]() |
504.1 | 165 | frame.setAttribute('data-poster-ready','1'); |
![]() |
505.1 | 166 | }catch(e){} |
![]() |
494.1 | 167 | } |
168 | |||
![]() |
504.1 | 169 | function mountVideo(frame){ |
170 | if(frame.getAttribute('data-mounted')==='1') return; | ||
![]() |
503.1 | 171 | var src = frame.getAttribute('data-src'); |
172 | var type = frame.getAttribute('data-type') || 'video/mp4'; | ||
![]() |
504.1 | 173 | var poster = frame.getAttribute('data-poster'); |
![]() |
503.1 | 174 | var v = document.createElement('video'); |
![]() |
505.1 | 175 | v.setAttribute('controls',''); v.setAttribute('preload','none'); |
![]() |
504.1 | 176 | if(poster) v.setAttribute('poster', poster); |
177 | v.style.width='100%'; v.style.maxWidth='100%'; v.style.borderRadius='4px'; | ||
![]() |
505.1 | 178 | var s = document.createElement('source'); s.src=src; s.type=type; v.appendChild(s); |
![]() |
504.1 | 179 | v.addEventListener('loadedmetadata', function(){ |
180 | var d = Math.round(v.duration||0), mm = Math.floor(d/60), ss = String(d%60).padStart(2,'0'); | ||
![]() |
505.1 | 181 | var dur = frame.parentElement.querySelector('.vid-duration'); if(dur) dur.textContent='Duration: '+mm+':'+ss; |
![]() |
503.1 | 182 | }); |
183 | frame.replaceChildren(v); | ||
![]() |
504.1 | 184 | frame.setAttribute('data-mounted','1'); |
![]() |
503.1 | 185 | } |
![]() |
502.1 | 186 | |
![]() |
505.1 | 187 | /* -------- lazy posters + chunk reveal -------- */ |
![]() |
504.1 | 188 | if('IntersectionObserver' in window){ |
189 | var io = new IntersectionObserver(function(entries){ | ||
190 | entries.forEach(function(e){ | ||
191 | if(e.isIntersecting){ makePoster(e.target); io.unobserve(e.target); } | ||
192 | }); | ||
193 | }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') }); | ||
194 | document.querySelectorAll('.video-frame').forEach(function(el){ io.observe(el); }); | ||
195 | } | ||
![]() |
494.1 | 196 | |
![]() |
504.1 | 197 | document.addEventListener('click', function(ev){ |
198 | var frame = ev.target.closest('.video-frame'); | ||
199 | if(frame){ | ||
200 | mountVideo(frame); | ||
201 | var v = frame.querySelector('video'); if(v) v.play().catch(function(){}); | ||
202 | } | ||
203 | }); | ||
![]() |
499.1 | 204 | |
![]() |
504.1 | 205 | document.addEventListener('click', function(ev){ |
206 | var b = ev.target.closest('.load-more'); if(!b) return; | ||
207 | var next = b.getAttribute('data-next'); | ||
208 | var nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]'); | ||
![]() |
505.1 | 209 | if(nxt){ nxt.style.display='block'; b.parentElement.style.display='none'; } |
![]() |
504.1 | 210 | }); |
![]() |
494.1 | 211 | |
![]() |
505.1 | 212 | /* -------- CSRF token (form token) for REST writes -------- */ |
213 | var FORM_TOKEN = null; | ||
214 | async function getFormToken(){ | ||
215 | if (FORM_TOKEN) return FORM_TOKEN; | ||
216 | var wiki = window.XWIKI_WIKI; | ||
217 | var r = await fetch('/rest/wikis/'+encodeURIComponent(wiki), {credentials:'same-origin'}); | ||
218 | FORM_TOKEN = r.headers.get('XWiki-Form-Token') || ''; | ||
219 | return FORM_TOKEN; | ||
220 | } | ||
![]() |
499.2 | 221 | |
![]() |
505.1 | 222 | /* -------- move attachment: download -> PUT -> DELETE -------- */ |
223 | async function moveAttachment(opts){ | ||
224 | var wiki = window.XWIKI_WIKI; | ||
225 | var srcSpace = opts.srcSpace, srcPage = opts.srcPage, filename = opts.filename, dstFull = opts.dstFull; | ||
226 | var pf = parseFullName(dstFull); | ||
227 | var srcSpacesPath = spacesPath(srcSpace); | ||
228 | var dstSpacesPath = spacesPath(pf.spacePath); | ||
![]() |
499.1 | 229 | |
![]() |
505.1 | 230 | // Find the card to read the current download URL |
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'); | ||
![]() |
499.2 | 237 | |
![]() |
505.1 | 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(); | ||
![]() |
499.2 | 241 | |
![]() |
505.1 | 242 | var token = await getFormToken(); |
243 | |||
244 | var putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath + | ||
245 | '/pages/' + encodeURIComponent(pf.page) + | ||
246 | '/attachments/' + encodeURIComponent(filename) + '?media=json'; | ||
247 | |||
248 | var uploading = await fetch(putURL, { | ||
249 | method: 'PUT', | ||
250 | body: blob, | ||
251 | headers: { | ||
252 | 'Content-Type':'application/octet-stream', | ||
253 | 'XWiki-Form-Token': token // harmless when not required | ||
254 | }, | ||
255 | credentials:'same-origin' | ||
256 | }); | ||
257 | if(!(uploading.status===201 || uploading.status===202)){ | ||
258 | var txt = await uploading.text().catch(function(){ return String(uploading.status); }); | ||
259 | throw new Error('Upload failed: ' + txt); | ||
260 | } | ||
261 | |||
262 | var delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath + | ||
263 | '/pages/' + encodeURIComponent(srcPage) + | ||
264 | '/attachments/' + encodeURIComponent(filename); | ||
265 | |||
266 | var deleting = await fetch(delURL, { | ||
267 | method:'DELETE', | ||
268 | headers: {'XWiki-Form-Token': token}, | ||
269 | credentials:'same-origin' | ||
270 | }); | ||
271 | if(!(deleting.status===204)){ | ||
272 | console.warn('Delete original failed', deleting.status); | ||
273 | } | ||
274 | return true; | ||
![]() |
504.1 | 275 | } |
![]() |
499.1 | 276 | |
![]() |
505.1 | 277 | /* -------- button handler (explicit Move) -------- */ |
278 | document.addEventListener('click', function(e){ | ||
279 | var btn = e.target.closest('.move-go'); if(!btn) return; | ||
280 | var box = btn.closest('.move-box'); | ||
281 | var inp = box.querySelector('.move-input'); | ||
282 | var selected = (inp && inp.value ? inp.value.trim() : ''); | ||
283 | if(!selected){ inp && inp.focus(); return; } | ||
![]() |
499.1 | 284 | |
![]() |
504.1 | 285 | var notice = document.createElement('div'); |
286 | notice.style.fontSize='12px'; notice.style.color='#666'; notice.textContent='Moving…'; | ||
![]() |
505.1 | 287 | box.appendChild(notice); |
![]() |
499.2 | 288 | |
![]() |
504.1 | 289 | moveAttachment({ |
290 | srcSpace: window.SOURCE_SPACE, | ||
291 | srcPage: window.SOURCE_PAGE, | ||
![]() |
505.1 | 292 | filename: btn.getAttribute('data-filename'), |
![]() |
504.1 | 293 | dstFull: selected |
294 | }).then(function(){ | ||
295 | notice.textContent = 'Moved ✔ — reloading…'; | ||
296 | setTimeout(function(){ location.reload(); }, 600); | ||
297 | }).catch(function(err){ | ||
298 | notice.style.color = '#b00020'; | ||
299 | notice.textContent = 'Move failed: ' + (err && err.message ? err.message : err); | ||
300 | }); | ||
301 | }); | ||
![]() |
505.1 | 302 | |
303 | /* Optional: still move when the picker fires a native change */ | ||
304 | document.addEventListener('change', function(e){ | ||
305 | var inp = e.target.closest('.move-input.suggest-pages'); if(!inp) return; | ||
306 | // no auto-move here to avoid surprises; the Move button is the canonical trigger | ||
307 | }); | ||
![]() |
494.1 | 308 | })(); |
![]() |
493.1 | 309 | </script> |
310 | |||
311 | <style> | ||
![]() |
503.1 | 312 | .video-container:hover { |
313 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08); | ||
314 | transition: box-shadow .25s; | ||
315 | } | ||
316 | |||
317 | .btn { | ||
318 | padding: 4px 8px; | ||
319 | border: 1px solid #ddd; | ||
320 | background: #f8f9fa; | ||
321 | border-radius: 4px; | ||
322 | cursor: pointer; | ||
323 | text-decoration: none; | ||
324 | display: inline-block; | ||
325 | } | ||
326 | |||
327 | .btn:hover { | ||
328 | background: #e9ecef; | ||
329 | } | ||
330 | |||
331 | .btn-primary { | ||
332 | background: #007bff; | ||
333 | color: #fff; | ||
334 | border-color: #007bff; | ||
335 | } | ||
336 | |||
337 | .btn-secondary { | ||
338 | background: #6c757d; | ||
339 | color: #fff; | ||
340 | border-color: #6c757d; | ||
341 | } | ||
342 | |||
343 | .btn-success { | ||
344 | background: #28a745; | ||
345 | color: #fff; | ||
346 | border-color: #28a745; | ||
347 | } | ||
348 | |||
349 | .btn-sm { | ||
350 | font-size: 12px; | ||
351 | padding: 2px 6px; | ||
352 | } | ||
353 | |||
354 | @media (max-width:768px) { | ||
355 | .video-display-grid { | ||
356 | grid-template-columns: 1fr | ||
357 | } | ||
358 | } | ||
![]() |
493.1 | 359 | </style> |
360 | {{/html}} | ||
![]() |
494.1 | 361 | {{/cache}} |
![]() |
501.1 | 362 | {{/velocity}} |