diff --git a/public/css/main.css b/public/css/main.css index 64b31c3..7774cd2 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -980,7 +980,8 @@ video::cue { .epg-grid { height: calc(100vh - var(--navbar-height) - 60px); - overflow: auto; + overflow: hidden; + overflow-y: auto; padding: var(--space-md); -webkit-overflow-scrolling: touch; overscroll-behavior: contain; @@ -1887,6 +1888,7 @@ kbd { --sidebar-width: 320px; --space-lg: 16px; --space-xl: 24px; + --epg-sidebar-width: 180px; } /* Navigation - Hide text labels, show only icons */ @@ -1900,7 +1902,8 @@ kbd { gap: 0; } - .navbar-menu span:not(.nav-icon) { + /* Hide nav link text on mobile, but keep Now Playing visible */ + .navbar-menu .nav-link span:not(.nav-icon) { display: none; } @@ -2039,7 +2042,11 @@ kbd { } .epg-channel-info { - width: 180px; + width: var(--epg-sidebar-width); + } + + .epg-time-header { + margin-left: var(--epg-sidebar-width); } .epg-time-slot { @@ -4314,7 +4321,7 @@ kbd { align-items: center; gap: 8px; padding: 6px 14px; - margin-right: 16px; + margin-right: var(--space-md); background: linear-gradient(135deg, var(--color-accent) 0%, #8b5cf6 100%); border-radius: var(--radius-full); color: white; @@ -4360,13 +4367,16 @@ kbd { } } -/* Mobile responsive */ +/* Mobile responsive - show in dropdown menu */ @media (max-width: 768px) { .now-playing-indicator { - max-width: 140px; - padding: 5px 10px; - font-size: 12px; - margin-right: 8px; + width: calc(100% - 32px); + max-width: none; + margin: 0 16px var(--space-sm) 16px; + padding: 10px 16px; + font-size: 13px; + border-radius: var(--radius-md); + justify-content: center; } } @@ -4406,7 +4416,63 @@ kbd { color: var(--color-text-bright); } +/* Scroll Wrapper with Navigation Arrows */ +.scroll-wrapper { + position: relative; +} + +.scroll-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + z-index: 10; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(0, 0, 0, 0.7); + border: 1px solid rgba(255, 255, 255, 0.1); + color: white; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all var(--transition-fast); + backdrop-filter: blur(8px); +} + +.scroll-arrow:hover { + background: rgba(0, 0, 0, 0.9); + border-color: var(--color-accent); + transform: translateY(-50%) scale(1.1); +} + +.scroll-arrow svg { + width: 24px; + height: 24px; +} + +.scroll-arrow.scroll-left { + left: -8px; +} + +.scroll-arrow.scroll-right { + right: -8px; +} + +.scroll-arrow.hidden { + opacity: 0; + pointer-events: none; +} + +/* Hide arrows on touch devices (they can swipe) */ +@media (hover: none) { + .scroll-arrow { + display: none; + } +} + .horizontal-scroll { + display: flex; gap: 20px; overflow-x: auto; diff --git a/public/img/placeholder.png b/public/img/placeholder.png index 3f17944..c33035c 100644 Binary files a/public/img/placeholder.png and b/public/img/placeholder.png differ diff --git a/public/img/poster-placeholder.jpg b/public/img/poster-placeholder.jpg new file mode 100644 index 0000000..c3283e9 Binary files /dev/null and b/public/img/poster-placeholder.jpg differ diff --git a/public/index.html b/public/index.html index 6d2542c..c45df97 100644 --- a/public/index.html +++ b/public/index.html @@ -38,12 +38,12 @@ - - `).join('')} -
+
@@ -461,11 +461,25 @@ class EpgGuide { if (this._scrollHandler) { this.scrollContainer?.removeEventListener('scroll', this._scrollHandler); } + if (this._hScrollHandler) { + this.scrollContainer?.removeEventListener('scroll', this._hScrollHandler); + } - // Set up scroll handler for virtual scrolling + // Get reference to time header for horizontal scroll sync + this.timeHeader = this.container.querySelector('.epg-time-header'); + + // Set up scroll handler for virtual scrolling (debounced for performance) this._scrollHandler = this.debounce(() => this.updateVisibleRows(), 16); // ~60fps this.scrollContainer.addEventListener('scroll', this._scrollHandler); + // Set up horizontal scroll sync (instant, not debounced) + this._hScrollHandler = () => { + if (this.timeHeader && this.scrollContainer) { + this.timeHeader.style.transform = `translateX(-${this.scrollContainer.scrollLeft}px)`; + } + }; + this.scrollContainer.addEventListener('scroll', this._hScrollHandler); + // Initial render of visible rows this.updateVisibleRows(); diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index a0e3a5d..fdc954a 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -1251,6 +1251,9 @@ class VideoPlayer { * Stop playback */ stop() { + // Stop any running transcode session first + this.stopTranscodeSession(); + if (this.hls) { this.hls.destroy(); this.hls = null; diff --git a/public/js/pages/HomePage.js b/public/js/pages/HomePage.js index 814b280..66e255c 100644 --- a/public/js/pages/HomePage.js +++ b/public/js/pages/HomePage.js @@ -35,8 +35,16 @@ class HomePage {

Favorite Channels

-
-
Loading favorites...
+
+ +
+
Loading favorites...
+
+
@@ -44,8 +52,16 @@ class HomePage {

Continue Watching

-
-
Loading history...
+
+ +
+
Loading history...
+
+
@@ -53,8 +69,16 @@ class HomePage {

Recently Added Movies

-
-
Coming soon
+
+ +
+
Coming soon
+
+
@@ -62,15 +86,73 @@ class HomePage {

Recently Added Series

-
-
Coming soon
+
+ +
+
Coming soon
+
+
`; this.container = document.getElementById('home-content'); + + // Attach scroll arrow handlers + this.initScrollArrows(); } + initScrollArrows() { + this.container.querySelectorAll('.scroll-wrapper').forEach(wrapper => { + const scrollContainer = wrapper.querySelector('.horizontal-scroll'); + const leftBtn = wrapper.querySelector('.scroll-left'); + const rightBtn = wrapper.querySelector('.scroll-right'); + + if (!scrollContainer || !leftBtn || !rightBtn) return; + + const scrollAmount = 300; // pixels to scroll per click + + leftBtn.addEventListener('click', () => { + scrollContainer.scrollBy({ left: -scrollAmount, behavior: 'smooth' }); + }); + + rightBtn.addEventListener('click', () => { + scrollContainer.scrollBy({ left: scrollAmount, behavior: 'smooth' }); + }); + + // Update arrow visibility based on scroll position + const updateArrows = () => { + const { scrollLeft, scrollWidth, clientWidth } = scrollContainer; + leftBtn.classList.toggle('hidden', scrollLeft <= 0); + rightBtn.classList.toggle('hidden', scrollLeft + clientWidth >= scrollWidth - 5); + }; + + // Store reference for later updates + wrapper._updateArrows = updateArrows; + + scrollContainer.addEventListener('scroll', updateArrows); + // Initial check after content loads + setTimeout(updateArrows, 100); + }); + } + + /** + * Re-check scroll arrow visibility for all sections + * Call this after dynamically loading content + */ + updateScrollArrows() { + this.container?.querySelectorAll('.scroll-wrapper').forEach(wrapper => { + if (wrapper._updateArrows) { + wrapper._updateArrows(); + } + }); + } + + async loadDashboardData() { if (this.isLoading) return; this.isLoading = true; @@ -147,6 +229,9 @@ class HomePage { }); }); + // Update scroll arrows after content renders + this.updateScrollArrows(); + } catch (err) { console.error('[Dashboard] Error loading favorite channels:', err); list.innerHTML = '
Error loading favorites
'; @@ -161,7 +246,7 @@ class HomePage { return `
${name}
@@ -223,6 +308,9 @@ class HomePage { } }); }); + + // Update scroll arrows after content renders + this.updateScrollArrows(); } navigateToSeries(item) { @@ -268,6 +356,9 @@ class HomePage { if (item) this.playItem(item); }); }); + + // Update scroll arrows after content renders + this.updateScrollArrows(); } catch (err) { console.error('[Dashboard] Error loading recent movies:', err); } @@ -294,6 +385,9 @@ class HomePage { if (item) this.navigateToSeries(item); }); }); + + // Update scroll arrows after content renders + this.updateScrollArrows(); } catch (err) { console.error('[Dashboard] Error loading recent series:', err); } @@ -311,7 +405,7 @@ class HomePage { return `
- ${data.title || item.name} + ${data.title || item.name}
@@ -336,7 +430,7 @@ class HomePage { return `
- ${item.name} + ${item.name}
diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index 3f3041c..53061e7 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -888,7 +888,14 @@ class WatchPage { renderDetails() { if (!this.content) return; - this.posterEl.src = this.content.poster || '/img/placeholder.png'; + const isChannel = this.content.type === 'channel' || !this.content.type; // Default to channel if unknown + const fallback = isChannel ? '/img/placeholder.png' : '/img/poster-placeholder.jpg'; + + this.posterEl.onerror = () => { + this.posterEl.onerror = null; + this.posterEl.src = fallback; + }; + this.posterEl.src = this.content.poster || fallback; this.posterEl.alt = this.content.title || ''; this.contentTitleEl.textContent = this.content.title || ''; this.yearEl.textContent = this.content.year || ''; @@ -984,7 +991,7 @@ class WatchPage { `).join(''); diff --git a/server/routes/channels.js b/server/routes/channels.js index 4c84d3d..15965a1 100644 --- a/server/routes/channels.js +++ b/server/routes/channels.js @@ -298,9 +298,17 @@ router.get('/recent', async (req, res) => { const db = getDb(); const recentItems = db.prepare(` - SELECT * FROM playlist_items - WHERE type = ? AND is_hidden = 0 - ORDER BY added_at DESC + SELECT * FROM playlist_items p + WHERE p.type = ? + AND p.is_hidden = 0 + AND NOT EXISTS ( + SELECT 1 FROM categories c + WHERE c.source_id = p.source_id + AND c.category_id = p.category_id + AND c.type = p.type + AND c.is_hidden = 1 + ) + ORDER BY p.added_at DESC LIMIT ? `).all(type, parseInt(limit));