Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@
"Bash(git merge:*)",
"Bash(gh pr create:*)",
"Bash(gh pr view:*)",
"Bash(gh pr edit:*)"
"Bash(gh pr edit:*)",
"Bash(git rebase:*)"
]
}
}
14 changes: 7 additions & 7 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,20 @@ Context: [CLAUDE.md](CLAUDE.md), [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.

- [ ] **Error alert UX** — Show structured error from API (cause + diagnosis: title, solutions); optional collapsible Details and FFmpeg output; centred modal or large toast when stream fails; link to troubleshooting guide. _Note: Implemented (structured modal, diagnosis, stderr in Technical details, scenario tests); QA pending._ _Edge cases: Start All partial failure — toast lists failed stream names (up to 5 + "and N more") and first error reason; test API response shape and frontend message._

- [ ] **Stability** — Confirm 3+ streams stay stable (see TROUBLESHOOTING.md).
- [x] **Stability** — Confirm 3+ streams stay stable (see TROUBLESHOOTING.md). _CONFIRMED working._

- [ ] **Device change mid-stream / Play after edit** — After changing a stream's device mid-stream, Play on listener page can show "Authentication Failed"; workaround: Start stream on dashboard, refresh listener page. _See TROUBLESHOOTING.md. Should not require delete/recreate._
- [x] **Device change mid-stream / Play after edit** — Play proxy now checks Icecast response status; non-200 returns 502 with clear message so listener page no longer shows "Authentication Failed". Listener page shows "Stream not running. Start it on the dashboard, then try Play again." No refresh or delete/recreate needed. _See TROUBLESHOOTING.md §1c._

- [ ] **Stream labels (optional)** — e.g. prefix streams S1, S2, S3; or better naming idea.
- [x] **Stream labels (optional)** — Streams show S1, S2, S3 from server order; label in `getStats()` and on listener page / dashboard.

- [ ] **Sortable streams (optional)** — Drag-and-drop or up/down to reorder stream list on admin dashboard; persist order (e.g. in config). Works with Stream labels; affects frontend for listeners without refresh.
- [x] **Sortable streams (optional)** — Up/down buttons on dashboard; `POST /api/streams/reorder`; order persisted in `config/streams.json` (`_order`); listener page uses same order and S1/S2/S3 labels without refresh.

- [ ] **Contact: WhatsApp** — Enforce country code, build `wa.me/<digits>` link.

- [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md).
- [x] **Contact: WhatsApp** — Country code enforced (digits 10–15, no leading zero); validation backend + ContactManager; `wa.me/<digits>` built on listener page; placeholder "e.g. +44 7123 456789 (country code required)".

- [ ] **Update notification UX** — Show notification bell icon when update available; click to open modal with update info and link to latest release. Current: button shows "Updates" and runs manual check on click; should auto-check on load and show bell when updateAvailable=true.

- [ ] **UI/UX polish (optional)** — [docs/UI-UX-RECOMMENDATIONS.md](docs/UI-UX-RECOMMENDATIONS.md).

---

## Reference
Expand Down
10 changes: 4 additions & 6 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,20 +302,18 @@ If you access the dashboard using a virtual adapter IP (e.g., `http://172.29.16.
- Switch IPv4 to DHCP if a leftover static `.8` address exists.
4. Share only the subnet-matching URL (e.g., `http://192.168.100.x:3001/streams`).

#### 1c. "Authentication Failed" after changing device mid-stream
#### 1c. Play fails after changing device mid-stream

**Symptoms:** You edit a stream (change the audio device) while it was playing, then click Play on the listener page and see "Authentication Failed". Play only works again after removing and recreating the stream.
**Symptoms:** You edit a stream (change the audio device) while it was playing, then click Play on the listener page. You may see "Stream not running" or previously "Authentication Failed" (Icecast 401 when the mount has no source).

**Likely cause:** After an edit (device change), the stream is stopped and must be started again. The listener page may still be pointing at the old mount or the stream may be in an error state so the proxy or Icecast returns an error that appears as "Authentication Failed".
**Cause:** After an edit (device change), the stream is stopped and must be started again. The play proxy now returns a clear message instead of piping Icecast’s error, so you no longer see "Authentication Failed".

**What to do:**
1. On the **admin dashboard**, open the stream and click **Start** (or use Start All) so the stream is running again with the new device.
2. On the **listener page**, refresh the stream list (reload the page or wait for the next poll), then click Play again.
2. On the **listener page**, click Play again (no refresh or delete/recreate needed).
3. If it still fails, check that the stream shows "LIVE" on the dashboard and that Icecast is running. If the stream is in "ERROR" state, fix the device (e.g. select a working source) and click Start again.
4. Only remove and recreate the stream if the mount is stuck (e.g. Icecast still thinks the old source is connected). Restarting Icecast from the dashboard can clear stuck mounts.

**Note:** This behaviour is under review; the app should not require deleting and recreating the stream when only the device is changed.

#### 2. Windows Firewall Blocking Connections

**Solution:**
Expand Down
22 changes: 6 additions & 16 deletions public/components/ContactManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,24 +128,14 @@ class ContactManager {
}
}

// Validate WhatsApp if showWhatsapp is enabled
// Validate WhatsApp if showWhatsapp is enabled (country code required for wa.me)
if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) {
const whatsappRegex = /^\+?[1-9]\d{6,14}$/;
const cleanedWhatsapp = this.contactDetails.whatsapp.replace(/[^\d+]/g, '');
if (!whatsappRegex.test(cleanedWhatsapp)) {
errors.push('Please enter a valid WhatsApp number (e.g., +1234567890 or 1234567890)');
const digits = this.contactDetails.whatsapp.replace(/\D/g, '');
if (digits.length < 10 || digits.length > 15 || digits.startsWith('0')) {
errors.push('WhatsApp: include country code (e.g. +44 7123 456789), no leading zero');
}
}

// Validate WhatsApp if showWhatsapp is enabled
if (this.contactDetails.showWhatsapp && this.contactDetails.whatsapp) {
const phoneRegex = /^\+?[1-9]\d{6,14}$/;
const cleanedWhatsapp = this.contactDetails.whatsapp.replace(/[^\d+]/g, '');
if (!phoneRegex.test(cleanedWhatsapp)) {
errors.push('Please enter a valid WhatsApp number');
}
}


return errors;
}

Expand Down Expand Up @@ -319,7 +309,7 @@ class ContactManager {
<input type="tel"
id="contact-whatsapp"
class="w-full px-3 py-2 bg-[#111111] border border-[var(--border-color)] rounded-lg text-sm text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-[var(--primary-color)]/50 focus:border-[var(--primary-color)]"
placeholder="+1 (234) 567-8900"
placeholder="e.g. +44 7123 456789 (country code required)"
value="${this.contactDetails.whatsapp}">
</div>

Expand Down
51 changes: 48 additions & 3 deletions public/components/FFmpegStreamsManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -510,6 +510,42 @@ class FFmpegStreamsManager {
}
}

/**
* Reorder streams (move up/down). Persists order; listener page uses same order and S1/S2/S3 labels.
*/
async moveStreamUp(index) {
if (index <= 0) return;
await this.reorderStreams(index, index - 1);
}

async moveStreamDown(index) {
if (index >= this.activeStreams.length - 1) return;
await this.reorderStreams(index, index + 1);
}

async reorderStreams(fromIndex, toIndex) {
try {
const ids = this.activeStreams.map(s => s.id);
const [moved] = ids.splice(fromIndex, 1);
ids.splice(toIndex, 0, moved);
const response = await fetch('/api/streams/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ streamIds: ids })
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.message || 'Failed to reorder');
}
await this.loadStreams();
this.render();
this.showNotification('Stream order updated', 'success');
} catch (error) {
console.error('Failed to reorder streams:', error);
this.showNotification(`Failed to reorder: ${error.message}`, 'error');
}
}

/**
* Edit a stream (show edit modal)
*/
Expand Down Expand Up @@ -936,7 +972,7 @@ class FFmpegStreamsManager {
`;
}

return this.activeStreams.map(stream => {
return this.activeStreams.map((stream, index) => {
// Use client-side timer for uptime calculation
const clientTimer = this.clientTimers.get(stream.id);
let uptime = '0s';
Expand Down Expand Up @@ -984,15 +1020,24 @@ class FFmpegStreamsManager {
return `
<div class="bg-[#111111] border border-[var(--border-color)] rounded-xl p-5 hover:border-[var(--primary-color)]/30 transition-all duration-300">
<div class="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
<!-- Stream Info -->
<!-- Stream Info (label S1, S2, S3 + reorder) -->
<div class="flex items-center gap-4 flex-1">
<div class="flex flex-col items-center gap-0.5 flex-shrink-0">
<button type="button" onclick="ffmpegStreamsManager.moveStreamUp(${index})" title="Move up" class="p-1 rounded hover:bg-[var(--primary-color)]/20 text-gray-400 hover:text-white disabled:opacity-30 disabled:pointer-events-none" ${index === 0 ? 'disabled' : ''}>
<span class="material-symbols-rounded text-lg">arrow_upward</span>
</button>
<span class="text-xs font-medium text-[var(--primary-color)]">${stream.label || 'S' + (index + 1)}</span>
<button type="button" onclick="ffmpegStreamsManager.moveStreamDown(${index})" title="Move down" class="p-1 rounded hover:bg-[var(--primary-color)]/20 text-gray-400 hover:text-white disabled:opacity-30 disabled:pointer-events-none" ${index === this.activeStreams.length - 1 ? 'disabled' : ''}>
<span class="material-symbols-rounded text-lg">arrow_downward</span>
</button>
</div>
<div class="relative">
<div class="w-3 h-3 rounded-full ${stream.status === 'running' ? 'bg-[var(--live-color)] pulse-live' : stream.status === 'error' ? 'bg-red-500' : 'bg-gray-500'}"></div>
${stream.status === 'running' ? '<div class="absolute inset-0 w-3 h-3 rounded-full bg-[var(--live-color)] animate-ping opacity-75"></div>' : ''}
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2 mb-1">
<h3 class="font-semibold text-white text-lg truncate flex-1">${stream.name || stream.id}</h3>
<h3 class="font-semibold text-white text-lg truncate flex-1">${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}</h3>
</div>
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-400">
<span class="flex items-center gap-1">
Expand Down
15 changes: 9 additions & 6 deletions public/streams.html
Original file line number Diff line number Diff line change
Expand Up @@ -464,7 +464,7 @@ <h3 class="text-lg font-semibold text-white mb-4">📞 Contact & Feedback</h3>
<div class="absolute inset-0 w-3 h-3 rounded-full bg-[var(--live-color)] animate-ping opacity-75"></div>
</div>
<div class="flex-1 min-w-0">
<h3 class="font-semibold text-white text-base sm:text-lg mb-1 truncate">${stream.name || stream.id}</h3>
<h3 class="font-semibold text-white text-base sm:text-lg mb-1 truncate">${stream.label ? stream.label + ' — ' : ''}${stream.name || stream.id}</h3>
<!-- Mobile: Stack info vertically, Desktop: Horizontal -->
<div class="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-400">
<span class="flex items-center gap-1">
Expand Down Expand Up @@ -519,7 +519,7 @@ <h3 class="font-semibold text-white text-base sm:text-lg mb-1 truncate">${stream
<div class="mt-4 p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<div class="flex items-center gap-2 mb-3">
<span class="material-symbols-rounded text-green-400">volume_up</span>
<span class="text-green-300 text-sm">Now playing: ${stream.name || stream.id}</span>
<span class="text-green-300 text-sm">Now playing: ${stream.label ? stream.label + ' ' : ''}${stream.name || stream.id}</span>
</div>
<!-- Volume Control -->
<div class="flex items-center gap-3">
Expand Down Expand Up @@ -616,14 +616,14 @@ <h3 class="font-semibold text-white text-base sm:text-lg mb-1 truncate">${stream
errorMessage = 'Stream format not supported by your browser. Try Chrome, Firefox, or Edge for better AAC support.';
} else if (errorCode === 2) { // MEDIA_ERR_NETWORK
if (networkState === 2 || networkState === 3) {
errorMessage = 'Network error loading stream. Check if the Icecast server is running and try again.';
errorMessage = 'Stream not running. Start it on the dashboard, then try Play again.';
} else {
errorMessage = 'Network error loading stream. Check your connection and try again.';
}
} else if (errorCode === 3) { // MEDIA_ERR_DECODE
errorMessage = 'Stream decode error. The audio format may be corrupted or incompatible.';
} else if (networkState === 2 || networkState === 3) {
errorMessage = 'Stream server unavailable. Please check if the Icecast server is running.';
errorMessage = 'Stream not running. Start it on the dashboard, then try Play again.';
}

this.showNotification(errorMessage, 'error');
Expand Down Expand Up @@ -1039,13 +1039,16 @@ <h3 class="text-lg font-semibold text-white">Copy Stream URL</h3>
}

if (contactData.showWhatsapp && contactData.whatsapp) {
const whatsappUrl = `https://wa.me/${contactData.whatsapp.replace(/[^\d]/g, '')}?text=Hi%20from%20LANStreamer!`;
contactItems.push(`
const digits = contactData.whatsapp.replace(/\D/g, '');
if (digits && !digits.startsWith('0')) {
const whatsappUrl = `https://wa.me/${digits}?text=Hi%20from%20LANStreamer!`;
contactItems.push(`
<a href="${whatsappUrl}" target="_blank" class="flex items-center gap-3 text-gray-300 hover:text-[var(--primary-color)] transition-colors">
<span class="material-symbols-rounded text-sm">chat</span>
<span class="text-sm">WhatsApp Chat</span>
</a>
`);
}
}

// Hide the entire contact section if no contact details are available
Expand Down
15 changes: 13 additions & 2 deletions src/routes/contact.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,8 @@ router.post('/contact-details', async (req, res) => {
throw new ValidationError('Invalid phone format', ErrorCodes.INVALID_FORMAT);
}

if (whatsapp && !isValidPhone(whatsapp)) {
throw new ValidationError('Invalid WhatsApp number format', ErrorCodes.INVALID_FORMAT);
if (whatsapp && !isValidWhatsApp(whatsapp)) {
throw new ValidationError('WhatsApp number must include country code (e.g. +44 7123 456789). Use digits only with country code, no leading zero.', ErrorCodes.INVALID_FORMAT);
}

// Prepare contact details
Expand Down Expand Up @@ -206,6 +206,17 @@ function isValidPhone(phone) {
return phoneRegex.test(cleaned);
}

/**
* Validate WhatsApp number for wa.me link: digits only with country code (no leading zero).
* E.g. +44 7123 456789 → 447123456789 (valid). 07123456789 (invalid, missing country code).
*/
function isValidWhatsApp(value) {
const digits = (value || '').replace(/\D/g, '');
if (digits.length < 10 || digits.length > 15) return false;
if (digits.startsWith('0')) return false; // leading zero usually means missing country code
return /^[1-9]\d{9,14}$/.test(digits);
}

/**
* POST /api/upload-event-image
* Upload event image
Expand Down
Loading
Loading