peertube-plugin-odysee-player 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -0
- package/client/client-plugin.js +374 -0
- package/client/style.css +555 -0
- package/index.js +6 -0
- package/package.json +35 -0
- package/server/plugin.js +76 -0
package/README.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# PeerTube Plugin β Odysee Player
|
|
2
|
+
|
|
3
|
+
Embed and manage unlisted Odysee videos directly inside PeerTube with full episode management.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- βΆ Embed unlisted Odysee videos via iframe
|
|
10
|
+
- β Manually set title, description, thumbnail per episode
|
|
11
|
+
- π’ Custom episode numbering (pick any number)
|
|
12
|
+
- π Delete episodes from admin panel
|
|
13
|
+
- π Episode pagination below the video (6 per page)
|
|
14
|
+
- β¨ Active episode card is highlighted for both admin and users
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Folder Structure
|
|
19
|
+
|
|
20
|
+
peertube-plugin-odysee-player/
|
|
21
|
+
βββ index.js β Plugin entry point
|
|
22
|
+
βββ package.json β Plugin manifest
|
|
23
|
+
βββ server/
|
|
24
|
+
β βββ plugin.js β API routes (get/add/delete episodes)
|
|
25
|
+
βββ client/
|
|
26
|
+
βββ client-plugin.js β Admin panel + player UI
|
|
27
|
+
βββ styles.css β All styles
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
### Option 1 β Install from folder (Development)
|
|
33
|
+
|
|
34
|
+
1. Copy the entire peertube-plugin-odysee-player folder into your PeerTube plugins directory:
|
|
35
|
+
|
|
36
|
+
/var/www/peertube/storage/plugins/
|
|
37
|
+
|
|
38
|
+
2. Inside the folder, run:
|
|
39
|
+
|
|
40
|
+
npm install
|
|
41
|
+
|
|
42
|
+
3. In PeerTube Admin β Plugins/Themes β Install β select the plugin.
|
|
43
|
+
|
|
44
|
+
### Option 2 β Publish to npm then install via PeerTube UI
|
|
45
|
+
|
|
46
|
+
1. Run npm publish from inside the plugin folder.
|
|
47
|
+
2. In PeerTube Admin β Plugins/Themes β search peertube-plugin-odysee-player β Install.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## How to Use
|
|
52
|
+
|
|
53
|
+
### Admin Panel
|
|
54
|
+
|
|
55
|
+
1. Go to Admin β Plugins β Odysee Player β Settings
|
|
56
|
+
2. Click β Manage Episodes tab
|
|
57
|
+
3. Fill in:
|
|
58
|
+
- Episode Number β any number you choose (e.g. 1, 5, 12)
|
|
59
|
+
- Title β episode title
|
|
60
|
+
- Description β optional
|
|
61
|
+
- Thumbnail URL β optional image URL
|
|
62
|
+
- Odysee Embed ID β from the Odysee embed URL:
|
|
63
|
+
https://odysee.com/$/embed/**THIS_IS_THE_EMBED_ID**
|
|
64
|
+
4. Cl+ Add Episodede**
|
|
65
|
+
5. To delete, click π next to any episode
|
|
66
|
+
|
|
67
|
+
### Player Preview
|
|
68
|
+
|
|
69
|
+
ClβΆ Preview Playerer** tab to see how it looks for users.
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
|
|
73
|
+
## Odysee Embed ID
|
|
74
|
+
|
|
75
|
+
To get the embed ID of an unlisted Odysee video:
|
|
76
|
+
|
|
77
|
+
1. Open your video on Odysee
|
|
78
|
+
2. ClSharere*Embeded**
|
|
79
|
+
3. Copy the URL from the iframe src:
|
|
80
|
+
|
|
81
|
+
https://odysee.com/$/embed/my-video-title:abc123hash
|
|
82
|
+
|
|
83
|
+
4. The embed ID is: my-video-title:abc123hash
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
[5/9/26 7:01β―PM] Mohammad: /* global registerHook, peertubeHelpers */
|
|
2
|
+
|
|
3
|
+
// βββ Constants ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
4
|
+
const EPISODES_PER_PAGE = 6
|
|
5
|
+
const PLUGIN_NAME = 'peertube-plugin-odysee-player'
|
|
6
|
+
|
|
7
|
+
// βββ State ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
8
|
+
let state = {
|
|
9
|
+
episodes: [],
|
|
10
|
+
currentEpisodeId: null,
|
|
11
|
+
currentPage: 1,
|
|
12
|
+
isAdmin: false,
|
|
13
|
+
baseUrl: ''
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// βββ API Helpers ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
17
|
+
async function apiGet (path) {
|
|
18
|
+
const res = await fetch(/plugins/odysee-player/router${path})
|
|
19
|
+
return res.json()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function apiPost (path, body) {
|
|
23
|
+
const res = await fetch(/plugins/odysee-player/router${path}, {
|
|
24
|
+
method: 'POST',
|
|
25
|
+
headers: { 'Content-Type': 'application/json' },
|
|
26
|
+
body: JSON.stringify(body)
|
|
27
|
+
})
|
|
28
|
+
return res.json()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function apiDelete (path) {
|
|
32
|
+
const res = await fetch(/plugins/odysee-player/router${path}, {
|
|
33
|
+
method: 'DELETE'
|
|
34
|
+
})
|
|
35
|
+
return res.json()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// βββ Load Episodes ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
39
|
+
async function loadEpisodes () {
|
|
40
|
+
const data = await apiGet('/episodes')
|
|
41
|
+
state.episodes = data.episodes || []
|
|
42
|
+
if (!state.currentEpisodeId && state.episodes.length > 0) {
|
|
43
|
+
state.currentEpisodeId = state.episodes[0].id
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// βββ Pagination helpers βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
48
|
+
function getTotalPages () {
|
|
49
|
+
return Math.max(1, Math.ceil(state.episodes.length / EPISODES_PER_PAGE))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getPageEpisodes () {
|
|
53
|
+
const start = (state.currentPage - 1) * EPISODES_PER_PAGE
|
|
54
|
+
return state.episodes.slice(start, start + EPISODES_PER_PAGE)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getCurrentEpisode () {
|
|
58
|
+
return state.episodes.find(e => e.id === state.currentEpisodeId) || null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// βββ Render Player ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
62
|
+
function renderPlayer (container) {
|
|
63
|
+
const ep = getCurrentEpisode()
|
|
64
|
+
const totalPages = getTotalPages()
|
|
65
|
+
const pageEps = getPageEpisodes()
|
|
66
|
+
|
|
67
|
+
container.innerHTML =
|
|
68
|
+
<div class="odysee-player-wrap">
|
|
69
|
+
|
|
70
|
+
${ep ?
|
|
71
|
+
<!-- VIDEO SECTION -->
|
|
72
|
+
<div class="op-video-section">
|
|
73
|
+
<div class="op-video-frame-wrap">
|
|
74
|
+
<iframe
|
|
75
|
+
src="https://odysee.com/$/embed/${ep.embedId}"
|
|
76
|
+
frameborder="0"
|
|
77
|
+
allowfullscreen
|
|
78
|
+
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
79
|
+
></iframe>
|
|
80
|
+
</div>
|
|
81
|
+
<div class="op-video-meta">
|
|
82
|
+
<span class="op-episode-badge">Episode ${ep.episodeNumber}</span>
|
|
83
|
+
<h2 class="op-video-title">${escHtml(ep.title)}</h2>
|
|
84
|
+
${ep.description ? <p class="op-video-desc">${escHtml(ep.description)}</p> : ''}
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
:
|
|
88
|
+
<div class="op-empty-state">
|
|
89
|
+
<div class="op-empty-icon">πΊ</div>
|
|
90
|
+
<p>No episodes available yet.</p>
|
|
91
|
+
${state.isAdmin ? '<p>Use the <strong>Manage Episodes</strong> tab to add your first episode.</p>' : ''}
|
|
92
|
+
</div>
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
<!-- EPISODE GRID -->
|
|
96
|
+
${state.episodes.length > 0 ?
|
|
97
|
+
<div class="op-episodes-section">
|
|
98
|
+
<h3 class="op-section-label">Episodes</h3>
|
|
99
|
+
<div class="op-episode-grid">
|
|
100
|
+
${pageEps.map(ep => renderEpisodeCard(ep)).join('')}
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<!-- PAGINATION -->
|
|
104
|
+
${totalPages > 1 ?
|
|
105
|
+
<div class="op-pagination">
|
|
106
|
+
<button class="op-page-btn" data-action="prev" ${state.currentPage === 1 ? 'disabled' : ''}>
|
|
107
|
+
← Prev
|
|
108
|
+
</button>
|
|
109
|
+
<div class="op-page-numbers">
|
|
110
|
+
${Array.from({ length: totalPages }, (_, i) => i + 1).map(p =>
|
|
111
|
+
<button class="op-page-num ${p === state.currentPage ? 'active' : ''}" data-page="${p}">${p}</button>
|
|
112
|
+
).join('')}
|
|
113
|
+
</div>
|
|
114
|
+
<button class="op-page-btn" data-action="next" ${state.currentPage === totalPages ? 'disabled' : ''}>
|
|
115
|
+
Next →
|
|
116
|
+
</button>
|
|
117
|
+
</div>
|
|
118
|
+
: ''}
|
|
119
|
+
</div>
|
|
120
|
+
: ''}
|
|
121
|
+
</div>
|
|
122
|
+
[5/9/26 7:01β―PM] Mohammad: // Episode card click
|
|
123
|
+
container.querySelectorAll('.op-episode-card').forEach(card => {
|
|
124
|
+
card.addEventListener('click', () => {
|
|
125
|
+
const id = Number(card.dataset.id)
|
|
126
|
+
state.currentEpisodeId = id
|
|
127
|
+
renderPlayer(container)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
// Pagination clicks
|
|
132
|
+
container.querySelectorAll('[data-action]').forEach(btn => {
|
|
133
|
+
btn.addEventListener('click', () => {
|
|
134
|
+
if (btn.dataset.action === 'prev' && state.currentPage > 1) state.currentPage--
|
|
135
|
+
if (btn.dataset.action === 'next' && state.currentPage < totalPages) state.currentPage++
|
|
136
|
+
renderPlayer(container)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
container.querySelectorAll('[data-page]').forEach(btn => {
|
|
141
|
+
btn.addEventListener('click', () => {
|
|
142
|
+
state.currentPage = Number(btn.dataset.page)
|
|
143
|
+
renderPlayer(container)
|
|
144
|
+
})
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function renderEpisodeCard (ep) {
|
|
149
|
+
const isActive = ep.id === state.currentEpisodeId
|
|
150
|
+
const thumb = ep.thumbnail || https://picsum.photos/seed/${ep.id}/320/180
|
|
151
|
+
return
|
|
152
|
+
<div class="op-episode-card ${isActive ? 'is-active' : ''}" data-id="${ep.id}">
|
|
153
|
+
<div class="op-card-thumb">
|
|
154
|
+
<img src="${escHtml(thumb)}" alt="${escHtml(ep.title)}" loading="lazy" />
|
|
155
|
+
<div class="op-card-overlay">
|
|
156
|
+
<span class="op-play-icon">${isActive ? 'βΆ' : 'β·'}</span>
|
|
157
|
+
</div>
|
|
158
|
+
${isActive ? '<div class="op-now-playing">NOW PLAYING</div>' : ''}
|
|
159
|
+
</div>
|
|
160
|
+
<div class="op-card-body">
|
|
161
|
+
<span class="op-ep-num">EP ${ep.episodeNumber}</span>
|
|
162
|
+
<p class="op-card-title">${escHtml(ep.title)}</p>
|
|
163
|
+
</div>
|
|
164
|
+
</div>
|
|
165
|
+
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// βββ Render Admin Panel βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
169
|
+
function renderAdmin (container) {
|
|
170
|
+
container.innerHTML =
|
|
171
|
+
<div class="op-admin-wrap">
|
|
172
|
+
<div class="op-admin-header">
|
|
173
|
+
<h2>β Odysee Episode Manager</h2>
|
|
174
|
+
<p class="op-admin-sub">Add, manage, and delete Odysee embedded episodes.</p>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<!-- ADD FORM -->
|
|
178
|
+
<div class="op-add-form">
|
|
179
|
+
<h3>Add New Episode</h3>
|
|
180
|
+
<div class="op-form-grid">
|
|
181
|
+
<div class="op-field">
|
|
182
|
+
<label>Episode Number <span class="req">*</span></label>
|
|
183
|
+
<input type="number" id="op-ep-num" placeholder="e.g. 3" min="1" />
|
|
184
|
+
<span class="op-field-err" id="err-num"></span>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="op-field">
|
|
187
|
+
<label>Title <span class="req">*</span></label>
|
|
188
|
+
<input type="text" id="op-ep-title" placeholder="Episode title" />
|
|
189
|
+
<span class="op-field-err" id="err-title"></span>
|
|
190
|
+
</div>
|
|
191
|
+
<div class="op-field op-field-full">
|
|
192
|
+
<label>Description</label>
|
|
193
|
+
<textarea id="op-ep-desc" placeholder="Episode description (optional)" rows="3"></textarea>
|
|
194
|
+
</div>
|
|
195
|
+
<div class="op-field">
|
|
196
|
+
<label>Thumbnail URL</label>
|
|
197
|
+
<input type="url" id="op-ep-thumb" placeholder="https://example.com/thumb.jpg" />
|
|
198
|
+
</div>
|
|
199
|
+
<div class="op-field">
|
|
200
|
+
<label>Odysee Embed ID <span class="req">*</span></label>
|
|
201
|
+
<input type="text" id="op-ep-embed" placeholder="Paste the Odysee embed ID or URL path" />
|
|
202
|
+
<span class="op-field-err" id="err-embed"></span>
|
|
203
|
+
<span class="op-field-hint">From: odysee.com/$/embed/<strong>THIS_PART</strong></span>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
<div class="op-form-actions">
|
|
207
|
+
<button id="op-add-btn" class="op-btn op-btn-primary">+ Add Episode</button>
|
|
208
|
+
<span id="op-add-msg" class="op-msg"></span>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<!-- EPISODE LIST -->
|
|
213
|
+
<div class="op-episode-list-wrap">
|
|
214
|
+
<h3>All Episodes <span class="op-count">(${state.episodes.length})</span></h3>
|
|
215
|
+
${state.episodes.length === 0 ?
|
|
216
|
+
<div class="op-no-eps">No episodes yet. Add your first episode above.</div>
|
|
217
|
+
[5/9/26 7:01β―PM] Mohammad: :
|
|
218
|
+
<div class="op-admin-list">
|
|
219
|
+
${state.episodes.map(ep =>
|
|
220
|
+
<div class="op-admin-ep-row" data-id="${ep.id}">
|
|
221
|
+
<div class="op-admin-ep-thumb">
|
|
222
|
+
<img src="${escHtml(ep.thumbnail || https://picsum.photos/seed/${ep.id}/80/45)}" alt="" />
|
|
223
|
+
</div>
|
|
224
|
+
<div class="op-admin-ep-info">
|
|
225
|
+
<span class="op-admin-ep-num">EP ${ep.episodeNumber}</span>
|
|
226
|
+
<strong>${escHtml(ep.title)}</strong>
|
|
227
|
+
${ep.description ? <p>${escHtml(ep.description.substring(0, 80))}${ep.description.length > 80 ? '...' : ''}</p> : ''}
|
|
228
|
+
<code class="op-embed-id">${escHtml(ep.embedId)}</code>
|
|
229
|
+
</div>
|
|
230
|
+
<div class="op-admin-ep-actions">
|
|
231
|
+
<button class="op-btn op-btn-danger op-delete-btn" data-id="${ep.id}">π Delete</button>
|
|
232
|
+
</div>
|
|
233
|
+
</div>
|
|
234
|
+
).join('')}
|
|
235
|
+
</div>
|
|
236
|
+
}
|
|
237
|
+
</div>
|
|
238
|
+
</div>
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
// Add episode
|
|
242
|
+
container.querySelector('#op-add-btn').addEventListener('click', async () => {
|
|
243
|
+
const num = container.querySelector('#op-ep-num').value
|
|
244
|
+
const title = container.querySelector('#op-ep-title').value
|
|
245
|
+
const desc = container.querySelector('#op-ep-desc').value
|
|
246
|
+
const thumb = container.querySelector('#op-ep-thumb').value
|
|
247
|
+
const embed = container.querySelector('#op-ep-embed').value
|
|
248
|
+
|
|
249
|
+
// Clear errors
|
|
250
|
+
container.querySelectorAll('.op-field-err').forEach(e => (e.textContent = ''))
|
|
251
|
+
let valid = true
|
|
252
|
+
|
|
253
|
+
if (!num isNaN(Number(num)) Number(num) < 1) {
|
|
254
|
+
container.querySelector('#err-num').textContent = 'Valid episode number required'
|
|
255
|
+
valid = false
|
|
256
|
+
}
|
|
257
|
+
if (!title.trim()) {
|
|
258
|
+
container.querySelector('#err-title').textContent = 'Title is required'
|
|
259
|
+
valid = false
|
|
260
|
+
}
|
|
261
|
+
if (!embed.trim()) {
|
|
262
|
+
container.querySelector('#err-embed').textContent = 'Embed ID is required'
|
|
263
|
+
valid = false
|
|
264
|
+
}
|
|
265
|
+
if (!valid) return
|
|
266
|
+
|
|
267
|
+
const btn = container.querySelector('#op-add-btn')
|
|
268
|
+
btn.disabled = true
|
|
269
|
+
btn.textContent = 'Saving...'
|
|
270
|
+
|
|
271
|
+
const result = await apiPost('/episodes', {
|
|
272
|
+
episodeNumber: Number(num),
|
|
273
|
+
title,
|
|
274
|
+
description: desc,
|
|
275
|
+
thumbnail: thumb,
|
|
276
|
+
embedId: embed.trim()
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
btn.disabled = false
|
|
280
|
+
btn.textContent = '+ Add Episode'
|
|
281
|
+
|
|
282
|
+
const msg = container.querySelector('#op-add-msg')
|
|
283
|
+
if (result.error) {
|
|
284
|
+
msg.textContent = 'β ' + result.error
|
|
285
|
+
msg.className = 'op-msg op-msg-error'
|
|
286
|
+
} else {
|
|
287
|
+
msg.textContent = 'β Episode added!'
|
|
288
|
+
msg.className = 'op-msg op-msg-success'
|
|
289
|
+
await loadEpisodes()
|
|
290
|
+
renderAdmin(container)
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
// Delete buttons
|
|
295
|
+
container.querySelectorAll('.op-delete-btn').forEach(btn => {
|
|
296
|
+
btn.addEventListener('click', async () => {
|
|
297
|
+
const id = Number(btn.dataset.id)
|
|
298
|
+
const ep = state.episodes.find(e => e.id === id)
|
|
299
|
+
if (!ep) return
|
|
300
|
+
if (!confirm(Delete Episode ${ep.episodeNumber}: "${ep.title}"?)) return
|
|
301
|
+
|
|
302
|
+
btn.disabled = true
|
|
303
|
+
btn.textContent = 'Deleting...'
|
|
304
|
+
|
|
305
|
+
const result = await apiDelete(/episodes/${id})
|
|
306
|
+
if (result.success) {
|
|
307
|
+
if (state.currentEpisodeId === id) state.currentEpisodeId = null
|
|
308
|
+
await loadEpisodes()
|
|
309
|
+
renderAdmin(container)
|
|
310
|
+
} else {
|
|
311
|
+
btn.disabled = false
|
|
312
|
+
btn.textContent = 'π Delete'
|
|
313
|
+
alert('Failed to delete: ' + (result.error || 'Unknown error'))
|
|
314
|
+
}
|
|
315
|
+
})
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// βββ Utility ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
320
|
+
function escHtml (str) {
|
|
321
|
+
return String(str)
|
|
322
|
+
.replace(/&/g, '&')
|
|
323
|
+
.replace(/</g, '<')
|
|
324
|
+
.replace(/>/g, '>')
|
|
325
|
+
.replace(/"/g, '"')
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// βββ Plugin Registration ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
329
|
+
window.registerPlugin = async function (args) {
|
|
330
|
+
const { registerHook, peertubeHelpers } = args
|
|
331
|
+
[5/9/26 7:01β―PM] Mohammad: // ββ Admin Settings Page ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
332
|
+
registerHook({
|
|
333
|
+
target: 'action:admin-plugin-settings.init',
|
|
334
|
+
handler: async ({ setting }) => {
|
|
335
|
+
if (setting.npmName !== PLUGIN_NAME) return
|
|
336
|
+
state.isAdmin = true
|
|
337
|
+
|
|
338
|
+
const container = document.createElement('div')
|
|
339
|
+
container.id = 'odysee-admin-root'
|
|
340
|
+
|
|
341
|
+
const settingsEl = document.querySelector('.plugin-settings')
|
|
342
|
+
if (settingsEl) {
|
|
343
|
+
settingsEl.appendChild(container)
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
await loadEpisodes()
|
|
347
|
+
|
|
348
|
+
// Tabs
|
|
349
|
+
const tabWrap = document.createElement('div')
|
|
350
|
+
tabWrap.className = 'op-tabs'
|
|
351
|
+
tabWrap.innerHTML =
|
|
352
|
+
<button class="op-tab active" data-tab="manage">β Manage Episodes</button>
|
|
353
|
+
<button class="op-tab" data-tab="preview">βΆ Preview Player</button>
|
|
354
|
+
|
|
355
|
+
container.appendChild(tabWrap)
|
|
356
|
+
|
|
357
|
+
const content = document.createElement('div')
|
|
358
|
+
content.id = 'op-tab-content'
|
|
359
|
+
container.appendChild(content)
|
|
360
|
+
|
|
361
|
+
renderAdmin(content)
|
|
362
|
+
|
|
363
|
+
tabWrap.querySelectorAll('.op-tab').forEach(tab => {
|
|
364
|
+
tab.addEventListener('click', async () => {
|
|
365
|
+
tabWrap.querySelectorAll('.op-tab').forEach(t => t.classList.remove('active'))
|
|
366
|
+
tab.classList.add('active')
|
|
367
|
+
await loadEpisodes()
|
|
368
|
+
if (tab.dataset.tab === 'manage') renderAdmin(content)
|
|
369
|
+
else renderPlayer(content)
|
|
370
|
+
})
|
|
371
|
+
})
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
}
|
package/client/style.css
ADDED
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
[5/9/26 7:04β―PM] Mohammad: /* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
2
|
+
Odysee Player Plugin β Styles
|
|
3
|
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
4
|
+
|
|
5
|
+
/* ββ Tabs βββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
6
|
+
.op-tabs {
|
|
7
|
+
display: flex;
|
|
8
|
+
gap: 8px;
|
|
9
|
+
margin: 24px 0 0;
|
|
10
|
+
border-bottom: 2px solid #2a2a3a;
|
|
11
|
+
padding-bottom: 0;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
.op-tab {
|
|
15
|
+
background: none;
|
|
16
|
+
border: none;
|
|
17
|
+
border-bottom: 3px solid transparent;
|
|
18
|
+
color: #aaa;
|
|
19
|
+
cursor: pointer;
|
|
20
|
+
font-size: 14px;
|
|
21
|
+
font-weight: 600;
|
|
22
|
+
letter-spacing: 0.5px;
|
|
23
|
+
margin-bottom: -2px;
|
|
24
|
+
padding: 10px 20px;
|
|
25
|
+
transition: all 0.2s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.op-tab:hover {
|
|
29
|
+
color: #fff;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.op-tab.active {
|
|
33
|
+
border-bottom-color: #f5a623;
|
|
34
|
+
color: #f5a623;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* ββ Admin Wrap βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
38
|
+
.op-admin-wrap {
|
|
39
|
+
padding: 20px 0;
|
|
40
|
+
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
.op-admin-header h2 {
|
|
44
|
+
color: #f5f5f5;
|
|
45
|
+
font-size: 20px;
|
|
46
|
+
font-weight: 700;
|
|
47
|
+
margin: 0 0 4px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.op-admin-sub {
|
|
51
|
+
color: #888;
|
|
52
|
+
font-size: 13px;
|
|
53
|
+
margin: 0 0 24px;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/* ββ Add Form βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
57
|
+
.op-add-form {
|
|
58
|
+
background: #1a1a2e;
|
|
59
|
+
border: 1px solid #2a2a4a;
|
|
60
|
+
border-radius: 10px;
|
|
61
|
+
padding: 20px;
|
|
62
|
+
margin-bottom: 28px;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
.op-add-form h3 {
|
|
66
|
+
color: #e0e0e0;
|
|
67
|
+
font-size: 15px;
|
|
68
|
+
font-weight: 600;
|
|
69
|
+
margin: 0 0 16px;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.op-form-grid {
|
|
73
|
+
display: grid;
|
|
74
|
+
grid-template-columns: 1fr 1fr;
|
|
75
|
+
gap: 14px;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
.op-field-full {
|
|
79
|
+
grid-column: 1 / -1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.op-field {
|
|
83
|
+
display: flex;
|
|
84
|
+
flex-direction: column;
|
|
85
|
+
gap: 5px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.op-field label {
|
|
89
|
+
color: #bbb;
|
|
90
|
+
font-size: 12px;
|
|
91
|
+
font-weight: 600;
|
|
92
|
+
letter-spacing: 0.5px;
|
|
93
|
+
text-transform: uppercase;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
.op-field input,
|
|
97
|
+
.op-field textarea {
|
|
98
|
+
background: #0f0f1e;
|
|
99
|
+
border: 1px solid #333;
|
|
100
|
+
border-radius: 6px;
|
|
101
|
+
color: #f0f0f0;
|
|
102
|
+
font-size: 13px;
|
|
103
|
+
padding: 9px 12px;
|
|
104
|
+
transition: border-color 0.2s;
|
|
105
|
+
width: 100%;
|
|
106
|
+
box-sizing: border-box;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.op-field input:focus,
|
|
110
|
+
.op-field textarea:focus {
|
|
111
|
+
border-color: #f5a623;
|
|
112
|
+
outline: none;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.op-field-err {
|
|
116
|
+
color: #ff5555;
|
|
117
|
+
font-size: 11px;
|
|
118
|
+
min-height: 14px;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
.op-field-hint {
|
|
122
|
+
color: #666;
|
|
123
|
+
font-size: 11px;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
.req {
|
|
127
|
+
color: #f5a623;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.op-form-actions {
|
|
131
|
+
align-items: center;
|
|
132
|
+
display: flex;
|
|
133
|
+
gap: 14px;
|
|
134
|
+
margin-top: 18px;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/* ββ Buttons ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
138
|
+
.op-btn {
|
|
139
|
+
border: none;
|
|
140
|
+
border-radius: 6px;
|
|
141
|
+
cursor: pointer;
|
|
142
|
+
font-size: 13px;
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
padding: 9px 18px;
|
|
145
|
+
transition: all 0.2s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.op-btn-primary {
|
|
149
|
+
background: #f5a623;
|
|
150
|
+
color: #0f0f1e;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
.op-btn-primary:hover:not(:disabled) {
|
|
154
|
+
background: #ffb84d;
|
|
155
|
+
transform: translateY(-1px);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
.op-btn-primary:disabled {
|
|
159
|
+
background: #7a5310;
|
|
160
|
+
color: #555;
|
|
161
|
+
cursor: not-allowed;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.op-btn-danger {
|
|
165
|
+
background: rgba(220, 53, 69, 0.15);
|
|
166
|
+
border: 1px solid rgba(220, 53, 69, 0.4);
|
|
167
|
+
color: #ff6b7a;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
.op-btn-danger:hover:not(:disabled) {
|
|
171
|
+
background: rgba(220, 53, 69, 0.3);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/* ββ Messages βββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
175
|
+
.op-msg {
|
|
176
|
+
font-size: 13px;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.op-msg-success {
|
|
180
|
+
color: #4caf50;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.op-msg-error {
|
|
184
|
+
color: #ff5555;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/* ββ Episode List (Admin) βββββββββββββββββββββββββββββββββββββββ */
|
|
188
|
+
.op-episode-list-wrap h3 {
|
|
189
|
+
color: #e0e0e0;
|
|
190
|
+
font-size: 15px;
|
|
191
|
+
font-weight: 600;
|
|
192
|
+
margin: 0 0 14px;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.op-count {
|
|
196
|
+
color: #888;
|
|
197
|
+
font-size: 13px;
|
|
198
|
+
font-weight: 400;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.op-no-eps {
|
|
202
|
+
background: #1a1a2e;
|
|
203
|
+
border: 1px dashed #2a2a4a;
|
|
204
|
+
border-radius: 8px;
|
|
205
|
+
color: #666;
|
|
206
|
+
font-size: 13px;
|
|
207
|
+
padding: 24px;
|
|
208
|
+
text-align: center;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.op-admin-list {
|
|
212
|
+
display: flex;
|
|
213
|
+
flex-direction: column;
|
|
214
|
+
gap: 10px;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.op-admin-ep-row {
|
|
218
|
+
align-items: center;
|
|
219
|
+
background: #1a1a2e;
|
|
220
|
+
border: 1px solid #2a2a4a;
|
|
221
|
+
border-radius: 8px;
|
|
222
|
+
display: flex;
|
|
223
|
+
gap: 14px;
|
|
224
|
+
padding: 12px 16px;
|
|
225
|
+
transition: border-color 0.2s;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
.op-admin-ep-row:hover {
|
|
229
|
+
border-color: #3a3a5a;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.op-admin-ep-thumb {
|
|
233
|
+
flex-shrink: 0;
|
|
234
|
+
height: 45px;
|
|
235
|
+
width: 80px;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.op-admin-ep-thumb img {
|
|
239
|
+
border-radius: 4px;
|
|
240
|
+
height: 100%;
|
|
241
|
+
object-fit: cover;
|
|
242
|
+
width: 100%;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
.op-admin-ep-info {
|
|
246
|
+
flex: 1;
|
|
247
|
+
min-width: 0;
|
|
248
|
+
}
|
|
249
|
+
[5/9/26 7:04β―PM] Mohammad: .op-admin-ep-num {
|
|
250
|
+
background: rgba(245, 166, 35, 0.15);
|
|
251
|
+
border-radius: 4px;
|
|
252
|
+
color: #f5a623;
|
|
253
|
+
font-size: 11px;
|
|
254
|
+
font-weight: 700;
|
|
255
|
+
letter-spacing: 0.5px;
|
|
256
|
+
padding: 2px 6px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.op-admin-ep-info strong {
|
|
260
|
+
color: #e0e0e0;
|
|
261
|
+
display: block;
|
|
262
|
+
font-size: 14px;
|
|
263
|
+
margin: 5px 0 2px;
|
|
264
|
+
white-space: nowrap;
|
|
265
|
+
overflow: hidden;
|
|
266
|
+
text-overflow: ellipsis;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
.op-admin-ep-info p {
|
|
270
|
+
color: #888;
|
|
271
|
+
font-size: 12px;
|
|
272
|
+
margin: 0 0 4px;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.op-embed-id {
|
|
276
|
+
background: #0f0f1e;
|
|
277
|
+
border-radius: 3px;
|
|
278
|
+
color: #5599ff;
|
|
279
|
+
font-size: 11px;
|
|
280
|
+
padding: 2px 6px;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.op-admin-ep-actions {
|
|
284
|
+
flex-shrink: 0;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
288
|
+
PUBLIC PLAYER
|
|
289
|
+
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
290
|
+
.odysee-player-wrap {
|
|
291
|
+
font-family: 'Segoe UI', system-ui, sans-serif;
|
|
292
|
+
max-width: 100%;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/* ββ Video Frame ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
296
|
+
.op-video-section {
|
|
297
|
+
margin-bottom: 28px;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
.op-video-frame-wrap {
|
|
301
|
+
aspect-ratio: 16 / 9;
|
|
302
|
+
background: #000;
|
|
303
|
+
border-radius: 10px;
|
|
304
|
+
overflow: hidden;
|
|
305
|
+
position: relative;
|
|
306
|
+
width: 100%;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
.op-video-frame-wrap iframe {
|
|
310
|
+
height: 100%;
|
|
311
|
+
left: 0;
|
|
312
|
+
position: absolute;
|
|
313
|
+
top: 0;
|
|
314
|
+
width: 100%;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
.op-video-meta {
|
|
318
|
+
padding: 16px 4px 0;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
.op-episode-badge {
|
|
322
|
+
background: rgba(245, 166, 35, 0.15);
|
|
323
|
+
border: 1px solid rgba(245, 166, 35, 0.3);
|
|
324
|
+
border-radius: 20px;
|
|
325
|
+
color: #f5a623;
|
|
326
|
+
font-size: 12px;
|
|
327
|
+
font-weight: 700;
|
|
328
|
+
letter-spacing: 0.5px;
|
|
329
|
+
padding: 3px 10px;
|
|
330
|
+
text-transform: uppercase;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
.op-video-title {
|
|
334
|
+
color: #f0f0f0;
|
|
335
|
+
font-size: 20px;
|
|
336
|
+
font-weight: 700;
|
|
337
|
+
line-height: 1.3;
|
|
338
|
+
margin: 10px 0 8px;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
.op-video-desc {
|
|
342
|
+
color: #999;
|
|
343
|
+
font-size: 14px;
|
|
344
|
+
line-height: 1.6;
|
|
345
|
+
margin: 0;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/* ββ Episodes Section βββββββββββββββββββββββββββββββββββββββββββ */
|
|
349
|
+
.op-episodes-section {
|
|
350
|
+
margin-top: 8px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.op-section-label {
|
|
354
|
+
color: #ccc;
|
|
355
|
+
font-size: 14px;
|
|
356
|
+
font-weight: 700;
|
|
357
|
+
letter-spacing: 1px;
|
|
358
|
+
margin: 0 0 14px;
|
|
359
|
+
text-transform: uppercase;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.op-episode-grid {
|
|
363
|
+
display: grid;
|
|
364
|
+
gap: 14px;
|
|
365
|
+
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ββ Episode Card βββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
369
|
+
.op-episode-card {
|
|
370
|
+
background: #1a1a2e;
|
|
371
|
+
border: 2px solid transparent;
|
|
372
|
+
border-radius: 8px;
|
|
373
|
+
cursor: pointer;
|
|
374
|
+
overflow: hidden;
|
|
375
|
+
transition: all 0.2s;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.op-episode-card:hover {
|
|
379
|
+
border-color: rgba(245, 166, 35, 0.4);
|
|
380
|
+
transform: translateY(-2px);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
.op-episode-card.is-active {
|
|
384
|
+
border-color: #f5a623;
|
|
385
|
+
box-shadow: 0 0 0 3px rgba(245, 166, 35, 0.15), 0 4px 20px rgba(245, 166, 35, 0.2);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.op-card-thumb {
|
|
389
|
+
aspect-ratio: 16/9;
|
|
390
|
+
position: relative;
|
|
391
|
+
overflow: hidden;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.op-card-thumb img {
|
|
395
|
+
height: 100%;
|
|
396
|
+
object-fit: cover;
|
|
397
|
+
transition: transform 0.3s;
|
|
398
|
+
width: 100%;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.op-episode-card:hover .op-card-thumb img {
|
|
402
|
+
transform: scale(1.05);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
.op-card-overlay {
|
|
406
|
+
align-items: center;
|
|
407
|
+
background: rgba(0, 0, 0, 0.4);
|
|
408
|
+
bottom: 0;
|
|
409
|
+
display: flex;
|
|
410
|
+
justify-content: center;
|
|
411
|
+
left: 0;
|
|
412
|
+
opacity: 0;
|
|
413
|
+
position: absolute;
|
|
414
|
+
right: 0;
|
|
415
|
+
top: 0;
|
|
416
|
+
transition: opacity 0.2s;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
.op-episode-card:hover .op-card-overlay,
|
|
420
|
+
.op-episode-card.is-active .op-card-overlay {
|
|
421
|
+
opacity: 1;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.op-play-icon {
|
|
425
|
+
color: #fff;
|
|
426
|
+
font-size: 24px;
|
|
427
|
+
text-shadow: 0 2px 8px rgba(0,0,0,0.5);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.op-now-playing {
|
|
431
|
+
background: #f5a623;
|
|
432
|
+
bottom: 6px;
|
|
433
|
+
color: #0f0f1e;
|
|
434
|
+
font-size: 9px;
|
|
435
|
+
font-weight: 800;
|
|
436
|
+
left: 6px;
|
|
437
|
+
letter-spacing: 0.8px;
|
|
438
|
+
padding: 2px 6px;
|
|
439
|
+
position: absolute;
|
|
440
|
+
border-radius: 3px;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
.op-card-body {
|
|
444
|
+
padding: 8px 10px 10px;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
.op-ep-num {
|
|
448
|
+
color: #f5a623;
|
|
449
|
+
font-size: 11px;
|
|
450
|
+
font-weight: 700;
|
|
451
|
+
letter-spacing: 0.5px;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
.op-card-title {
|
|
455
|
+
color: #ddd;
|
|
456
|
+
font-size: 13px;
|
|
457
|
+
font-weight: 600;
|
|
458
|
+
line-height: 1.3;
|
|
459
|
+
margin: 4px 0 0;
|
|
460
|
+
overflow: hidden;
|
|
461
|
+
display: -webkit-box;
|
|
462
|
+
-webkit-line-clamp: 2;
|
|
463
|
+
-webkit-box-orient: vertical;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* ββ Pagination βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
467
|
+
.op-pagination {
|
|
468
|
+
align-items: center;
|
|
469
|
+
display: flex;
|
|
470
|
+
gap: 10px;
|
|
471
|
+
justify-content: center;
|
|
472
|
+
margin-top: 20px;
|
|
473
|
+
}
|
|
474
|
+
[5/9/26 7:04β―PM] Mohammad: .op-page-btn {
|
|
475
|
+
background: #1a1a2e;
|
|
476
|
+
border: 1px solid #2a2a4a;
|
|
477
|
+
border-radius: 6px;
|
|
478
|
+
color: #ccc;
|
|
479
|
+
cursor: pointer;
|
|
480
|
+
font-size: 13px;
|
|
481
|
+
font-weight: 600;
|
|
482
|
+
padding: 7px 14px;
|
|
483
|
+
transition: all 0.2s;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
.op-page-btn:hover:not(:disabled) {
|
|
487
|
+
background: #2a2a4a;
|
|
488
|
+
color: #fff;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
.op-page-btn:disabled {
|
|
492
|
+
color: #444;
|
|
493
|
+
cursor: not-allowed;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
.op-page-numbers {
|
|
497
|
+
display: flex;
|
|
498
|
+
gap: 6px;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
.op-page-num {
|
|
502
|
+
background: #1a1a2e;
|
|
503
|
+
border: 1px solid #2a2a4a;
|
|
504
|
+
border-radius: 6px;
|
|
505
|
+
color: #aaa;
|
|
506
|
+
cursor: pointer;
|
|
507
|
+
font-size: 13px;
|
|
508
|
+
font-weight: 600;
|
|
509
|
+
height: 34px;
|
|
510
|
+
transition: all 0.2s;
|
|
511
|
+
width: 34px;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
.op-page-num:hover {
|
|
515
|
+
background: #2a2a4a;
|
|
516
|
+
color: #fff;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
.op-page-num.active {
|
|
520
|
+
background: #f5a623;
|
|
521
|
+
border-color: #f5a623;
|
|
522
|
+
color: #0f0f1e;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/* ββ Empty State ββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
526
|
+
.op-empty-state {
|
|
527
|
+
background: #1a1a2e;
|
|
528
|
+
border: 1px dashed #2a2a4a;
|
|
529
|
+
border-radius: 10px;
|
|
530
|
+
color: #888;
|
|
531
|
+
font-size: 14px;
|
|
532
|
+
margin-bottom: 24px;
|
|
533
|
+
padding: 40px;
|
|
534
|
+
text-align: center;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
.op-empty-icon {
|
|
538
|
+
font-size: 48px;
|
|
539
|
+
margin-bottom: 12px;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/* ββ Responsive βββββββββββββββββββββββββββββββββββββββββββββββββ */
|
|
543
|
+
@media (max-width: 640px) {
|
|
544
|
+
.op-form-grid {
|
|
545
|
+
grid-template-columns: 1fr;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.op-episode-grid {
|
|
549
|
+
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.op-admin-ep-row {
|
|
553
|
+
flex-wrap: wrap;
|
|
554
|
+
}
|
|
555
|
+
}
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "peertube-plugin-odysee-player",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Embed and manage unlisted Odysee videos with episode management inside PeerTube admin UI",
|
|
5
|
+
"engine": {
|
|
6
|
+
"peertube": ">=5.0.0"
|
|
7
|
+
},
|
|
8
|
+
"keywords": [
|
|
9
|
+
"peertube",
|
|
10
|
+
"plugin",
|
|
11
|
+
"odysee",
|
|
12
|
+
"embed",
|
|
13
|
+
"episodes"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "",
|
|
16
|
+
"author": "",
|
|
17
|
+
"bugs": "",
|
|
18
|
+
"library": "./client/client-plugin.js",
|
|
19
|
+
"staticDirs": {},
|
|
20
|
+
"css": [
|
|
21
|
+
"client/styles.css"
|
|
22
|
+
],
|
|
23
|
+
"clientScripts": [
|
|
24
|
+
{
|
|
25
|
+
"script": "client/client-plugin.js",
|
|
26
|
+
"scopes": [
|
|
27
|
+
"admin-plugin-settings",
|
|
28
|
+
"common"
|
|
29
|
+
]
|
|
30
|
+
}
|
|
31
|
+
],
|
|
32
|
+
"translations": {},
|
|
33
|
+
"settings": [],
|
|
34
|
+
"storage": {}
|
|
35
|
+
}
|
package/server/plugin.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
async function register ({ registerSetting, settingsManager, storageManager, peertubeHelpers, getRouter }) {
|
|
2
|
+
const router = getRouter()
|
|
3
|
+
|
|
4
|
+
// βββ GET all episodes βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
5
|
+
router.get('/episodes', async (req, res) => {
|
|
6
|
+
try {
|
|
7
|
+
const raw = await storageManager.getData('episodes')
|
|
8
|
+
const episodes = raw ? JSON.parse(raw) : []
|
|
9
|
+
return res.json({ episodes })
|
|
10
|
+
} catch (e) {
|
|
11
|
+
return res.status(500).json({ error: 'Failed to load episodes' })
|
|
12
|
+
}
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
// βββ POST add episode βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
16
|
+
router.post('/episodes', async (req, res) => {
|
|
17
|
+
try {
|
|
18
|
+
const { episodeNumber, title, description, thumbnail, embedId } = req.body
|
|
19
|
+
|
|
20
|
+
if (!episodeNumber !title !embedId) {
|
|
21
|
+
return res.status(400).json({ error: 'episodeNumber, title, and embedId are required' })
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const raw = await storageManager.getData('episodes')
|
|
25
|
+
const episodes = raw ? JSON.parse(raw) : []
|
|
26
|
+
|
|
27
|
+
const exists = episodes.find(e => e.episodeNumber === Number(episodeNumber))
|
|
28
|
+
if (exists) {
|
|
29
|
+
return res.status(409).json({ error: Episode number ${episodeNumber} already exists })
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const newEpisode = {
|
|
33
|
+
id: Date.now(),
|
|
34
|
+
episodeNumber: Number(episodeNumber),
|
|
35
|
+
title: title.trim(),
|
|
36
|
+
description: description ? description.trim() : '',
|
|
37
|
+
thumbnail: thumbnail ? thumbnail.trim() : '',
|
|
38
|
+
embedId: embedId.trim(),
|
|
39
|
+
createdAt: new Date().toISOString()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
episodes.push(newEpisode)
|
|
43
|
+
episodes.sort((a, b) => a.episodeNumber - b.episodeNumber)
|
|
44
|
+
|
|
45
|
+
await storageManager.storeData('episodes', JSON.stringify(episodes))
|
|
46
|
+
return res.json({ episode: newEpisode })
|
|
47
|
+
} catch (e) {
|
|
48
|
+
return res.status(500).json({ error: 'Failed to save episode' })
|
|
49
|
+
}
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
// βββ DELETE episode βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
53
|
+
router.delete('/episodes/:id', async (req, res) => {
|
|
54
|
+
try {
|
|
55
|
+
const id = Number(req.params.id)
|
|
56
|
+
const raw = await storageManager.getData('episodes')
|
|
57
|
+
let episodes = raw ? JSON.parse(raw) : []
|
|
58
|
+
|
|
59
|
+
const before = episodes.length
|
|
60
|
+
episodes = episodes.filter(e => e.id !== id)
|
|
61
|
+
|
|
62
|
+
if (episodes.length === before) {
|
|
63
|
+
return res.status(404).json({ error: 'Episode not found' })
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await storageManager.storeData('episodes', JSON.stringify(episodes))
|
|
67
|
+
return res.json({ success: true })
|
|
68
|
+
} catch (e) {
|
|
69
|
+
return res.status(500).json({ error: 'Failed to delete episode' })
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function unregister () {}
|
|
75
|
+
|
|
76
|
+
module.exports = { register, unregister }
|