Wiki source code of Uncategorized Videos
Show last authors
author | version | line-number | content |
---|---|---|---|
1 | {{velocity}} | ||
2 | #pagePicker_import ## load the native Page Picker resources once | ||
3 | |||
4 | ## 1) Collect video attachments from the current page | ||
5 | #set($videoExts = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v']) | ||
6 | #set($videos = []) | ||
7 | #foreach($att in $doc.getAttachmentList()) | ||
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 | ||
14 | #end | ||
15 | #end | ||
16 | #end | ||
17 | |||
18 | ## 2) Cache the rendered HTML (auto-bust on each save via version) | ||
19 | {{cache id="vid-list-$doc.fullName-$doc.version" timeToLive="21600"}} | ||
20 | {{html wiki="false" clean="false"}} | ||
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)}; // 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 | #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)) | ||
46 | |||
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 | ||
64 | |||
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" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:20px;"> | ||
69 | #end | ||
70 | |||
71 | <div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:12px;background:#fff;"> | ||
72 | <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> | ||
73 | <h4 style="margin:0;flex:1;min-width:0;">${escapetool.xml($filename)}</h4> | ||
74 | </div> | ||
75 | |||
76 | <!-- Placeholder (poster drawn on-demand) --> | ||
77 | <div class="video-frame" | ||
78 | data-src="${url}" | ||
79 | data-type="${videoType}" | ||
80 | data-name="${escapetool.xml($filename)}" | ||
81 | 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;"> | ||
82 | <canvas class="vid-canvas" width="320" height="180" style="position:absolute;inset:0;width:100%;height:100%;object-fit:cover;"></canvas> | ||
83 | <button class="btn btn-sm btn-primary" type="button" style="position:relative;z-index:1;">Load & Play</button> | ||
84 | </div> | ||
85 | |||
86 | <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;align-items:center;"> | ||
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> | ||
90 | |||
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 | </div> | ||
102 | <small style="color:#888;">Pick a page (e.g., <code>Main.SomePage</code> or a titled page). Selecting it moves this file.</small> | ||
103 | </div> | ||
104 | </div> | ||
105 | |||
106 | #if(($i % 48 == 0) || $foreach.last) | ||
107 | </div> | ||
108 | #if(!$foreach.last) | ||
109 | <div style="text-align:center;margin:12px 0 28px;"> | ||
110 | <button class="btn btn-secondary load-more" data-next="$mathtool.add($chunkIndex,1)">Load more</button> | ||
111 | </div> | ||
112 | #end | ||
113 | </div> | ||
114 | #end | ||
115 | #end | ||
116 | </div> | ||
117 | #end | ||
118 | </div> | ||
119 | |||
120 | <script> | ||
121 | /* replace your makePoster with this */ | ||
122 | async function makePoster(frame){ | ||
123 | if(frame.getAttribute('data-poster-ready')==='1') return; | ||
124 | var src = frame.getAttribute('data-src'); | ||
125 | try{ | ||
126 | var v = document.createElement('video'); | ||
127 | v.preload = 'metadata'; | ||
128 | v.muted = true; // allow autoplay on some browsers if needed | ||
129 | v.playsInline = true; | ||
130 | v.crossOrigin = 'anonymous'; // harmless on same-origin; avoids taint if proxied | ||
131 | |||
132 | // Promise that resolves after we actually have a decodable frame | ||
133 | function once(target, evt){ return new Promise(function(res){ target.addEventListener(evt, res, {once:true}); }); } | ||
134 | v.src = src; | ||
135 | |||
136 | // Wait for dimensions | ||
137 | await once(v, 'loadedmetadata'); | ||
138 | |||
139 | // Prefer rVFC when available (more reliable than seek for some codecs) | ||
140 | if (typeof v.requestVideoFrameCallback === 'function') { | ||
141 | await new Promise(function(res){ v.requestVideoFrameCallback(function(){ res(); }); }); | ||
142 | } else { | ||
143 | // Seek a bit into the file so we land on a keyframe | ||
144 | try { v.currentTime = Math.min(0.25, (v.seekable && v.seekable.length ? v.seekable.start(0) + 0.25 : 0.25)); } catch(e){} | ||
145 | await once(v, 'seeked'); | ||
146 | // Some browsers still need data decoded: | ||
147 | try { await once(v, 'loadeddata'); } catch(e){} | ||
148 | } | ||
149 | |||
150 | var canvas = frame.querySelector('.vid-canvas'); | ||
151 | if(canvas){ | ||
152 | var w = 320, h = Math.round(320 * (v.videoHeight||9) / (v.videoWidth||16)); | ||
153 | canvas.width = 320; canvas.height = h>0?h:180; | ||
154 | var ctx = canvas.getContext('2d', { willReadFrequently: true }); | ||
155 | ctx.drawImage(v, 0, 0, canvas.width, canvas.height); | ||
156 | |||
157 | // cache the poster for when we mount the real <video> | ||
158 | try { | ||
159 | var dataURL = canvas.toDataURL('image/webp', 0.85); | ||
160 | frame.setAttribute('data-poster', dataURL); | ||
161 | } catch(e) { /* ignore */ } | ||
162 | } | ||
163 | frame.setAttribute('data-poster-ready','1'); | ||
164 | } catch(e){ | ||
165 | // keep dark fallback | ||
166 | } | ||
167 | } | ||
168 | |||
169 | /* replace your mountVideo with this */ | ||
170 | function mountVideo(frame){ | ||
171 | if(frame.getAttribute('data-mounted')==='1') return; | ||
172 | var src = frame.getAttribute('data-src'); | ||
173 | var type = frame.getAttribute('data-type') || 'video/mp4'; | ||
174 | var poster = frame.getAttribute('data-poster'); // from makePoster() | ||
175 | |||
176 | var v = document.createElement('video'); | ||
177 | v.setAttribute('controls',''); | ||
178 | v.setAttribute('preload','none'); // keep perf | ||
179 | if (poster) v.setAttribute('poster', poster); // show the captured image | ||
180 | v.style.width='100%'; v.style.maxWidth='100%'; v.style.borderRadius='4px'; | ||
181 | |||
182 | var s = document.createElement('source'); s.src=src; s.type=type; v.appendChild(s); | ||
183 | |||
184 | v.addEventListener('loadedmetadata', function(){ | ||
185 | var d = Math.round(v.duration||0), mm = Math.floor(d/60), ss = String(d%60).padStart(2,'0'); | ||
186 | var dur = frame.parentElement.querySelector('.vid-duration'); | ||
187 | if(dur) dur.textContent = 'Duration: '+mm+':'+ss; | ||
188 | }); | ||
189 | |||
190 | frame.replaceChildren(v); | ||
191 | frame.setAttribute('data-mounted','1'); | ||
192 | } | ||
193 | </script> | ||
194 | |||
195 | // ---- Lazy poster | ||
196 | if('IntersectionObserver' in window){ | ||
197 | var io = new IntersectionObserver(function(entries){ | ||
198 | entries.forEach(function(e){ | ||
199 | if(e.isIntersecting){ makePoster(e.target); io.unobserve(e.target); } | ||
200 | }); | ||
201 | }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') }); | ||
202 | document.querySelectorAll('.video-frame').forEach(function(el){ io.observe(el); }); | ||
203 | } | ||
204 | |||
205 | // ---- Click to load & play | ||
206 | document.addEventListener('click', function(ev){ | ||
207 | var frame = ev.target.closest('.video-frame'); | ||
208 | if(frame){ | ||
209 | mountVideo(frame); | ||
210 | var v = frame.querySelector('video'); if(v) v.play().catch(function(){}); | ||
211 | } | ||
212 | }); | ||
213 | |||
214 | // ---- Chunk reveal | ||
215 | document.addEventListener('click', function(ev){ | ||
216 | var b = ev.target.closest('.load-more'); if(!b) return; | ||
217 | var next = b.getAttribute('data-next'); | ||
218 | var nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]'); | ||
219 | if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; } | ||
220 | }); | ||
221 | |||
222 | // ---- Move: when a page is picked in the native Page Picker (fires change) | ||
223 | var wiki = window.XWIKI_WIKI; | ||
224 | function moveAttachment(opts){ | ||
225 | return (async function(){ | ||
226 | var srcSpace = opts.srcSpace, srcPage = opts.srcPage, filename = opts.filename, dstFull = opts.dstFull; | ||
227 | var pf = (function(full){ full = String(full||'').replace(/^[^:]+:/,''); var p=full.split('.'); var page=p.pop(); return {spacePath:p.join('.'), page:page}; })(dstFull); | ||
228 | var srcSpacesPath = spacesPath(srcSpace), dstSpacesPath = spacesPath(pf.spacePath); | ||
229 | |||
230 | // Download -> Upload -> Delete original | ||
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'); | ||
237 | |||
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(); | ||
241 | |||
242 | var putURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + dstSpacesPath + | ||
243 | '/pages/' + encodeURIComponent(pf.page) + | ||
244 | '/attachments/' + encodeURIComponent(filename) + '?media=json'; | ||
245 | var uploading = await fetch(putURL, { | ||
246 | method: 'PUT', | ||
247 | body: blob, | ||
248 | headers: {'Content-Type':'application/octet-stream'}, | ||
249 | credentials:'same-origin' | ||
250 | }); | ||
251 | if(!(uploading.status===201 || uploading.status===202)){ | ||
252 | var txt = await uploading.text().catch(function(){ return String(uploading.status); }); | ||
253 | throw new Error('Upload failed: ' + txt); | ||
254 | } | ||
255 | |||
256 | var delURL = '/rest/wikis/' + encodeURIComponent(wiki) + '/' + srcSpacesPath + | ||
257 | '/pages/' + encodeURIComponent(srcPage) + | ||
258 | '/attachments/' + encodeURIComponent(filename); | ||
259 | var deleting = await fetch(delURL, {method:'DELETE', credentials:'same-origin'}); | ||
260 | if(!(deleting.status===204)){ console.warn('Delete original failed', deleting.status); } | ||
261 | return true; | ||
262 | })(); | ||
263 | } | ||
264 | |||
265 | document.addEventListener('change', function(e){ | ||
266 | var inp = e.target.closest('.move-input.suggest-pages'); if(!inp) return; | ||
267 | var selected = (inp.value || '').trim(); if(!selected) return; | ||
268 | |||
269 | var filename = inp.getAttribute('data-filename'); | ||
270 | var notice = document.createElement('div'); | ||
271 | notice.style.fontSize='12px'; notice.style.color='#666'; notice.textContent='Moving…'; | ||
272 | inp.parentElement.appendChild(notice); | ||
273 | |||
274 | moveAttachment({ | ||
275 | srcSpace: window.SOURCE_SPACE, | ||
276 | srcPage: window.SOURCE_PAGE, | ||
277 | filename: filename, | ||
278 | dstFull: selected | ||
279 | }).then(function(){ | ||
280 | notice.textContent = 'Moved ✔ — reloading…'; | ||
281 | setTimeout(function(){ location.reload(); }, 600); | ||
282 | }).catch(function(err){ | ||
283 | notice.style.color = '#b00020'; | ||
284 | notice.textContent = 'Move failed: ' + (err && err.message ? err.message : err); | ||
285 | }); | ||
286 | }); | ||
287 | })(); | ||
288 | </script> | ||
289 | |||
290 | <style> | ||
291 | .video-container:hover{box-shadow:0 4px 8px rgba(0,0,0,0.08);transition:box-shadow .25s;} | ||
292 | .btn{padding:4px 8px;border:1px solid #ddd;background:#f8f9fa;border-radius:4px;cursor:pointer;text-decoration:none;display:inline-block;} | ||
293 | .btn:hover{background:#e9ecef;} | ||
294 | .btn-primary{background:#007bff;color:#fff;border-color:#007bff;} | ||
295 | .btn-secondary{background:#6c757d;color:#fff;border-color:#6c757d;} | ||
296 | .btn-success{background:#28a745;color:#fff;border-color:#28a745;} | ||
297 | .btn-sm{font-size:12px;padding:2px 6px;} | ||
298 | @media (max-width:768px){.video-display-grid{grid-template-columns:1fr}} | ||
299 | </style> | ||
300 | {{/html}} | ||
301 | {{/cache}} | ||
302 | {{/velocity}} |