diff --git a/README.md b/README.md
index 25f04b2..b6010c1 100644
--- a/README.md
+++ b/README.md
@@ -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.
- **❤️ 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)).
+- **🆔 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.
- **⚙️ Management**:
- 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
## Getting Started
diff --git a/public/css/main.css b/public/css/main.css
index e95ceb5..5af3e93 100644
--- a/public/css/main.css
+++ b/public/css/main.css
@@ -1866,6 +1866,21 @@ kbd {
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 {
to {
transform: rotate(360deg);
@@ -4109,7 +4124,6 @@ kbd {
border-radius: 4px;
font-size: 0.8rem;
font-weight: 600;
- margin-left: 10px;
background: rgba(0, 0, 0, 0.5);
color: white;
display: inline-flex;
@@ -4139,6 +4153,30 @@ kbd {
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) */
/* Moved to original definition around line 3311 */
diff --git a/public/img/screenshots/screenshot-dashboard.png b/public/img/screenshots/screenshot-dashboard.png
new file mode 100644
index 0000000..25cfd56
Binary files /dev/null and b/public/img/screenshots/screenshot-dashboard.png differ
diff --git a/public/img/screenshots/screenshot-settings.png b/public/img/screenshots/screenshot-settings.png
new file mode 100644
index 0000000..db1e75b
Binary files /dev/null and b/public/img/screenshots/screenshot-settings.png differ
diff --git a/public/index.html b/public/index.html
index c45df97..cfb728c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -168,7 +168,10 @@
@@ -788,7 +791,10 @@
- Direct
+
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js
index fdc954a..751c8e2 100644
--- a/public/js/components/VideoPlayer.js
+++ b/public/js/components/VideoPlayer.js
@@ -522,6 +522,17 @@ class VideoPlayer {
// Setup custom video controls
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
if (Hls.isSupported()) {
this.hls = new Hls(this.getHlsConfig());
@@ -775,7 +786,11 @@ class VideoPlayer {
try {
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(streamUrl)}`);
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
// Clear existing remote tracks (from previous streams)
@@ -1122,6 +1137,34 @@ class VideoPlayer {
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
*/
@@ -1267,6 +1310,11 @@ class VideoPlayer {
this.controlsOverlay?.classList.add('hidden'); // Hide controls
this.loadingSpinner?.classList.remove('show');
this.nowPlaying.classList.add('hidden');
+
+ // Hide quality badge
+ this.currentStreamInfo = null;
+ const badge = document.getElementById('player-quality-badge');
+ if (badge) badge.classList.add('hidden');
}
/**
diff --git a/public/js/icons.js b/public/js/icons.js
index bbaab05..7ef6f8a 100644
--- a/public/js/icons.js
+++ b/public/js/icons.js
@@ -26,6 +26,7 @@ const Icons = {
check: ` `,
circle: ` `,
close: ` `,
+ fingerprint: ` `,
};
window.Icons = Icons;
diff --git a/public/js/pages/HomePage.js b/public/js/pages/HomePage.js
index 66e255c..45793a0 100644
--- a/public/js/pages/HomePage.js
+++ b/public/js/pages/HomePage.js
@@ -40,7 +40,10 @@ class HomePage {
@@ -57,7 +60,10 @@ class HomePage {
@@ -74,7 +80,10 @@ class HomePage {
@@ -91,7 +100,10 @@ class HomePage {
@@ -342,7 +354,7 @@ class HomePage {
try {
const movies = await window.API.request('GET', '/channels/recent?type=movie&limit=12');
if (!movies || movies.length === 0) {
- list.innerHTML = 'No recent movies found
';
+ list.innerHTML = 'No recently added movies found
';
return;
}
@@ -371,7 +383,7 @@ class HomePage {
try {
const series = await window.API.request('GET', '/channels/recent?type=series&limit=12');
if (!series || series.length === 0) {
- list.innerHTML = 'No recent series found
';
+ list.innerHTML = 'No recently added series found
';
return;
}
diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js
index 53061e7..0218d27 100644
--- a/public/js/pages/WatchPage.js
+++ b/public/js/pages/WatchPage.js
@@ -61,6 +61,7 @@ class WatchPage {
// Transcode Status
this.transcodeStatusEx = document.getElementById('watch-transcode-status');
+ this.qualityBadgeEl = document.getElementById('watch-quality-badge');
// State
this.hls = null;
@@ -331,6 +332,33 @@ class WatchPage {
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) {
// Store the URL for copy functionality
this.currentUrl = url;
@@ -361,7 +389,11 @@ class WatchPage {
const ua = settings.userAgentPreset === 'custom' ? settings.userAgentCustom : settings.userAgentPreset;
const probeRes = await fetch(`/api/probe?url=${encodeURIComponent(url)}&ua=${encodeURIComponent(ua || '')}`);
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) {
console.log('[WatchPage] Auto: Using HLS transcode session (incompatible audio/video)');
@@ -539,6 +571,12 @@ class WatchPage {
this.stopTranscodeSession();
this.updateTranscodeStatus('hidden');
+ // Hide quality badge
+ this.currentStreamInfo = null;
+ if (this.qualityBadgeEl) {
+ this.qualityBadgeEl.classList.add('hidden');
+ }
+
if (this.hls) {
this.hls.destroy();
this.hls = null;
@@ -656,7 +694,14 @@ class WatchPage {
}
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
if (this.resumeTime > 0 && this.video) {
diff --git a/public/login.html b/public/login.html
index d9358a3..454acfe 100644
--- a/public/login.html
+++ b/public/login.html
@@ -205,14 +205,23 @@
+ 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;">
+ 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" />
Sign in with SSO
+
+
diff --git a/server/routes/probe.js b/server/routes/probe.js
index 1b12e33..751ef43 100644
--- a/server/routes/probe.js
+++ b/server/routes/probe.js
@@ -128,6 +128,8 @@ function analyzeProbeResult(probeResult, url) {
return {
video: videoCodec,
audio: audioCodec,
+ width: videoStream?.width || 0,
+ height: videoStream?.height || 0,
audioChannels: audioStream?.channels || 0, // For Smart Audio Copy
container: container,
compatible: compatible,