chore: update screenshots in readme and sso icon

This commit is contained in:
Trevor Mears
2026-01-18 19:49:22 -08:00
parent 475c50b743
commit 100a26fe4e
11 changed files with 182 additions and 18 deletions
+7 -4
View File
@@ -13,6 +13,7 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
- **🎬 VOD Support**: Dedicated sections for Movies and TV Series with rich metadata, posters, and seasonal episode lists. - **🎬 VOD Support**: Dedicated sections for Movies and TV Series with rich metadata, posters, and seasonal episode lists.
- **❤️ Favorites System**: Unified favorites for channels, movies, and series with instant synchronization. - **❤️ Favorites System**: Unified favorites for channels, movies, and series with instant synchronization.
- **🔐 Authentication**: User login system with admin and viewer roles ([details](https://github.com/technomancer702/nodecast-tv/pull/23)). - **🔐 Authentication**: User login system with admin and viewer roles ([details](https://github.com/technomancer702/nodecast-tv/pull/23)).
- **🆔 OIDC SSO**: Support for Single Sign-On via OIDC providers (Authentik, Keycloak, etc.).
- **⚡ High Performance**: Optimized for large playlists (7000+ channels) using virtual scrolling and batch rendering. - **⚡ High Performance**: Optimized for large playlists (7000+ channels) using virtual scrolling and batch rendering.
- **⚙️ Management**: - **⚙️ Management**:
- Support for Xtream Codes and M3U playlists. - Support for Xtream Codes and M3U playlists.
@@ -26,10 +27,12 @@ nodecast-tv is a modern, web-based IPTV player featuring Live TV, EPG, Movies (V
## Screenshots ## Screenshots
<div align="center"> <div align="center">
<img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Screenshot 1" /> <img src="public/img/screenshots/screenshot-dashboard.png" width="45%" alt="Dashboard" />
<img src="public/img/screenshots/screenshot-2.png" width="45%" alt="Screenshot 2" /> <img src="public/img/screenshots/screenshot-1.png" width="45%" alt="Live TV" />
<img src="public/img/screenshots/screenshot-3.png" width="45%" alt="Screenshot 3" /> <img src="public/img/screenshots/screenshot-2.png" width="45%" alt="TV Guide" />
<img src="public/img/screenshots/screenshot-4.png" width="45%" alt="Screenshot 4" /> <img src="public/img/screenshots/screenshot-3.png" width="45%" alt="Movies" />
<img src="public/img/screenshots/screenshot-4.png" width="45%" alt="Series" />
<img src="public/img/screenshots/screenshot-settings.png" width="45%" alt="Settings" />
</div> </div>
## Getting Started ## Getting Started
+39 -1
View File
@@ -1866,6 +1866,21 @@ kbd {
animation: spin 0.8s linear infinite; animation: spin 0.8s linear infinite;
} }
.loading-state {
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-sm);
padding: var(--space-xl);
color: var(--color-text-muted);
width: 100%;
}
.horizontal-scroll .empty-state {
padding: var(--space-xl);
min-height: 150px;
}
@keyframes spin { @keyframes spin {
to { to {
transform: rotate(360deg); transform: rotate(360deg);
@@ -4109,7 +4124,6 @@ kbd {
border-radius: 4px; border-radius: 4px;
font-size: 0.8rem; font-size: 0.8rem;
font-weight: 600; font-weight: 600;
margin-left: 10px;
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
color: white; color: white;
display: inline-flex; display: inline-flex;
@@ -4139,6 +4153,30 @@ kbd {
pointer-events: none; pointer-events: none;
} }
/* Badge Container for right alignment */
.player-badges {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
/* Fallback for cases without flex-grow middle element */
}
/* Quality Badge */
.quality-badge {
display: inline-block;
background: rgba(158, 158, 158, 0.8);
color: white;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.quality-badge.hidden {
display: none;
}
/* Watch Header Bar (Title + Transcode Status) */ /* Watch Header Bar (Title + Transcode Status) */
/* Moved to original definition around line 3311 */ /* Moved to original definition around line 3311 */
Binary file not shown.

After

Width:  |  Height:  |  Size: 483 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

+8 -2
View File
@@ -168,7 +168,10 @@
<!-- Top Bar --> <!-- Top Bar -->
<div class="watch-top-bar live-header"> <div class="watch-top-bar live-header">
<h2 class="watch-title-overlay" id="player-channel-name"></h2> <h2 class="watch-title-overlay" id="player-channel-name"></h2>
<div class="transcode-status hidden" id="player-transcode-status">Direct</div> <div class="player-badges">
<div class="transcode-status hidden" id="player-transcode-status">Direct</div>
<div class="quality-badge hidden" id="player-quality-badge"></div>
</div>
</div> </div>
@@ -788,7 +791,10 @@
<h3 class="watch-title" id="watch-title"></h3> <h3 class="watch-title" id="watch-title"></h3>
<p class="watch-subtitle" id="watch-subtitle"></p> <p class="watch-subtitle" id="watch-subtitle"></p>
</div> </div>
<div class="transcode-status hidden" id="watch-transcode-status">Direct</div> <div class="player-badges">
<div class="transcode-status hidden" id="watch-transcode-status">Direct</div>
<div class="quality-badge hidden" id="watch-quality-badge"></div>
</div>
</div> </div>
<!-- Center: Large Play Button --> <!-- Center: Large Play Button -->
+49 -1
View File
@@ -522,6 +522,17 @@ class VideoPlayer {
// Setup custom video controls // Setup custom video controls
this.initCustomControls(); this.initCustomControls();
// Detect video resolution when metadata loads (works for all streams)
this.video.addEventListener('loadedmetadata', () => {
if (this.video.videoHeight > 0) {
this.currentStreamInfo = {
width: this.video.videoWidth,
height: this.video.videoHeight
};
this.updateQualityBadge();
}
});
// Initialize HLS.js if supported // Initialize HLS.js if supported
if (Hls.isSupported()) { if (Hls.isSupported()) {
this.hls = new Hls(this.getHlsConfig()); this.hls = new Hls(this.getHlsConfig());
@@ -775,7 +786,11 @@ class VideoPlayer {
try { try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`); const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
const info = await probeRes.json(); const info = await probeRes.json();
console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`); console.log(`[Player] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
// Store probe result for quality badge display
this.currentStreamInfo = info;
this.updateQualityBadge();
// Handle subtitles from probe result // Handle subtitles from probe result
// Clear existing remote tracks (from previous streams) // Clear existing remote tracks (from previous streams)
@@ -1122,6 +1137,34 @@ class VideoPlayer {
el.classList.remove('hidden'); el.classList.remove('hidden');
} }
/**
* Get quality label from video height
*/
getQualityLabel(height) {
if (height >= 2160) return '4K';
if (height >= 1440) return '1440p';
if (height >= 1080) return '1080p';
if (height >= 720) return '720p';
if (height >= 480) return '480p';
if (height > 0) return `${height}p`;
return null;
}
/**
* Update quality badge display
*/
updateQualityBadge() {
const badge = document.getElementById('player-quality-badge');
if (!badge) return;
if (this.currentStreamInfo?.height > 0) {
badge.textContent = this.getQualityLabel(this.currentStreamInfo.height);
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
}
/** /**
* Fetch EPG data for current channel * Fetch EPG data for current channel
*/ */
@@ -1267,6 +1310,11 @@ class VideoPlayer {
this.controlsOverlay?.classList.add('hidden'); // Hide controls this.controlsOverlay?.classList.add('hidden'); // Hide controls
this.loadingSpinner?.classList.remove('show'); this.loadingSpinner?.classList.remove('show');
this.nowPlaying.classList.add('hidden'); this.nowPlaying.classList.add('hidden');
// Hide quality badge
this.currentStreamInfo = null;
const badge = document.getElementById('player-quality-badge');
if (badge) badge.classList.add('hidden');
} }
/** /**
+1
View File
@@ -26,6 +26,7 @@ const Icons = {
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`, check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>`,
circle: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`, circle: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8z"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`, close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>`,
fingerprint: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="icon"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z"/></svg>`,
}; };
window.Icons = Icons; window.Icons = Icons;
+18 -6
View File
@@ -40,7 +40,10 @@ class HomePage {
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button> </button>
<div class="horizontal-scroll channel-tiles" id="favorite-channels-list"> <div class="horizontal-scroll channel-tiles" id="favorite-channels-list">
<div class="loading-state">Loading favorites...</div> <div class="loading-state">
<div class="loading"></div>
<span>Loading favorites...</span>
</div>
</div> </div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right"> <button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
@@ -57,7 +60,10 @@ class HomePage {
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button> </button>
<div class="horizontal-scroll" id="continue-watching-list"> <div class="horizontal-scroll" id="continue-watching-list">
<div class="loading-state">Loading history...</div> <div class="loading-state">
<div class="loading"></div>
<span>Loading history...</span>
</div>
</div> </div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right"> <button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
@@ -74,7 +80,10 @@ class HomePage {
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button> </button>
<div class="horizontal-scroll" id="recent-movies-list"> <div class="horizontal-scroll" id="recent-movies-list">
<div class="empty-state">Coming soon</div> <div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div> </div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right"> <button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
@@ -91,7 +100,10 @@ class HomePage {
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M15.41 7.41L14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button> </button>
<div class="horizontal-scroll" id="recent-series-list"> <div class="horizontal-scroll" id="recent-series-list">
<div class="empty-state">Coming soon</div> <div class="loading-state">
<div class="loading"></div>
<span>Loading recently added...</span>
</div>
</div> </div>
<button class="scroll-arrow scroll-right" aria-label="Scroll right"> <button class="scroll-arrow scroll-right" aria-label="Scroll right">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg> <svg viewBox="0 0 24 24" fill="currentColor"><path d="M10 6L8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
@@ -342,7 +354,7 @@ class HomePage {
try { try {
const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12'); const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
if (!movies || movies.length === 0) { if (!movies || movies.length === 0) {
list.innerHTML = '<div class="no-data">No recent movies found</div>'; list.innerHTML = '<div class="empty-state hint">No recently added movies found</div>';
return; return;
} }
@@ -371,7 +383,7 @@ class HomePage {
try { try {
const series = await window.API.request('GET', '/channels/recent?type=series&limit=12'); const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
if (!series || series.length === 0) { if (!series || series.length === 0) {
list.innerHTML = '<div class="no-data">No recent series found</div>'; list.innerHTML = '<div class="empty-state hint">No recently added series found</div>';
return; return;
} }
+47 -2
View File
@@ -61,6 +61,7 @@ class WatchPage {
// Transcode Status // Transcode Status
this.transcodeStatusEx = document.getElementById('watch-transcode-status'); this.transcodeStatusEx = document.getElementById('watch-transcode-status');
this.qualityBadgeEl = document.getElementById('watch-quality-badge');
// State // State
this.hls = null; this.hls = null;
@@ -331,6 +332,33 @@ class WatchPage {
this.transcodeStatusEx.classList.remove('hidden'); this.transcodeStatusEx.classList.remove('hidden');
} }
/**
* Get quality label from video height
*/
getQualityLabel(height) {
if (height >= 2160) return '4K';
if (height >= 1440) return '1440p';
if (height >= 1080) return '1080p';
if (height >= 720) return '720p';
if (height >= 480) return '480p';
if (height > 0) return `${height}p`;
return null;
}
/**
* Update quality badge display
*/
updateQualityBadge() {
if (!this.qualityBadgeEl) return;
if (this.currentStreamInfo?.height > 0) {
this.qualityBadgeEl.textContent = this.getQualityLabel(this.currentStreamInfo.height);
this.qualityBadgeEl.classList.remove('hidden');
} else {
this.qualityBadgeEl.classList.add('hidden');
}
}
async loadVideo(url) { async loadVideo(url) {
// Store the URL for copy functionality // Store the URL for copy functionality
this.currentUrl = url; this.currentUrl = url;
@@ -361,7 +389,11 @@ class WatchPage {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset; const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`); const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
const info = await probeRes.json(); const info = await probeRes.json();
console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, compatible=${info.compatible}`); console.log(`[WatchPage] Probe result: video=${info.video}, audio=${info.audio}, ${info.width}x${info.height}, compatible=${info.compatible}`);
// Store early probe info for quality display
this.currentStreamInfo = info;
this.updateQualityBadge();
if (info.needsTranscode) { if (info.needsTranscode) {
console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)'); console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
@@ -539,6 +571,12 @@ class WatchPage {
this.stopTranscodeSession(); this.stopTranscodeSession();
this.updateTranscodeStatus('hidden'); this.updateTranscodeStatus('hidden');
// Hide quality badge
this.currentStreamInfo = null;
if (this.qualityBadgeEl) {
this.qualityBadgeEl.classList.add('hidden');
}
if (this.hls) { if (this.hls) {
this.hls.destroy(); this.hls.destroy();
this.hls = null; this.hls = null;
@@ -656,7 +694,14 @@ class WatchPage {
} }
onMetadataLoaded() { onMetadataLoaded() {
// Duration is unreliable for transcoded streams, so we don't display it // Detect resolution
if (this.video && this.video.videoHeight > 0) {
this.currentStreamInfo = {
width: this.video.videoWidth,
height: this.video.videoHeight
};
this.updateQualityBadge();
}
// Handle resumption // Handle resumption
if (this.resumeTime > 0 && this.video) { if (this.resumeTime > 0 && this.video) {
+11 -2
View File
@@ -205,14 +205,23 @@
</div> </div>
<button id="btn-sso-login" class="btn-login" <button id="btn-sso-login" class="btn-login"
style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px;"> style="background: transparent; border: 1px solid var(--color-border); color: var(--color-text-primary); display: flex; align-items: center; justify-content: center; gap: 10px; transition: all 0.2s;">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"
style="width: 20px; height: 20px;"> style="width: 20px; height: 20px;">
<path <path
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z" /> d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm-2 16l-4-4 1.41-1.41L10 14.17l6.59-6.59L18 9l-8 8z" />
</svg> </svg>
Sign in with SSO Sign in with SSO
</button> </button>
<style>
#btn-sso-login:hover {
background: var(--color-bg-tertiary) !important;
border-color: var(--color-accent) !important;
color: var(--color-accent) !important;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
</style>
</div> </div>
</div> </div>
+2
View File
@@ -128,6 +128,8 @@ function analyzeProbeResult(probeResult, url) {
return { return {
video: videoCodec, video: videoCodec,
audio: audioCodec, audio: audioCodec,
width: videoStream?.width || 0,
height: videoStream?.height || 0,
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
container: container, container: container,
compatible: compatible, compatible: compatible,