Wiki source code of Uncategorized Videos
Hide last authors
author | version | line-number | content |
---|---|---|---|
![]() |
493.1 | 1 | {{velocity}} |
![]() |
498.1 | 2 | ## Gather video attachments on the current page |
3 | #set($videoExtensions = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v']) | ||
4 | #set($videos = []) | ||
![]() |
493.1 | 5 | #foreach($att in $doc.getAttachmentList()) |
![]() |
498.1 | 6 | #set($name = $att.getFilename()) |
7 | #set($lname = $name.toLowerCase()) | ||
8 | #foreach($ext in $videoExtensions) | ||
9 | #if($lname.endsWith("." + $ext)) | ||
![]() |
493.1 | 10 | #set($discard = $videos.add($att)) |
11 | #break | ||
12 | #end | ||
13 | #end | ||
14 | #end | ||
15 | |||
![]() |
498.1 | 16 | {{cache id="vid-list-$doc.fullName" timeToLive="21600"}}## 6h cache of rendered HTML |
![]() |
493.1 | 17 | {{html wiki="false" clean="false"}} |
18 | <div id="xwiki-video-manager" style="margin:20px 0;"> | ||
![]() |
494.1 | 19 | <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2> |
![]() |
493.1 | 20 | |
![]() |
494.1 | 21 | #if($videos.size() == 0) |
22 | <div style="text-align:center;padding:40px;background:#f8f9fa;border-radius:8px;"> | ||
23 | <h3>No Videos Found</h3> | ||
24 | <p>Attach video files to this page to see them here.</p> | ||
![]() |
493.1 | 25 | </div> |
![]() |
494.1 | 26 | #else |
![]() |
498.1 | 27 | ## ---- Settings |
![]() |
494.1 | 28 | <script> |
![]() |
498.1 | 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 | ||
![]() |
494.1 | 31 | </script> |
![]() |
493.1 | 32 | |
![]() |
494.1 | 33 | <div id="video-chunks"> |
34 | #set($i = 0) | ||
35 | #set($chunkIndex = 0) | ||
36 | #foreach($att in $videos) | ||
37 | #set($i = $i + 1) | ||
38 | #set($filename = $att.getFilename()) | ||
39 | #set($lname = $filename.toLowerCase()) | ||
40 | #set($url = $doc.getAttachmentURL($filename)) | ||
![]() |
493.1 | 41 | |
![]() |
498.1 | 42 | ## decide MIME type (lightweight, used later when creating <video>) |
![]() |
493.1 | 43 | #set($videoType = "video/mp4") |
![]() |
494.1 | 44 | #if($lname.endsWith(".webm")) |
45 | #set($videoType = "video/webm") | ||
46 | #elseif($lname.endsWith(".ogg")) | ||
47 | #set($videoType = "video/ogg") | ||
48 | #elseif($lname.endsWith(".avi")) | ||
49 | #set($videoType = "video/x-msvideo") | ||
50 | #elseif($lname.endsWith(".mov")) | ||
51 | #set($videoType = "video/quicktime") | ||
52 | #elseif($lname.endsWith(".wmv")) | ||
53 | #set($videoType = "video/x-ms-wmv") | ||
54 | #elseif($lname.endsWith(".flv")) | ||
55 | #set($videoType = "video/x-flv") | ||
56 | #elseif($lname.endsWith(".m4v")) | ||
57 | #set($videoType = "video/mp4") | ||
58 | #end | ||
![]() |
493.1 | 59 | |
![]() |
498.1 | 60 | ## open chunk wrapper when starting a new chunk |
![]() |
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 | ||
![]() |
493.1 | 66 | |
![]() |
498.1 | 67 | ## lightweight card (NO <video> yet) |
![]() |
494.1 | 68 | <div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:12px;background:#fff;"> |
69 | <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;"> | ||
70 | <h4 style="margin:0;flex:1;min-width:0;">${escapetool.xml($filename)}</h4> | ||
71 | #if($xcontext.action == 'edit') | ||
72 | <input type="checkbox" class="video-selector" data-video="${escapetool.xml($filename)}" title="Select for bulk actions"> | ||
73 | #end | ||
74 | </div> | ||
![]() |
493.1 | 75 | |
![]() |
498.1 | 76 | <!-- Placeholder; the real <video> is created on demand --> |
![]() |
494.1 | 77 | <div class="video-frame" |
78 | data-src="${url}" | ||
79 | data-type="${videoType}" | ||
80 | data-name="${escapetool.xml($filename)}" | ||
![]() |
498.1 | 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 & Play</button> | ||
![]() |
494.1 | 83 | </div> |
84 | |||
![]() |
498.1 | 85 | <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;"> |
![]() |
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> | ||
![]() |
493.1 | 89 | </div> |
90 | |||
![]() |
498.1 | 91 | ## close chunk wrapper when reaching size or end |
![]() |
494.1 | 92 | #if(($i % 48 == 0) || $foreach.last) |
93 | </div> | ||
94 | #if(!$foreach.last) | ||
95 | <div style="text-align:center;margin:12px 0 28px;"> | ||
96 | <button class="btn btn-secondary load-more" data-next="$mathtool.add($chunkIndex,1)">Load more</button> | ||
97 | </div> | ||
98 | #end | ||
99 | </div> | ||
100 | #end | ||
101 | #end | ||
102 | </div> | ||
103 | #end | ||
![]() |
493.1 | 104 | </div> |
105 | |||
106 | <script> | ||
![]() |
494.1 | 107 | (function(){ |
![]() |
498.1 | 108 | // Utility: create a <video preload="none"> only when needed |
109 | function mountVideo(frame){ | ||
110 | if(frame.getAttribute('data-mounted') === '1') return; | ||
![]() |
494.1 | 111 | const src = frame.getAttribute('data-src'); |
112 | const type = frame.getAttribute('data-type') || 'video/mp4'; | ||
![]() |
498.1 | 113 | const name = frame.getAttribute('data-name') || 'video'; |
![]() |
494.1 | 114 | |
![]() |
498.1 | 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'; | ||
![]() |
494.1 | 121 | |
![]() |
498.1 | 122 | const s = document.createElement('source'); |
123 | s.src = src; | ||
124 | s.type = type; | ||
125 | v.appendChild(s); | ||
![]() |
494.1 | 126 | |
![]() |
498.1 | 127 | // when metadata arrives (after load()), fill duration text |
![]() |
494.1 | 128 | v.addEventListener('loadedmetadata', function(){ |
![]() |
498.1 | 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; | ||
![]() |
493.1 | 133 | }); |
![]() |
498.1 | 134 | |
![]() |
494.1 | 135 | frame.replaceChildren(v); |
136 | frame.setAttribute('data-mounted','1'); | ||
![]() |
498.1 | 137 | // We don't call v.load() here; it will start when the user clicks play. |
![]() |
494.1 | 138 | } |
139 | |||
![]() |
498.1 | 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 | ||
![]() |
494.1 | 150 | if('IntersectionObserver' in window){ |
151 | const io = new IntersectionObserver((entries)=>{ | ||
152 | entries.forEach(e=>{ | ||
153 | if(e.isIntersecting){ | ||
![]() |
498.1 | 154 | mountVideo(e.target); |
155 | // don't auto-play on scroll; user can play if desired | ||
![]() |
494.1 | 156 | io.unobserve(e.target); |
157 | } | ||
158 | }); | ||
159 | }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') }); | ||
160 | document.querySelectorAll('.video-frame').forEach(el=>io.observe(el)); | ||
![]() |
493.1 | 161 | } |
![]() |
494.1 | 162 | |
![]() |
498.1 | 163 | // Chunk loader (reveals next batch only when user asks) |
![]() |
494.1 | 164 | document.addEventListener('click', function(ev){ |
![]() |
498.1 | 165 | const b = ev.target.closest('.load-more'); |
166 | if(!b) return; | ||
![]() |
494.1 | 167 | const next = b.getAttribute('data-next'); |
![]() |
498.1 | 168 | const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]'); |
![]() |
494.1 | 169 | if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; } |
170 | }); | ||
171 | |||
![]() |
498.1 | 172 | // Basic button styles (same as before) |
![]() |
494.1 | 173 | })(); |
![]() |
493.1 | 174 | </script> |
175 | |||
176 | <style> | ||
![]() |
494.1 | 177 | .video-container:hover{box-shadow:0 4px 8px rgba(0,0,0,0.08);transition:box-shadow .25s;} |
![]() |
493.1 | 178 | .btn{padding:4px 8px;border:1px solid #ddd;background:#f8f9fa;border-radius:4px;cursor:pointer;text-decoration:none;display:inline-block;} |
179 | .btn:hover{background:#e9ecef;} | ||
180 | .btn-primary{background:#007bff;color:#fff;border-color:#007bff;} | ||
181 | .btn-secondary{background:#6c757d;color:#fff;border-color:#6c757d;} | ||
182 | .btn-success{background:#28a745;color:#fff;border-color:#28a745;} | ||
183 | .btn-sm{font-size:12px;padding:2px 6px;} | ||
![]() |
494.1 | 184 | @media (max-width:768px){.video-display-grid{grid-template-columns:1fr}} |
![]() |
493.1 | 185 | </style> |
186 | {{/html}} | ||
![]() |
494.1 | 187 | {{/cache}} |
![]() |
493.1 | 188 | {{/velocity}} |
![]() |
498.1 | 189 |