Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6e147ab93 | ||
|
|
8d223359f4 | ||
|
|
9c3f8793af | ||
|
|
4e1518f484 | ||
|
|
100ad77058 | ||
|
|
c97d19fa99 | ||
|
|
89da5fa3f6 | ||
|
|
dae8bd4634 | ||
|
|
35c4053bec |
@@ -0,0 +1,3 @@
|
|||||||
|
GridTV aims to stay lightweight and dependency-free.
|
||||||
|
|
||||||
|
Please avoid adding heavy JS frameworks or external dependencies.
|
||||||
@@ -216,21 +216,22 @@ gridtv/
|
|||||||
└── src/
|
└── src/
|
||||||
├── config.php # Config loader + migration
|
├── config.php # Config loader + migration
|
||||||
├── tpl/ # HTML templates
|
├── tpl/ # HTML templates
|
||||||
│ ├── head.php
|
│ ├── head.php # DOCTYPE, <head>, CSS
|
||||||
│ ├── topbar.php
|
│ ├── topbar.php # Topbar (nav, source switcher, search, theme)
|
||||||
│ ├── grid.php
|
│ ├── grid.php # Grid + mobile + PiP + overlays
|
||||||
│ ├── modals.php
|
│ ├── modals.php # Personal EPG modal
|
||||||
│ └── footer.php
|
│ └── footer.php # JS modules loader, </body></html>
|
||||||
└── js/ # JavaScript modules
|
└── js/ # JavaScript modules
|
||||||
├── config.js # Constants + PHP-injected vars
|
├── config.js # Constants + PHP-injected vars
|
||||||
├── utils.js # Helpers, clock, EPG fetch
|
├── utils.js # Helpers, clock, EPG fetch
|
||||||
├── epg.js # Grid rendering
|
├── epg.js # Grid rendering
|
||||||
├── mobile.js # Mobile list view
|
├── mobile.js # Mobile list view
|
||||||
├── tooltip.js # Hover tooltip
|
├── tooltip.js # Hover tooltip
|
||||||
├── sources.js # Multi-EPG switcher
|
├── sources.js # Multi-EPG switcher + Personal EPG
|
||||||
├── m3u.js # M3U parser
|
├── m3u.js # M3U parser
|
||||||
├── player.js # HLS PiP player
|
├── player.js # HLS PiP player
|
||||||
├── themes.js # Theme switcher
|
├── themes.js # Theme switcher
|
||||||
|
├── search.js # Search + filter (debounced)
|
||||||
└── live.js # Live updates + responsive
|
└── live.js # Live updates + responsive
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -306,3 +307,25 @@ PRs are welcome! Got an idea, a fix, or a feature request — open an issue or s
|
|||||||
## 📄 License
|
## 📄 License
|
||||||
|
|
||||||
GNU Affero General Public License v3.0 (AGPLv3)
|
GNU Affero General Public License v3.0 (AGPLv3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ Roadmap
|
||||||
|
|
||||||
|
### ✅ Done
|
||||||
|
- Timeline grid with live "now" indicator
|
||||||
|
- Mobile responsive list view
|
||||||
|
- Built-in HLS PiP player
|
||||||
|
- HTTP→HTTPS proxy
|
||||||
|
- Theme system (4 built-in themes)
|
||||||
|
- Multi-EPG sources with topbar switcher
|
||||||
|
- Personal EPG (localStorage)
|
||||||
|
- Modular codebase (src/tpl + src/js)
|
||||||
|
- Docker support
|
||||||
|
- **Search** — filter grid by channel name or program title/synopsis
|
||||||
|
|
||||||
|
### 🔜 Coming soon
|
||||||
|
- **Favorites** — pin channels to the top of the grid (localStorage)
|
||||||
|
- **Setup re-editable** — reopen setup.php with an admin key, no SSH required
|
||||||
|
- **Dark/Light auto mode** — follow system preference when using the default theme
|
||||||
|
- **Grid export** — export today's schedule as PDF or image
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 281 KiB After Width: | Height: | Size: 642 KiB |
@@ -0,0 +1,198 @@
|
|||||||
|
// ── SEARCH ────────────────────────────────────────────────────────────────────
|
||||||
|
let searchActive = false;
|
||||||
|
let searchQuery = '';
|
||||||
|
let searchTimer = null;
|
||||||
|
|
||||||
|
function toggleSearch() {
|
||||||
|
const bar = document.getElementById('searchBar');
|
||||||
|
const inp = document.getElementById('searchInput');
|
||||||
|
searchActive = !searchActive;
|
||||||
|
bar.classList.toggle('visible', searchActive);
|
||||||
|
if (searchActive) {
|
||||||
|
inp.focus();
|
||||||
|
} else {
|
||||||
|
clearSearch();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery = '';
|
||||||
|
document.getElementById('searchInput').value = '';
|
||||||
|
applySearch('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearchInput(val) {
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(() => {
|
||||||
|
searchQuery = val.trim().toLowerCase();
|
||||||
|
applySearch(searchQuery);
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySearch(q) {
|
||||||
|
if (!q) {
|
||||||
|
// Remettre toutes les chaines
|
||||||
|
renderAll();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0,0,0,0);
|
||||||
|
const tomorrow = new Date(today.getTime() + 24*3600000);
|
||||||
|
|
||||||
|
// Filtrer les chaines : nom OU programme aujourd'hui correspond
|
||||||
|
const filtered = channels.filter(ch => {
|
||||||
|
// Match sur le nom de la chaine
|
||||||
|
if (ch.name.toLowerCase().includes(q)) return true;
|
||||||
|
// Match sur les programmes d'aujourd'hui (titre + desc)
|
||||||
|
return (programs[ch.id]||[]).some(p => {
|
||||||
|
if (p.stop < today || p.start > tomorrow) return false;
|
||||||
|
if (p.title.toLowerCase().includes(q)) return true;
|
||||||
|
if (p.desc && p.desc.toLowerCase().includes(q)) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFiltered(filtered, q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFiltered(filteredChannels, q) {
|
||||||
|
if (isMobile()) {
|
||||||
|
renderMobileFiltered(filteredChannels, q);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GRID ──
|
||||||
|
const list = document.getElementById('channelsList');
|
||||||
|
const inner = document.getElementById('programsInner');
|
||||||
|
const ROW_H = 80;
|
||||||
|
|
||||||
|
list.innerHTML = '';
|
||||||
|
inner.innerHTML = '';
|
||||||
|
inner.style.width = TOTAL_WIDTH_PX + 'px';
|
||||||
|
inner.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||||
|
|
||||||
|
// Remettre la now-line
|
||||||
|
const nowLine = document.createElement('div');
|
||||||
|
nowLine.className = 'now-line'; nowLine.id = 'nowLine';
|
||||||
|
nowLine.style.height = (filteredChannels.length * ROW_H) + 'px';
|
||||||
|
inner.appendChild(nowLine);
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
filteredChannels.forEach((ch, i) => {
|
||||||
|
// Colonne chaîne
|
||||||
|
const cell = document.createElement('div');
|
||||||
|
cell.className = 'channel-cell';
|
||||||
|
if (ch.icon) {
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.className = 'channel-logo'; img.src = ch.icon;
|
||||||
|
img.onerror = () => img.replaceWith(makePlaceholder(ch.name));
|
||||||
|
cell.appendChild(img);
|
||||||
|
} else { cell.appendChild(makePlaceholder(ch.name)); }
|
||||||
|
const name = document.createElement('div');
|
||||||
|
name.className = 'channel-name'; name.textContent = ch.name; name.title = ch.name;
|
||||||
|
cell.appendChild(name);
|
||||||
|
cell.addEventListener('click', () => openPip(ch.name, getNowPlaying(ch.id)));
|
||||||
|
list.appendChild(cell);
|
||||||
|
|
||||||
|
// Programmes
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'program-row';
|
||||||
|
row.style.cssText = `top:${i*ROW_H}px;position:absolute;left:0;right:0;`;
|
||||||
|
|
||||||
|
(programs[ch.id]||[]).filter(p => p.stop > GRID_START && p.start < GRID_END).forEach(p => {
|
||||||
|
const x = Math.max(0, msToX(p.start.getTime()));
|
||||||
|
const w = Math.min(TOTAL_WIDTH_PX, msToX(p.stop.getTime())) - x;
|
||||||
|
if (w < 2) return;
|
||||||
|
|
||||||
|
const block = document.createElement('div');
|
||||||
|
block.className = 'program-block';
|
||||||
|
|
||||||
|
// Highlight si le programme matche la recherche
|
||||||
|
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
||||||
|
if (matchesProg) block.classList.add('search-match');
|
||||||
|
|
||||||
|
if (p.stop < now) block.classList.add('is-past');
|
||||||
|
if (p.start <= now && p.stop > now) {
|
||||||
|
block.classList.add('is-live');
|
||||||
|
block.style.setProperty('--progress', (((now-p.start)/(p.stop-p.start))*100).toFixed(1)+'%');
|
||||||
|
}
|
||||||
|
block.style.left = x + 'px'; block.style.width = w + 'px';
|
||||||
|
if (w > 40) { const t = document.createElement('div'); t.className = 'program-title'; t.textContent = p.title; block.appendChild(t); }
|
||||||
|
if (w > 80) {
|
||||||
|
const ep = fmtEpisode(p.season, p.episode);
|
||||||
|
const t = document.createElement('div'); t.className = 'program-time';
|
||||||
|
t.textContent = (ep ? ep + ' ' : '') + `${fmtTime(p.start)} — ${fmtTime(p.stop)}`;
|
||||||
|
block.appendChild(t);
|
||||||
|
}
|
||||||
|
block.addEventListener('mouseenter', e => showTooltip(e, p));
|
||||||
|
block.addEventListener('mousemove', e => moveTooltip(e));
|
||||||
|
block.addEventListener('mouseleave', hideTooltip);
|
||||||
|
if (p.start <= now && p.stop > now) {
|
||||||
|
block.addEventListener('click', () => openPip(ch.name, p.title));
|
||||||
|
}
|
||||||
|
row.appendChild(block);
|
||||||
|
});
|
||||||
|
inner.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
positionNowLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMobileFiltered(filteredChannels, q) {
|
||||||
|
const container = document.getElementById('mobileView');
|
||||||
|
container.innerHTML = '';
|
||||||
|
const now = new Date();
|
||||||
|
const winStart = new Date(now.getTime() - 30*60000);
|
||||||
|
const winEnd = new Date(now.getTime() + 4*3600000);
|
||||||
|
|
||||||
|
filteredChannels.forEach(ch => {
|
||||||
|
const progs = (programs[ch.id]||[]).filter(p => p.stop > winStart && p.start < winEnd);
|
||||||
|
if (!progs.length) return;
|
||||||
|
|
||||||
|
const section = document.createElement('div'); section.className = 'mobile-channel';
|
||||||
|
const header = document.createElement('div'); header.className = 'mobile-channel-header';
|
||||||
|
|
||||||
|
if (ch.icon) {
|
||||||
|
const img = document.createElement('img'); img.className = 'channel-logo'; img.src = ch.icon;
|
||||||
|
img.onerror = () => img.replaceWith(makePlaceholder(ch.name));
|
||||||
|
header.appendChild(img);
|
||||||
|
} else { header.appendChild(makePlaceholder(ch.name)); }
|
||||||
|
|
||||||
|
const name = document.createElement('div'); name.className = 'channel-name'; name.textContent = ch.name;
|
||||||
|
header.appendChild(name); section.appendChild(header);
|
||||||
|
|
||||||
|
const list = document.createElement('div'); list.className = 'mobile-programs';
|
||||||
|
progs.forEach(p => {
|
||||||
|
const isLive = p.start <= now && p.stop > now;
|
||||||
|
const isPast = p.stop < now;
|
||||||
|
const dur = Math.round((p.stop - p.start) / 60000);
|
||||||
|
const matchesProg = p.title.toLowerCase().includes(q) || (p.desc && p.desc.toLowerCase().includes(q));
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'mobile-program' + (isLive ? ' is-live' : '') + (isPast ? ' is-past' : '') + (matchesProg ? ' search-match' : '');
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
const bar = document.createElement('div'); bar.className = 'mobile-program-progress';
|
||||||
|
bar.style.width = (((now-p.start)/(p.stop-p.start))*100).toFixed(1) + '%';
|
||||||
|
item.appendChild(bar);
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeCol = document.createElement('div'); timeCol.className = 'mobile-program-time';
|
||||||
|
timeCol.innerHTML = `<span>${fmtTime(p.start)}</span><span>${fmtTime(p.stop)}</span>`;
|
||||||
|
item.appendChild(timeCol);
|
||||||
|
|
||||||
|
const info = document.createElement('div'); info.className = 'mobile-program-info';
|
||||||
|
const ep = fmtEpisode(p.season, p.episode);
|
||||||
|
info.innerHTML = `<div class="mobile-program-title">${p.title}</div><div class="mobile-program-dur">${ep ? ep + ' · ' : ''}${dur} min</div>`;
|
||||||
|
item.appendChild(info);
|
||||||
|
|
||||||
|
if (isLive) {
|
||||||
|
const badge = document.createElement('div'); badge.className = 'mobile-live-badge'; badge.textContent = 'LIVE';
|
||||||
|
item.appendChild(badge);
|
||||||
|
}
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
section.appendChild(list); container.appendChild(section);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ function switchSource(value) {
|
|||||||
currentEpgUrl = src.epg_url;
|
currentEpgUrl = src.epg_url;
|
||||||
currentM3uUrl = src.m3u_url ?? '';
|
currentM3uUrl = src.m3u_url ?? '';
|
||||||
localStorage.setItem('gridtv-active-source', idx);
|
localStorage.setItem('gridtv-active-source', idx);
|
||||||
|
if (typeof clearSearch === "function") clearSearch();
|
||||||
loadEPG(currentEpgUrl);
|
loadEPG(currentEpgUrl);
|
||||||
if (currentM3uUrl) loadM3U(currentM3uUrl);
|
if (currentM3uUrl) loadM3U(currentM3uUrl);
|
||||||
updateCopyButtons();
|
updateCopyButtons();
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
<?php
|
<?php
|
||||||
$js_dir = __DIR__ . '/../js/';
|
$js_dir = __DIR__ . '/../js/';
|
||||||
include $js_dir . 'config.js';
|
include $js_dir . 'config.js';
|
||||||
$js_modules = ['utils', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'live'];
|
$js_modules = ['utils', 'epg', 'mobile', 'tooltip', 'sources', 'm3u', 'player', 'themes', 'search', 'live'];
|
||||||
foreach ($js_modules as $mod) {
|
foreach ($js_modules as $mod) {
|
||||||
echo file_get_contents($js_dir . $mod . '.js');
|
echo file_get_contents($js_dir . $mod . '.js');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -759,6 +759,47 @@
|
|||||||
.pip { width: calc(100vw - 16px); bottom: 8px; right: 8px; left: 8px; }
|
.pip { width: calc(100vw - 16px); bottom: 8px; right: 8px; left: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* ── SEARCH ───────────────────────────────────────────────────────────────── */
|
||||||
|
.search-btn {
|
||||||
|
background: none; border: 1px solid var(--border); border-radius: 4px;
|
||||||
|
color: var(--text-muted); cursor: pointer; font-size: 14px;
|
||||||
|
padding: 4px 8px; transition: color .2s, border-color .2s;
|
||||||
|
}
|
||||||
|
.search-btn:hover { color: var(--accent); border-color: var(--accent); }
|
||||||
|
|
||||||
|
.search-bar {
|
||||||
|
display: none; align-items: center; gap: 8px;
|
||||||
|
background: var(--bg-card); border-bottom: 1px solid var(--border);
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
.search-bar.visible { display: flex; }
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
||||||
|
border-radius: 4px; color: var(--text); font-family: inherit;
|
||||||
|
font-size: 14px; outline: none; padding: 6px 12px;
|
||||||
|
transition: border-color .2s;
|
||||||
|
}
|
||||||
|
.search-input:focus { border-color: var(--accent); }
|
||||||
|
.search-input::placeholder { color: var(--text-muted); }
|
||||||
|
|
||||||
|
.search-clear {
|
||||||
|
background: none; border: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; font-size: 16px; padding: 4px 8px;
|
||||||
|
transition: color .2s;
|
||||||
|
}
|
||||||
|
.search-clear:hover { color: var(--accent); }
|
||||||
|
|
||||||
|
/* Highlight programmes qui matchent */
|
||||||
|
.program-block.search-match {
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
box-shadow: 0 0 6px var(--accent);
|
||||||
|
}
|
||||||
|
.mobile-program.search-match {
|
||||||
|
border-left: 3px solid var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<link id="themeStylesheet" rel="stylesheet" href="themes/default.css">
|
<link id="themeStylesheet" rel="stylesheet" href="themes/default.css">
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ foreach ($theme_files as $file) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
</select>
|
</select>
|
||||||
|
<button class="search-btn" onclick="toggleSearch()" title="Search">🔍</button>
|
||||||
<div class="live-dot">LIVE</div>
|
<div class="live-dot">LIVE</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="search-bar" id="searchBar">
|
||||||
|
<input class="search-input" id="searchInput" type="text" placeholder="Search channel or program..." oninput="onSearchInput(this.value)" />
|
||||||
|
<button class="search-clear" onclick="clearSearch()">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user