... |
... |
@@ -1,5 +1,5 @@ |
1 |
1 |
{{velocity}} |
2 |
|
-## Collect video attachments on the current page (safe getters only) |
|
2 |
+## Gather video attachments on the current page |
3 |
3 |
#set($videoExtensions = ['mp4','webm','ogg','avi','mov','wmv','flv','m4v']) |
4 |
4 |
#set($videos = []) |
5 |
5 |
#foreach($att in $doc.getAttachmentList()) |
... |
... |
@@ -13,158 +13,177 @@ |
13 |
13 |
#end |
14 |
14 |
#end |
15 |
15 |
|
16 |
|
-#if($videos.size() > 0) |
|
16 |
+{{cache id="vid-list-$doc.fullName" timeToLive="21600"}}## 6h cache of rendered HTML |
17 |
17 |
{{html wiki="false" clean="false"}} |
18 |
18 |
<div id="xwiki-video-manager" style="margin:20px 0;"> |
19 |
|
- <h2>📹 Video Manager for: ${escapetool.xml($doc.fullName)}</h2> |
|
19 |
+ <h2>📹 Videos on: ${escapetool.xml($doc.fullName)}</h2> |
20 |
20 |
|
21 |
|
- <!-- Management panel only in edit action --> |
22 |
|
- #if($xcontext.action == 'edit') |
23 |
|
- <div class="video-management-panel" style="background:#f8f9fa;border:1px solid #dee2e6;border-radius:8px;padding:15px;margin-bottom:20px;"> |
24 |
|
- <h3>🛠️ Video Management</h3> |
25 |
|
- <div style="margin-bottom:10px;"> |
26 |
|
- <button onclick="toggleVideoManager()" class="btn btn-primary">Manage Videos</button> |
27 |
|
- <button onclick="exportVideoList()" class="btn btn-secondary">Export Video List</button> |
28 |
|
- </div> |
29 |
|
- <div id="video-manager-controls" style="display:none;"> |
30 |
|
- <h4>Move Videos to Another Page:</h4> |
31 |
|
- <input type="text" id="target-page" placeholder="Space.PageName" style="width:300px;margin-right:10px;"> |
32 |
|
- <button onclick="moveSelectedVideos()" class="btn btn-warning">Move Selected</button> |
33 |
|
- <div style="margin-top:10px;font-size:12px;color:#666;"> |
34 |
|
- Select videos below and enter target page (e.g., "Main.MyPage") |
35 |
|
- </div> |
36 |
|
- </div> |
|
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> |
37 |
37 |
</div> |
38 |
|
- #end |
|
26 |
+ #else |
|
27 |
+ ## ---- Settings |
|
28 |
+ <script> |
|
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 |
|
31 |
+ </script> |
39 |
39 |
|
40 |
|
- <!-- Display grid --> |
41 |
|
- <div class="video-display-grid" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(400px,1fr));gap:20px;"> |
42 |
|
- #set($i = 0) |
43 |
|
- #foreach($att in $videos) |
44 |
|
- #set($i = $i + 1) |
45 |
|
- #set($filename = $att.getFilename()) |
46 |
|
- #set($lname = $filename.toLowerCase()) |
47 |
|
- #set($url = $doc.getAttachmentURL($filename)) |
48 |
|
- #set($vid = "video_${i}") |
|
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)) |
49 |
49 |
|
50 |
|
- ## MIME type from extension (basic) |
51 |
|
- #set($videoType = "video/mp4") |
52 |
|
- #if($lname.endsWith(".webm")) |
53 |
|
- #set($videoType = "video/webm") |
54 |
|
- #elseif($lname.endsWith(".ogg")) |
55 |
|
- #set($videoType = "video/ogg") |
56 |
|
- #elseif($lname.endsWith(".avi")) |
57 |
|
- #set($videoType = "video/x-msvideo") |
58 |
|
- #elseif($lname.endsWith(".mov")) |
59 |
|
- #set($videoType = "video/quicktime") |
60 |
|
- #elseif($lname.endsWith(".wmv")) |
61 |
|
- #set($videoType = "video/x-ms-wmv") |
62 |
|
- #elseif($lname.endsWith(".flv")) |
63 |
|
- #set($videoType = "video/x-flv") |
64 |
|
- #elseif($lname.endsWith(".m4v")) |
|
42 |
+ ## decide MIME type (lightweight, used later when creating <video>) |
65 |
65 |
#set($videoType = "video/mp4") |
66 |
|
- #end |
|
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 |
67 |
67 |
|
68 |
|
- <div class="video-container" style="border:1px solid #ddd;border-radius:8px;padding:15px;background:#fff;"> |
69 |
|
- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;"> |
70 |
|
- <h4 style="margin:0;flex-grow:1;">${escapetool.xml($filename)}</h4> |
71 |
|
- #if($xcontext.action == 'edit') |
72 |
|
- <input type="checkbox" class="video-selector" data-video="${escapetool.xml($filename)}" style="margin-left:10px;"> |
73 |
|
- #end |
74 |
|
- </div> |
|
60 |
+ ## open chunk wrapper when starting a new chunk |
|
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 |
75 |
75 |
|
76 |
|
- <video id="${vid}" controls preload="metadata" style="width:100%;max-width:100%;border-radius:4px;"> |
77 |
|
- <source src="${url}" type="${videoType}"> |
78 |
|
- Your browser does not support the video tag. |
79 |
|
- </video> |
|
67 |
+ ## lightweight card (NO <video> yet) |
|
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> |
80 |
80 |
|
81 |
|
- <div class="video-controls" style="margin-top:10px;display:flex;flex-wrap:wrap;gap:5px;"> |
82 |
|
- <button onclick="playPause('${vid}')" class="btn btn-sm btn-primary">⏯️ Play/Pause</button> |
83 |
|
- <button onclick="skipTime('${vid}', -10)" class="btn btn-sm btn-secondary">⏪ -10s</button> |
84 |
|
- <button onclick="skipTime('${vid}', 10)" class="btn btn-sm btn-secondary">⏩ +10s</button> |
85 |
|
- <button onclick="changeSpeed('${vid}', 0.5)" class="btn btn-sm btn-info">0.5x</button> |
86 |
|
- <button onclick="changeSpeed('${vid}', 1)" class="btn btn-sm btn-info">1x</button> |
87 |
|
- <button onclick="changeSpeed('${vid}', 1.5)" class="btn btn-sm btn-info">1.5x</button> |
88 |
|
- <button onclick="changeSpeed('${vid}', 2)" class="btn btn-sm btn-info">2x</button> |
89 |
|
- <a href="${url}" download="${escapetool.xml($filename)}" class="btn btn-sm btn-success">📥 Download</a> |
90 |
|
- </div> |
|
76 |
+ <!-- Placeholder; the real <video> is created on demand --> |
|
77 |
+ <div class="video-frame" |
|
78 |
+ data-src="${url}" |
|
79 |
+ data-type="${videoType}" |
|
80 |
+ data-name="${escapetool.xml($filename)}" |
|
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> |
|
83 |
+ </div> |
91 |
91 |
|
92 |
|
- <div class="video-info" style="margin-top:10px;font-size:12px;color:#666;"> |
93 |
|
- <div>Size: $att.getLongSize() bytes</div> |
94 |
|
- <div>Modified: $xwiki.formatDate($att.getDate())</div> |
95 |
|
- <div id="${vid}_duration">Duration: Loading...</div> |
|
85 |
+ <div class="video-controls" style="margin-top:8px;display:flex;flex-wrap:wrap;gap:6px;"> |
|
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> |
96 |
96 |
</div> |
97 |
|
- </div> |
98 |
|
- #end |
99 |
|
- </div> |
|
90 |
+ |
|
91 |
+ ## close chunk wrapper when reaching size or end |
|
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 |
100 |
100 |
</div> |
101 |
101 |
|
102 |
102 |
<script> |
103 |
|
-function playPause(id){const v=document.getElementById(id);if(v){v.paused?v.play():v.pause();}} |
104 |
|
-function skipTime(id,s){const v=document.getElementById(id);if(v){v.currentTime+=s;}} |
105 |
|
-function changeSpeed(id,sp){const v=document.getElementById(id);if(v){v.playbackRate=sp;}} |
106 |
|
-document.addEventListener('DOMContentLoaded',function(){ |
107 |
|
- document.querySelectorAll('video').forEach(function(v){ |
108 |
|
- v.addEventListener('loadedmetadata',function(){ |
109 |
|
- var d=Math.round(v.duration)||0,m=Math.floor(d/60),s=d%60; |
110 |
|
- var e=document.getElementById(v.id+'_duration'); |
111 |
|
- if(e){e.textContent='Duration: '+m+':' + String(s).padStart(2,'0');} |
|
107 |
+(function(){ |
|
108 |
+ // Utility: create a <video preload="none"> only when needed |
|
109 |
+ function mountVideo(frame){ |
|
110 |
+ if(frame.getAttribute('data-mounted') === '1') return; |
|
111 |
+ const src = frame.getAttribute('data-src'); |
|
112 |
+ const type = frame.getAttribute('data-type') || 'video/mp4'; |
|
113 |
+ const name = frame.getAttribute('data-name') || 'video'; |
|
114 |
+ |
|
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'; |
|
121 |
+ |
|
122 |
+ const s = document.createElement('source'); |
|
123 |
+ s.src = src; |
|
124 |
+ s.type = type; |
|
125 |
+ v.appendChild(s); |
|
126 |
+ |
|
127 |
+ // when metadata arrives (after load()), fill duration text |
|
128 |
+ v.addEventListener('loadedmetadata', function(){ |
|
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; |
112 |
112 |
}); |
|
134 |
+ |
|
135 |
+ frame.replaceChildren(v); |
|
136 |
+ frame.setAttribute('data-mounted','1'); |
|
137 |
+ // We don't call v.load() here; it will start when the user clicks play. |
|
138 |
+ } |
|
139 |
+ |
|
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}); |
113 |
113 |
}); |
114 |
|
-}); |
115 |
|
-function toggleVideoManager(){ |
116 |
|
- var c=document.getElementById('video-manager-controls'); |
117 |
|
- if(c){c.style.display=(c.style.display==='none'||!c.style.display)?'block':'none';} |
118 |
|
-} |
119 |
|
-function moveSelectedVideos(){ |
120 |
|
- const sel=[...document.querySelectorAll('.video-selector:checked')].map(x=>x.getAttribute('data-video')); |
121 |
|
- const target=(document.getElementById('target-page')||{}).value||''; |
122 |
|
- if(!target){alert('Please enter a target page (e.g., "Main.MyPage")');return;} |
123 |
|
- if(!sel.length){alert('Please select at least one video to move.');return;} |
124 |
|
- if(confirm('Move '+sel.length+' video(s) to '+target+'?\n\nVideos: '+sel.join(', '))){ |
125 |
|
- alert('Server-side move not implemented.\nSelected: '+sel.join(', ')+'\nTarget: '+target); |
|
148 |
+ |
|
149 |
+ // IntersectionObserver to auto-mount when approaching viewport |
|
150 |
+ if('IntersectionObserver' in window){ |
|
151 |
+ const io = new IntersectionObserver((entries)=>{ |
|
152 |
+ entries.forEach(e=>{ |
|
153 |
+ if(e.isIntersecting){ |
|
154 |
+ mountVideo(e.target); |
|
155 |
+ // don't auto-play on scroll; user can play if desired |
|
156 |
+ io.unobserve(e.target); |
|
157 |
+ } |
|
158 |
+ }); |
|
159 |
+ }, { rootMargin: (window.VID_LAZY_MARGIN||'600px') }); |
|
160 |
+ document.querySelectorAll('.video-frame').forEach(el=>io.observe(el)); |
126 |
126 |
} |
127 |
|
-} |
128 |
|
-function exportVideoList(){ |
129 |
|
- const titles=[...document.querySelectorAll('.video-container h4')].map(h=>h.textContent); |
130 |
|
- const txt='Video List from '+window.location.href+'\n\n'+titles.join('\n'); |
131 |
|
- const blob=new Blob([txt],{type:'text/plain'});const url=URL.createObjectURL(blob); |
132 |
|
- const a=document.createElement('a');a.href=url;a.download='video-list.txt'; |
133 |
|
- document.body.appendChild(a);a.click();document.body.removeChild(a);URL.revokeObjectURL(url); |
134 |
|
-} |
135 |
|
-document.addEventListener('keydown',function(e){ |
136 |
|
- const v=document.querySelector('video:hover, video:focus'); if(!v) return; |
137 |
|
- switch(e.code){ |
138 |
|
- case 'Space': e.preventDefault(); v.paused?v.play():v.pause(); break; |
139 |
|
- case 'ArrowLeft': e.preventDefault(); v.currentTime-=10; break; |
140 |
|
- case 'ArrowRight': e.preventDefault(); v.currentTime+=10; break; |
141 |
|
- } |
142 |
|
-}); |
|
162 |
+ |
|
163 |
+ // Chunk loader (reveals next batch only when user asks) |
|
164 |
+ document.addEventListener('click', function(ev){ |
|
165 |
+ const b = ev.target.closest('.load-more'); |
|
166 |
+ if(!b) return; |
|
167 |
+ const next = b.getAttribute('data-next'); |
|
168 |
+ const nxt = document.querySelector('.vid-chunk[data-chunk="'+ next +'"]'); |
|
169 |
+ if(nxt){ nxt.style.display = 'block'; b.parentElement.style.display = 'none'; } |
|
170 |
+ }); |
|
171 |
+ |
|
172 |
+ // Basic button styles (same as before) |
|
173 |
+})(); |
143 |
143 |
</script> |
144 |
144 |
|
145 |
145 |
<style> |
146 |
|
-.video-container:hover{box-shadow:0 4px 8px rgba(0,0,0,0.1);transition:box-shadow .3s ease;} |
|
177 |
+.video-container:hover{box-shadow:0 4px 8px rgba(0,0,0,0.08);transition:box-shadow .25s;} |
147 |
147 |
.btn{padding:4px 8px;border:1px solid #ddd;background:#f8f9fa;border-radius:4px;cursor:pointer;text-decoration:none;display:inline-block;} |
148 |
148 |
.btn:hover{background:#e9ecef;} |
149 |
149 |
.btn-primary{background:#007bff;color:#fff;border-color:#007bff;} |
150 |
150 |
.btn-secondary{background:#6c757d;color:#fff;border-color:#6c757d;} |
151 |
151 |
.btn-success{background:#28a745;color:#fff;border-color:#28a745;} |
152 |
|
-.btn-warning{background:#ffc107;color:#212529;border-color:#ffc107;} |
153 |
|
-.btn-info{background:#17a2b8;color:#fff;border-color:#17a2b8;} |
154 |
154 |
.btn-sm{font-size:12px;padding:2px 6px;} |
155 |
|
-@media (max-width:768px){.video-display-grid{grid-template-columns:1fr}.video-controls{justify-content:center}} |
|
184 |
+@media (max-width:768px){.video-display-grid{grid-template-columns:1fr}} |
156 |
156 |
</style> |
157 |
157 |
{{/html}} |
158 |
|
-#else |
159 |
|
-{{html wiki="false" clean="false"}} |
160 |
|
-<div style="text-align:center;padding:40px;background:#f8f9fa;border-radius:8px;"> |
161 |
|
- <h3>📹 No Videos Found</h3> |
162 |
|
- <p>No video files are attached to this page.</p> |
163 |
|
- #if($xcontext.action == 'edit') |
164 |
|
- <p>You can upload video files using the attachment feature in XWiki.</p> |
165 |
|
- #end |
166 |
|
-</div> |
167 |
|
-{{/html}} |
168 |
|
-#end |
|
187 |
+{{/cache}} |
169 |
169 |
{{/velocity}} |
170 |
170 |
|