@xiboplayer/core 0.1.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/public/sw.js ADDED
@@ -0,0 +1,218 @@
1
+ /**
2
+ * Service Worker for offline cache
3
+ */
4
+
5
+ const CACHE_VERSION = 'v1';
6
+ const STATIC_CACHE = `xibo-static-${CACHE_VERSION}`;
7
+
8
+ /**
9
+ * Handle Range requests for video/audio seeking
10
+ * Required for HTML5 video element to support seeking/scrubbing
11
+ * For streaming files, downloads chunks on-demand
12
+ */
13
+ async function handleRangeRequest(cachedResponse, rangeHeader, cacheKey, originalUrl) {
14
+ const blob = await cachedResponse.blob();
15
+ const fileSize = blob.size;
16
+
17
+ // Parse Range header: "bytes=START-END" or "bytes=START-"
18
+ const rangeParts = rangeHeader.replace(/bytes=/, '').split('-');
19
+ const start = parseInt(rangeParts[0], 10);
20
+ const end = rangeParts[1] ? parseInt(rangeParts[1], 10) : fileSize - 1;
21
+
22
+ // Extract requested range from blob
23
+ const rangeBlob = blob.slice(start, end + 1);
24
+
25
+ return new Response(rangeBlob, {
26
+ status: 206, // Partial Content
27
+ statusText: 'Partial Content',
28
+ headers: {
29
+ 'Content-Type': cachedResponse.headers.get('Content-Type') || 'video/mp4',
30
+ 'Content-Length': rangeBlob.size,
31
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
32
+ 'Accept-Ranges': 'bytes',
33
+ 'Access-Control-Allow-Origin': '*'
34
+ }
35
+ });
36
+ }
37
+
38
+ /**
39
+ * Fetch and cache a chunk from the original URL on-demand
40
+ * Used for streaming large video files
41
+ */
42
+ async function fetchAndCacheChunk(originalUrl, rangeHeader, cacheKey, cache) {
43
+ console.log('[SW] Fetching chunk on-demand:', rangeHeader);
44
+
45
+ const response = await fetch(originalUrl, {
46
+ headers: { 'Range': rangeHeader }
47
+ });
48
+
49
+ if (!response.ok && response.status !== 206) {
50
+ throw new Error(`Failed to fetch chunk: ${response.status}`);
51
+ }
52
+
53
+ // Cache this chunk for future requests
54
+ const responseClone = response.clone();
55
+ await cache.put(cacheKey + '#' + rangeHeader, responseClone);
56
+
57
+ return response;
58
+ }
59
+
60
+ // Files to cache on install (with /player/ prefix)
61
+ const STATIC_FILES = [
62
+ '/player/',
63
+ '/player/index.html',
64
+ '/player/setup.html',
65
+ '/player/manifest.json'
66
+ ];
67
+
68
+ // Install event - cache static files
69
+ self.addEventListener('install', (event) => {
70
+ console.log('[SW] Installing...');
71
+ event.waitUntil(
72
+ caches.open(STATIC_CACHE).then((cache) => {
73
+ console.log('[SW] Caching static files');
74
+ return cache.addAll(STATIC_FILES);
75
+ })
76
+ );
77
+ self.skipWaiting();
78
+ });
79
+
80
+ // Activate event - clean up old caches
81
+ self.addEventListener('activate', (event) => {
82
+ console.log('[SW] Activating...');
83
+ event.waitUntil(
84
+ caches.keys().then((cacheNames) => {
85
+ return Promise.all(
86
+ cacheNames
87
+ .filter((name) => name.startsWith('xibo-') && name !== STATIC_CACHE && name !== 'xibo-media-v1')
88
+ .map((name) => {
89
+ console.log('[SW] Deleting old cache:', name);
90
+ return caches.delete(name);
91
+ })
92
+ );
93
+ })
94
+ );
95
+ self.clients.claim();
96
+ });
97
+
98
+ // Fetch event - serve from cache, fallback to network
99
+ self.addEventListener('fetch', (event) => {
100
+ const url = new URL(event.request.url);
101
+
102
+ // IMPORTANT: Let XMDS downloads bypass Service Worker
103
+ // Large video files (2GB+) cause timeouts if intercepted
104
+ // The cache.js module handles downloads directly
105
+ if (url.pathname.includes('xmds.php') && url.searchParams.has('file')) {
106
+ // Don't intercept - let it go directly to network
107
+ return;
108
+ }
109
+
110
+ // Handle widget HTML requests (/player/cache/widget/*)
111
+ if (url.pathname.startsWith('/player/cache/widget/')) {
112
+ console.log('[SW] Widget HTML request:', url.pathname);
113
+ // Strip /player/ prefix to match cached keys
114
+ const cacheKey = url.pathname.replace('/player', '');
115
+ const cacheUrl = new URL(cacheKey, url.origin);
116
+ event.respondWith(
117
+ caches.open('xibo-media-v1').then((cache) => {
118
+ return cache.match(cacheUrl).then((response) => {
119
+ if (response) {
120
+ console.log('[SW] Serving widget HTML from cache:', cacheKey);
121
+ return new Response(response.body, {
122
+ headers: {
123
+ 'Content-Type': 'text/html; charset=utf-8',
124
+ 'Access-Control-Allow-Origin': '*',
125
+ 'Cache-Control': 'public, max-age=31536000'
126
+ }
127
+ });
128
+ }
129
+ console.warn('[SW] Widget HTML not found in cache:', cacheKey);
130
+ return new Response('<!DOCTYPE html><html><body>Widget not found</body></html>', {
131
+ status: 404,
132
+ headers: { 'Content-Type': 'text/html' }
133
+ });
134
+ });
135
+ })
136
+ );
137
+ return;
138
+ }
139
+
140
+ // Handle cache URLs (/player/cache/*)
141
+ if (url.pathname.startsWith('/player/cache/')) {
142
+ // Strip /player/ prefix to match cached keys
143
+ const cacheKey = url.pathname.replace('/player', '');
144
+ console.log('[SW] Request for:', url.pathname, '→ Cache key:', cacheKey);
145
+
146
+ event.respondWith(
147
+ caches.open('xibo-media-v1').then((cache) => {
148
+ return cache.match(cacheKey).then(async (response) => {
149
+ if (response) {
150
+ console.log('[SW] Serving from cache:', cacheKey);
151
+
152
+ // Handle Range requests for video/audio seeking
153
+ const rangeHeader = event.request.headers.get('Range');
154
+ if (rangeHeader) {
155
+ return handleRangeRequest(response, rangeHeader, cacheKey, event.request.url);
156
+ }
157
+
158
+ // Regular response
159
+ return new Response(response.body, {
160
+ status: 200,
161
+ statusText: 'OK',
162
+ headers: {
163
+ 'Content-Type': response.headers.get('Content-Type') || 'application/octet-stream',
164
+ 'Content-Length': response.headers.get('Content-Length') || '',
165
+ 'Accept-Ranges': 'bytes',
166
+ 'Access-Control-Allow-Origin': '*'
167
+ }
168
+ });
169
+ }
170
+
171
+ // Cache miss - might be background download in progress
172
+ // For media files, show a loading message instead of 404
173
+ console.warn('[SW] Cache miss for:', cacheKey, '(might be downloading in background)');
174
+
175
+ // Return a placeholder for videos being downloaded
176
+ if (cacheKey.includes('.mp4') || cacheKey.includes('.mov') || cacheKey.includes('.avi')) {
177
+ return new Response('Downloading video in background...', {
178
+ status: 202,
179
+ statusText: 'Accepted',
180
+ headers: { 'Content-Type': 'text/plain' }
181
+ });
182
+ }
183
+
184
+ return new Response('Not found', { status: 404 });
185
+ });
186
+ }).catch(err => {
187
+ console.error('[SW] Error serving:', cacheKey, err);
188
+ return new Response('Error: ' + err.message, { status: 500 });
189
+ })
190
+ );
191
+ return;
192
+ }
193
+
194
+ // For other requests, try cache first, then network
195
+ event.respondWith(
196
+ caches.match(event.request).then((response) => {
197
+ if (response) {
198
+ return response;
199
+ }
200
+ return fetch(event.request).then((networkResponse) => {
201
+ // Cache successful GET responses only (can't cache POST)
202
+ if (networkResponse.ok && event.request.method === 'GET') {
203
+ const responseClone = networkResponse.clone();
204
+ caches.open(STATIC_CACHE).then((cache) => {
205
+ cache.put(event.request, responseClone);
206
+ });
207
+ }
208
+ return networkResponse;
209
+ });
210
+ }).catch(() => {
211
+ // Network failed and not in cache
212
+ if (event.request.destination === 'document') {
213
+ return caches.match('/player/index.html');
214
+ }
215
+ return new Response('Offline', { status: 503 });
216
+ })
217
+ );
218
+ });
package/setup.html ADDED
@@ -0,0 +1,220 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Xibo Player Setup</title>
7
+ <style>
8
+ * {
9
+ margin: 0;
10
+ padding: 0;
11
+ box-sizing: border-box;
12
+ }
13
+
14
+ body {
15
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
16
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
17
+ min-height: 100vh;
18
+ display: flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ padding: 20px;
22
+ }
23
+
24
+ .container {
25
+ background: white;
26
+ border-radius: 12px;
27
+ box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
28
+ padding: 40px;
29
+ max-width: 500px;
30
+ width: 100%;
31
+ }
32
+
33
+ h1 {
34
+ margin-bottom: 10px;
35
+ color: #333;
36
+ font-size: 28px;
37
+ }
38
+
39
+ p {
40
+ color: #666;
41
+ margin-bottom: 30px;
42
+ line-height: 1.5;
43
+ }
44
+
45
+ .form-group {
46
+ margin-bottom: 20px;
47
+ }
48
+
49
+ label {
50
+ display: block;
51
+ margin-bottom: 8px;
52
+ color: #333;
53
+ font-weight: 500;
54
+ }
55
+
56
+ input {
57
+ width: 100%;
58
+ padding: 12px;
59
+ border: 2px solid #e1e8ed;
60
+ border-radius: 6px;
61
+ font-size: 16px;
62
+ transition: border-color 0.3s;
63
+ }
64
+
65
+ input:focus {
66
+ outline: none;
67
+ border-color: #667eea;
68
+ }
69
+
70
+ button {
71
+ width: 100%;
72
+ padding: 14px;
73
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
74
+ color: white;
75
+ border: none;
76
+ border-radius: 6px;
77
+ font-size: 16px;
78
+ font-weight: 600;
79
+ cursor: pointer;
80
+ transition: transform 0.2s, box-shadow 0.2s;
81
+ }
82
+
83
+ button:hover {
84
+ transform: translateY(-2px);
85
+ box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
86
+ }
87
+
88
+ button:active {
89
+ transform: translateY(0);
90
+ }
91
+
92
+ .info {
93
+ margin-top: 20px;
94
+ padding: 15px;
95
+ background: #f8f9fa;
96
+ border-radius: 6px;
97
+ font-size: 14px;
98
+ color: #666;
99
+ }
100
+
101
+ .info code {
102
+ background: white;
103
+ padding: 2px 6px;
104
+ border-radius: 3px;
105
+ font-family: monospace;
106
+ color: #e83e8c;
107
+ }
108
+
109
+ .error {
110
+ margin-top: 15px;
111
+ padding: 12px;
112
+ background: #fee;
113
+ border: 1px solid #fcc;
114
+ border-radius: 6px;
115
+ color: #c33;
116
+ display: none;
117
+ }
118
+ </style>
119
+ </head>
120
+ <body>
121
+ <div class="container">
122
+ <h1>Xibo Player Setup</h1>
123
+ <p>Configure your connection to the Xibo CMS.</p>
124
+
125
+ <form id="setup-form">
126
+ <div class="form-group">
127
+ <label for="cms-address">CMS Address</label>
128
+ <input
129
+ type="url"
130
+ id="cms-address"
131
+ placeholder="https://cms.example.com"
132
+ required
133
+ >
134
+ </div>
135
+
136
+ <div class="form-group">
137
+ <label for="cms-key">CMS Key</label>
138
+ <input
139
+ type="text"
140
+ id="cms-key"
141
+ placeholder="Your CMS secret key"
142
+ required
143
+ >
144
+ </div>
145
+
146
+ <div class="form-group">
147
+ <label for="display-name">Display Name</label>
148
+ <input
149
+ type="text"
150
+ id="display-name"
151
+ placeholder="My Display"
152
+ required
153
+ >
154
+ </div>
155
+
156
+ <button type="submit">Connect</button>
157
+ </form>
158
+
159
+ <div id="error" class="error"></div>
160
+ </div>
161
+
162
+ <script type="module">
163
+ import { config } from './src/config.js';
164
+ import { XmdsClient } from './src/xmds.js';
165
+
166
+ const form = document.getElementById('setup-form');
167
+ const errorEl = document.getElementById('error');
168
+
169
+ // Pre-fill if already configured
170
+ if (config.isConfigured()) {
171
+ document.getElementById('cms-address').value = config.cmsAddress;
172
+ document.getElementById('cms-key').value = config.cmsKey;
173
+ document.getElementById('display-name').value = config.displayName;
174
+ }
175
+
176
+ form.addEventListener('submit', async (e) => {
177
+ e.preventDefault();
178
+ errorEl.style.display = 'none';
179
+
180
+ const cmsAddress = document.getElementById('cms-address').value.trim().replace(/\/$/, '');
181
+ const cmsKey = document.getElementById('cms-key').value.trim();
182
+ const displayName = document.getElementById('display-name').value.trim();
183
+
184
+ // Save config
185
+ config.cmsAddress = cmsAddress;
186
+ config.cmsKey = cmsKey;
187
+ config.displayName = displayName;
188
+
189
+ // Test connection
190
+ try {
191
+ const button = form.querySelector('button');
192
+ button.textContent = 'Connecting...';
193
+ button.disabled = true;
194
+
195
+ const xmds = new XmdsClient(config);
196
+ const result = await xmds.registerDisplay();
197
+
198
+ if (result.code === 'READY') {
199
+ // Success!
200
+ window.location.href = '/player/';
201
+ } else {
202
+ // Not yet authorized
203
+ errorEl.textContent = `Display registered but not authorized yet. Please authorize "${displayName}" in your CMS, then refresh this page.`;
204
+ errorEl.style.display = 'block';
205
+ }
206
+
207
+ button.textContent = 'Connect';
208
+ button.disabled = false;
209
+ } catch (error) {
210
+ errorEl.textContent = `Connection failed: ${error.message}`;
211
+ errorEl.style.display = 'block';
212
+
213
+ const button = form.querySelector('button');
214
+ button.textContent = 'Connect';
215
+ button.disabled = false;
216
+ }
217
+ });
218
+ </script>
219
+ </body>
220
+ </html>
@@ -0,0 +1,198 @@
1
+ /**
2
+ * DataConnectorManager - Manages real-time data connectors from CMS
3
+ *
4
+ * Data connectors allow widgets to receive real-time data from CMS-configured
5
+ * data sources. The CMS sends data connector configuration via the schedule XML,
6
+ * and this manager periodically polls the data source URLs, stores the data,
7
+ * and emits events so the IC /realtime route can serve it to widgets.
8
+ *
9
+ * Usage:
10
+ * const manager = new DataConnectorManager();
11
+ * manager.setConnectors(schedule.dataConnectors);
12
+ * manager.startPolling();
13
+ *
14
+ * // Get data for a widget
15
+ * const data = manager.getData('weather_data');
16
+ *
17
+ * // Listen for updates
18
+ * manager.on('data-updated', (dataKey, data) => { ... });
19
+ */
20
+
21
+ import { EventEmitter, createLogger, fetchWithRetry } from '@xiboplayer/utils';
22
+
23
+ const log = createLogger('DataConnector');
24
+
25
+ export class DataConnectorManager extends EventEmitter {
26
+ constructor() {
27
+ super();
28
+
29
+ // dataKey -> { config, data, timer, lastFetch }
30
+ this.connectors = new Map();
31
+ }
32
+
33
+ /**
34
+ * Set active connectors from schedule
35
+ * Stops any existing polling and reconfigures with new connector list.
36
+ * @param {Array} connectors - Array of connector config objects from schedule XML
37
+ * Each: { id, dataConnectorId, dataKey, url, updateInterval }
38
+ */
39
+ setConnectors(connectors) {
40
+ // Stop existing polling before reconfiguring
41
+ this.stopPolling();
42
+
43
+ // Clear previous connectors
44
+ this.connectors.clear();
45
+
46
+ if (!connectors || connectors.length === 0) {
47
+ log.debug('No data connectors configured');
48
+ return;
49
+ }
50
+
51
+ for (const connector of connectors) {
52
+ if (!connector.dataKey || !connector.url) {
53
+ log.warn('Skipping data connector with missing dataKey or url:', connector);
54
+ continue;
55
+ }
56
+
57
+ this.connectors.set(connector.dataKey, {
58
+ config: connector,
59
+ data: null,
60
+ timer: null,
61
+ lastFetch: null
62
+ });
63
+
64
+ log.info(`Registered data connector: ${connector.dataKey} (interval: ${connector.updateInterval}s)`);
65
+ }
66
+
67
+ log.info(`${this.connectors.size} data connector(s) configured`);
68
+ }
69
+
70
+ /**
71
+ * Start polling for all active connectors
72
+ * Performs an initial fetch immediately, then sets up periodic polling.
73
+ */
74
+ startPolling() {
75
+ for (const [dataKey, entry] of this.connectors.entries()) {
76
+ const { config } = entry;
77
+ const intervalMs = (config.updateInterval || 300) * 1000;
78
+
79
+ // Fetch immediately on start
80
+ this.fetchData(entry).catch(err => {
81
+ log.error(`Initial fetch failed for ${dataKey}:`, err);
82
+ });
83
+
84
+ // Set up periodic polling
85
+ entry.timer = setInterval(() => {
86
+ this.fetchData(entry).catch(err => {
87
+ log.error(`Polling fetch failed for ${dataKey}:`, err);
88
+ });
89
+ }, intervalMs);
90
+
91
+ log.debug(`Started polling for ${dataKey} every ${config.updateInterval}s`);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Stop all polling timers
97
+ */
98
+ stopPolling() {
99
+ for (const [dataKey, entry] of this.connectors.entries()) {
100
+ if (entry.timer) {
101
+ clearInterval(entry.timer);
102
+ entry.timer = null;
103
+ log.debug(`Stopped polling for ${dataKey}`);
104
+ }
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Get current data for a dataKey
110
+ * @param {string} dataKey - The data key to look up
111
+ * @returns {Object|null} The stored data, or null if not available
112
+ */
113
+ getData(dataKey) {
114
+ const entry = this.connectors.get(dataKey);
115
+ if (!entry) {
116
+ log.debug(`No data connector found for key: ${dataKey}`);
117
+ return null;
118
+ }
119
+ return entry.data;
120
+ }
121
+
122
+ /**
123
+ * Get all data keys that have data available
124
+ * @returns {string[]} Array of data keys with data
125
+ */
126
+ getAvailableKeys() {
127
+ const keys = [];
128
+ for (const [dataKey, entry] of this.connectors.entries()) {
129
+ if (entry.data !== null) {
130
+ keys.push(dataKey);
131
+ }
132
+ }
133
+ return keys;
134
+ }
135
+
136
+ /**
137
+ * Internal: fetch data from CMS data source
138
+ * @param {Object} entry - Connector entry from this.connectors
139
+ */
140
+ async fetchData(entry) {
141
+ const { config } = entry;
142
+ const { dataKey, url } = config;
143
+
144
+ log.debug(`Fetching data for ${dataKey}: ${url}`);
145
+
146
+ try {
147
+ const response = await fetchWithRetry(url, {
148
+ method: 'GET',
149
+ headers: {
150
+ 'Accept': 'application/json'
151
+ }
152
+ }, { maxRetries: 2, baseDelayMs: 2000 });
153
+
154
+ if (!response.ok) {
155
+ log.warn(`Data connector ${dataKey} returned ${response.status}: ${response.statusText}`);
156
+ return;
157
+ }
158
+
159
+ const contentType = response.headers.get('Content-Type') || '';
160
+ let data;
161
+
162
+ if (contentType.includes('application/json')) {
163
+ data = await response.json();
164
+ } else {
165
+ // Store as raw text if not JSON
166
+ data = await response.text();
167
+ }
168
+
169
+ const previousData = entry.data;
170
+ entry.data = data;
171
+ entry.lastFetch = Date.now();
172
+
173
+ log.debug(`Data updated for ${dataKey} (fetched at ${new Date(entry.lastFetch).toISOString()})`);
174
+
175
+ // Emit event for listeners (IC route, platform layer)
176
+ this.emit('data-updated', dataKey, data);
177
+
178
+ // Emit a specific event if data actually changed
179
+ if (JSON.stringify(previousData) !== JSON.stringify(data)) {
180
+ this.emit('data-changed', dataKey, data);
181
+ }
182
+
183
+ } catch (error) {
184
+ log.error(`Failed to fetch data for ${dataKey}:`, error);
185
+ this.emit('fetch-error', dataKey, error);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Cleanup - stop all polling and remove listeners
191
+ */
192
+ cleanup() {
193
+ this.stopPolling();
194
+ this.connectors.clear();
195
+ this.removeAllListeners();
196
+ log.debug('DataConnectorManager cleaned up');
197
+ }
198
+ }
package/src/index.js ADDED
@@ -0,0 +1,4 @@
1
+ // @xiboplayer/core - Player core orchestration
2
+ export { PlayerCore } from './player-core.js';
3
+ export { PlayerState } from './state.js';
4
+ export { DataConnectorManager } from './data-connectors.js';