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 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
+ &#8592; 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 &#8594;
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, '&amp;')
323
+ .replace(/</g, '&lt;')
324
+ .replace(/>/g, '&gt;')
325
+ .replace(/"/g, '&quot;')
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
+ }
@@ -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
@@ -0,0 +1,6 @@
1
+ // PeerTube requires this file as the plugin entry point
2
+ // It re-exports the register/unregister from server/plugin.js
3
+
4
+ const { register, unregister } = require('./server/plugin')
5
+
6
+ module.exports = { register, unregister }
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
+ }
@@ -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 }