Files
server_IPTV/static/index.html
T

3277 lines
132 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<title>Supervision 2</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- BOOTSTRAP 5 -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<script src="https://cdn.jsdelivr.net/npm/@popperjs/core@2.9.2/dist/umd/popper.min.js" integrity="sha384-IQsoLXl5PILFhosVNubq5LC7Qb9DXgDA9i+tQ8Zj3iwWAwPtgFTxbJ8NT4GN1R8p" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery-cookie/1.4.1/jquery.cookie.min.js"></script>
<script src="https://cdn.socket.io/3.0.5/socket.io.js"></script>
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.0/css/all.css" integrity="sha384-lZN37f5QGtY3VHgisS14W3ExzMWZxybE1SJSEsQp9S+oqd12jhcu+A56Ebc1zFSJ" crossorigin="anonymous">
<meta name="apple-mobile-web-app-capable" content="yes">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<link rel="icon" href="static/tv.png">
<link rel="apple-touch-icon" href="static/tv.png">
</head>
<!-- <script src="/static/code.js" defer></script> -->
<style>
:root {
--home-bg: #edf2f7;
--panel-bg: #ffffff;
--panel-border: #dbe3ef;
--panel-muted: #eef2f7;
--panel-primary-soft: #d7e7ff;
--text-main: #111827;
--text-muted: #6b7280;
--success: #1f8b24;
--danger: #dc3545;
--shadow-soft: 0 14px 34px rgba(31, 41, 55, 0.08);
--shadow-card: 0 8px 18px rgba(15, 23, 42, 0.12);
}
body {
background: #f7f8fa;
color: var(--text-main);
}
.btn_play_media:hover {
background-color: rgb(254, 254, 237);
}
/* Sub-dropdown positioning */
.dropdown-submenu {
position: relative;
}
.dropdown-submenu .dropdown-menu {
position: absolute;
left: 100%;
top: 0;
display: none;
margin-top: -1px;
min-width: 150px;
}
/* Show submenu when clicking */
.dropdown-submenu .dropdown-menu.show {
display: block;
}
.lineTable_device {
transition: background-color 0.2s ease, transform 0.2s ease;
padding: 5px;
border-radius: 6px;
}
.lineTable_device:hover {
background-color: #f0f0f0;
cursor: pointer;
transform: translateX(2px);
}
.table-hover-row {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.table-hover-row:hover {
background-color: #f8f9fa;
cursor: pointer;
transition: background-color 0.2s ease;
}
#timeline {
overflow-x: scroll;
white-space: nowrap;
border: 1px solid #ccc;
padding: 10px;
background-color: #f9f9f9;
}
.timeline-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.channel-label {
width: 100px;
font-weight: bold;
margin-right: 10px;
}
.program-block {
display: inline-block;
height: 40px;
background-color: #4caf50;
color: white;
font-size: 12px;
text-align: center;
line-height: 40px;
margin-right: 2px;
padding: 0 5px;
border-radius: 4px;
}
.home-shell {
margin: 18px 14px 26px;
padding: 26px;
background: var(--home-bg);
border: 1px solid var(--panel-border);
border-radius: 36px;
}
.home-layout {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 22px;
align-items: start;
}
.home-main {
display: grid;
gap: 18px;
}
.home-panel {
background: var(--panel-bg);
border: 1px solid rgba(219, 227, 239, 0.9);
border-radius: 28px;
box-shadow: var(--shadow-soft);
padding: 24px;
}
.home-panel-title {
margin: 0 0 18px;
font-size: 1.05rem;
font-weight: 800;
letter-spacing: 0.01em;
}
.device-list {
display: flex;
flex-direction: column;
gap: 18px;
min-height: 100%;
}
.device-card {
display: grid;
gap: 12px;
padding: 16px 18px;
border-radius: 18px;
border: 1px solid #e5e7eb;
background: #fff;
box-shadow: var(--shadow-card);
cursor: pointer;
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.device-card:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px rgba(15, 23, 42, 0.15);
}
.device-card.active {
border-color: #9ec1ff;
background: linear-gradient(180deg, #ffffff 0%, #f7fbff 100%);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12), 0 14px 28px rgba(15, 23, 42, 0.16);
}
.device-card-header,
.device-card-footer,
.selected-device-summary,
.home-toolbar,
.channel-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.device-name {
font-size: 1rem;
font-weight: 500;
min-width: 0;
}
.device-ip {
font-size: 0.75rem;
font-weight: 400;
color: #9ca3af;
}
.device-status {
color: var(--success);
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
}
.device-actions {
display: flex;
align-items: center;
gap: 10px;
color: #9ca3af;
}
.device-action {
border: 0;
background: transparent;
color: inherit;
padding: 0;
}
.device-action.delete-device {
color: #ef4444;
}
.device-channel-pill {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 5px 12px;
border-radius: 8px;
background: #1f8b5b;
color: #fff;
font-size: 0.95rem;
font-weight: 700;
max-width: 100%;
}
.device-channel-pill.is-idle {
background: #f8f9fa;
color: #212529;
border: 1px solid #dee2e6;
}
.device-channel-pill.is-live {
background: #1f8b24;
}
.device-channel-pill.is-frozen {
background: #dc3545;
}
.playlist-dot {
flex: 0 0 auto;
width: 12px;
height: 12px;
border-radius: 999px;
border: 1px solid rgba(17, 24, 39, 0.45);
display: inline-block;
}
.selected-device-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
gap: 30px;
align-items: start;
}
.selected-device-main {
display: grid;
gap: 22px;
align-content: start;
padding-top: 6px;
}
.selected-device-heading {
font-size: 1.2rem;
font-weight: 800;
color: #111827;
margin: 0;
}
.selected-device-pills {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 24px;
flex-wrap: nowrap;
min-width: 0;
}
.selected-device-channel-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.selected-device-inline-status {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: nowrap;
min-width: 0;
flex: 1 1 auto;
}
.selected-device-preview {
border: 1px solid #d8dee8;
border-radius: 18px;
overflow: hidden;
background: #f8fafc;
}
.selected-device-preview-frame {
position: relative;
width: 100%;
min-height: 200px;
background: linear-gradient(135deg, #eeeeef 0%, #f7f7f8 100%);
display: flex;
align-items: center;
justify-content: center;
}
.selected-device-preview img {
width: 100%;
height: auto;
object-fit: contain;
display: block;
}
.selected-device-preview-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
color: #868686;
font-weight: 700;
text-align: center;
font-size: 1rem;
}
.selected-device-box,
.selected-channel-box {
min-height: 50px;
display: flex;
align-items: center;
border-radius: 8px;
padding: 0 20px;
font-size: 1rem;
font-weight: 600;
border: 1px solid #d8dee8;
overflow: hidden;
}
.selected-device-box {
min-width: 146px;
max-width: 146px;
background: #cfd2d7;
color: #111827;
border-color: transparent;
font-size: 1.04rem;
font-weight: 700;
}
.selected-channel-box {
flex: 1 1 auto;
width: auto;
min-width: 0;
background: #cfe0f8;
color: #2e5faa;
border-color: transparent;
font-size: 0.95rem;
}
#streamStatusTag {
border-radius: 999px;
padding: 6px 12px;
font-size: 0.82rem;
font-weight: 700;
flex: 0 0 auto;
}
.control-actions {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
}
.control-actions .btn {
min-height: 52px;
padding: 0 18px;
border-radius: 6px;
font-size: 0.98rem;
font-weight: 500;
}
.snapshot-toggle {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 0 14px;
border: 1px solid #d8dee8;
border-radius: 8px;
background: #fff;
color: #374151;
font-weight: 600;
}
.snapshot-toggle.form-switch {
padding-left: 14px;
}
.snapshot-toggle .form-check-input {
flex: 0 0 auto;
float: none;
width: 2.2em;
height: 1.2em;
margin: 0;
margin-left: 0;
margin-top: 0;
cursor: pointer;
}
.snapshot-toggle .form-check-label {
cursor: pointer;
user-select: none;
white-space: nowrap;
}
.action-label {
margin-left: 8px;
}
.snapshot-toggle-icon {
color: #6b7280;
font-size: 1rem;
line-height: 1;
}
.channel-rail {
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 36px;
align-items: center;
gap: 10px;
margin-bottom: 22px;
}
.scroll-wrapper {
overflow-x: auto;
display: flex;
gap: 14px;
scroll-behavior: smooth;
padding: 4px 0 10px;
scrollbar-width: thin;
}
.channel-card {
flex: 0 0 112px;
text-align: center;
padding: 10px 6px 0;
transition: transform 0.2s ease, box-shadow 0.2s ease;
cursor: pointer;
border-radius: 14px;
}
.channel-logo-wrapper {
width: 96px;
height: 96px;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 10px;
background-color: #fff;
border-radius: 10px;
overflow: hidden;
border: 1px solid #d9e0ea;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.07);
}
.channel-logo {
max-width: 82%;
max-height: 82%;
object-fit: contain;
}
.channel-card:hover {
transform: translateY(-3px);
}
.channel-card-label {
font-size: 0.82rem;
line-height: 1.25;
color: #4b5563;
}
.nav-btn {
cursor: pointer;
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: transparent;
color: #111827;
font-size: 1.7rem;
display: inline-flex;
align-items: center;
justify-content: center;
}
.home-toolbar {
flex-wrap: wrap;
justify-content: flex-start;
margin-bottom: 18px;
}
.home-search {
flex: 1 1 260px;
max-width: 380px;
min-width: 180px;
min-height: 50px;
border-radius: 6px;
}
.home-toolbar .btn,
.pagination .page-link {
border-radius: 6px;
}
.home-toolbar .btn {
min-height: 52px;
padding: 0 18px;
font-size: 0.98rem;
font-weight: 500;
}
.toolbar-label {
margin-left: 8px;
}
.pagination {
margin-bottom: 16px;
}
.pagination .page-link {
color: #2e5faa;
}
.pagination .page-item.active .page-link {
background-color: #2e78ff;
border-color: #2e78ff;
}
.channel-results-list {
border: 1px solid #dde3eb;
border-radius: 16px;
overflow: hidden;
background: #fff;
}
.channel-row {
gap: 16px;
padding: 16px 18px;
border-bottom: 1px solid #eceff3;
background: #fff;
}
.channel-row:last-child {
border-bottom: 0;
}
.channel-row-main {
display: flex;
align-items: center;
gap: 18px;
flex: 1 1 auto;
min-width: 0;
cursor: pointer;
}
.channel-row-main:hover .channel-row-name {
color: #0d6efd;
}
.channel-row-logo {
width: 70px;
height: 42px;
object-fit: contain;
flex: 0 0 auto;
}
.channel-row-name {
font-size: 1rem;
color: #111827;
transition: color 0.18s ease;
}
.channel-delete {
border: 0;
background: transparent;
color: #ff2d20;
font-size: 1.2rem;
padding: 0;
flex: 0 0 auto;
}
.channel-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 14px;
}
.channel-grid-item {
border: 1px solid #dde3eb;
border-radius: 18px;
background: #fff;
box-shadow: 0 8px 16px rgba(15, 23, 42, 0.08);
padding: 18px 14px;
text-align: center;
}
.channel-grid-item .channel-logo-wrapper {
width: 110px;
height: 110px;
}
@media (max-width: 991px) {
.home-layout {
grid-template-columns: 1fr;
}
.home-shell {
padding: 16px;
border-radius: 26px;
}
}
@media (max-width: 767px) {
body {
background: #e7edf5;
}
.home-shell {
margin: 0;
padding: 12px;
background: transparent;
border: 0;
border-radius: 0;
}
.home-layout,
.home-main {
gap: 12px;
}
.home-panel {
padding: 18px;
border-radius: 22px;
min-width: 0;
}
.selected-device-layout {
grid-template-columns: 1fr;
}
.selected-device-pills {
gap: 12px;
flex-wrap: wrap;
}
.channel-rail {
grid-template-columns: 28px minmax(0, 1fr) 28px;
}
.home-toolbar {
gap: 10px;
align-items: center;
}
.home-toolbar .btn {
min-width: 48px;
justify-content: center;
padding: 0 14px;
}
.home-search {
flex: 0 1 170px;
width: auto;
max-width: 170px;
min-width: 120px;
}
.toolbar-label {
display: none;
}
.control-actions .btn {
min-width: 52px;
justify-content: center;
padding: 0 14px;
}
.action-label,
.snapshot-toggle .form-check-label {
display: none;
}
.snapshot-toggle {
min-width: 52px;
justify-content: center;
padding: 0 12px;
}
.channel-row {
padding: 14px;
align-items: center;
flex-wrap: nowrap;
}
.channel-row-main {
gap: 12px;
min-width: 0;
flex: 1 1 auto;
}
.channel-row-name {
font-size: 0.95rem;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.channel-row-logo {
width: 56px;
height: 36px;
flex: 0 0 auto;
}
.channel-delete {
margin-left: auto;
flex: 0 0 auto;
}
.channel-results-list {
overflow: hidden;
}
.list-group-item {
overflow-wrap: anywhere;
word-break: break-word;
}
.channel-rail {
grid-template-columns: 24px minmax(0, 1fr) 24px;
gap: 8px;
margin-bottom: 16px;
}
.channel-card {
flex: 0 0 88px;
padding: 8px 4px 0;
}
.channel-logo-wrapper {
width: 78px;
height: 78px;
margin-bottom: 8px;
}
.channel-card-label {
font-size: 0.76rem;
overflow-wrap: anywhere;
word-break: break-word;
}
.nav-btn {
width: 24px;
height: 24px;
font-size: 1.1rem;
}
}
@media (max-width: 575px) {
.selected-device-preview {
display: none;
}
.device-list {
gap: 0;
border: 1px solid #d8dee8;
border-radius: 14px;
overflow: hidden;
background: #fff;
}
.device-card {
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px 14px;
border: 0;
border-bottom: 1px solid #e9edf3;
border-radius: 0;
box-shadow: none;
background: #fff;
}
.device-card:last-child {
border-bottom: 0;
}
.device-card:hover {
transform: none;
box-shadow: none;
background: #f8fafc;
}
.device-card.active {
background: #eef5ff;
box-shadow: none;
border-color: transparent;
}
.device-card-header,
.device-card-footer {
display: contents;
}
.device-name {
grid-column: 1;
grid-row: 1;
font-size: 0.95rem;
font-weight: 700;
}
.device-actions {
grid-column: 2;
grid-row: 1;
justify-self: end;
gap: 8px;
}
.device-status {
font-size: 0.82rem;
}
.device-channel-pill {
grid-column: 1 / -1;
grid-row: 2;
max-width: 100%;
min-height: 34px;
border-radius: 8px;
font-size: 0.88rem;
}
.device-channel-pill.is-idle {
display: none;
}
.playlist-dot {
display: none;
}
}
/* EPG timeline */
#timeline-wrapper {
border: 1px solid #e0e0e0;
border-radius: 10px;
background: linear-gradient(135deg, #fdfbfb 0%, #ebedee 100%);
box-shadow: 0 10px 25px rgba(0,0,0,0.08);
overflow: hidden;
}
.epg-header {
display: grid;
grid-template-columns: 180px 1fr;
background: #0d6efd;
color: #fff;
padding: 8px 12px;
font-weight: 600;
letter-spacing: 0.5px;
}
.epg-time-grid {
position: relative;
overflow-x: auto;
background: linear-gradient(90deg, rgba(13,110,253,0.05) 0%, rgba(13,110,253,0) 60%);
}
.epg-timebar {
position: relative;
min-height: 52px;
border-bottom: 1px solid #e5e7eb;
}
.epg-timebar .time-slot {
position: absolute;
top: 10px;
font-size: 0.8rem;
color: #0d6efd;
font-weight: 600;
}
.epg-row {
display: grid;
grid-template-columns: 180px 1fr;
border-bottom: 1px solid #f1f1f1;
align-items: stretch;
background: #fff;
}
.epg-row:nth-child(odd) {
background: #fafbff;
}
.epg-row .channel-name {
padding: 10px 12px;
font-weight: 600;
color: #2b2d42;
background: linear-gradient(180deg, #ffffff 0%, #f7f8fb 100%);
border-right: 1px solid #f1f1f1;
}
.epg-row .program-track {
position: relative;
min-height: 62px;
overflow: hidden;
}
.epg-program {
position: absolute;
top: 8px;
height: 46px;
border-radius: 8px;
padding: 6px 10px;
color: #0f172a;
font-size: 0.82rem;
box-shadow: 0 8px 20px rgba(0,0,0,0.08);
display: flex;
flex-direction: column;
justify-content: center;
border: 1px solid rgba(13,110,253,0.15);
background: linear-gradient(135deg, #c7d2fe 0%, #e0e7ff 100%);
}
.epg-program .program-title {
font-weight: 700;
line-height: 1.1;
}
.epg-program .program-time {
font-size: 0.72rem;
color: #334155;
}
.epg-program.current {
background: linear-gradient(135deg, #fed7aa 0%, #fecdd3 100%);
border: 1px solid rgba(239,68,68,0.35);
box-shadow: 0 12px 30px rgba(239,68,68,0.25);
}
.epg-now {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #ef4444;
box-shadow: 0 0 0 3px rgba(239,68,68,0.25);
}
</style>
<body>
<!-- ID CLIENT SOCKET -->
<input id="sidClient" val="" type="hidden">
<input id="id_device" type="hidden" val="">
<input id="sidDevice" type="hidden" >
<!-- PRINCIPAL NAVBAR
<nav class="navbar navbar-expand-lg navbar-light bg-light">
<div class="container-fluid">
<a class="navbar-brand" href="#">IPTV v4</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#"> </a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" id="btn_initdb">Init DB</a>
</li>
</ul>
</div>
</div>
</nav>
-->
<!-- NAVIGATION TABS -->
<ul class="nav nav-tabs">
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="#" id="display_tab_home">Home</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" id="display_tab_playlist">Playlists</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" id="display_tab_guide">Guide</a>
</li>
<li class="nav-item">
<a class="nav-link" href="#" id="display_tab_search">About</a>
</li>
</ul>
<!-- ZONES TABS -->
<!-- ZONE Playlist -->
<div id="tab_playlist">
<div id="list_playlist"></div>
<button type="button" class="btn btn-primary" id="btn_configure_playlist">Add playlist</button>
</div>
<!-- ZONE HOME -->
<div id="tab_home">
<input id="typeDisplay" type="hidden" value="list">
<div class="home-shell">
<div class="home-layout">
<aside class="home-panel">
<h2 class="home-panel-title">Devices</h2>
<div id="listDevices" class="device-list"></div>
</aside>
<div class="home-main">
<section class="home-panel">
<h2 class="home-panel-title">Selected device</h2>
<div id="controlDevice">
<div class="selected-device-layout">
<div class="selected-device-main">
<div class="selected-device-pills">
<div id="NameDevice" class="selected-device-box text-truncate">No device</div>
<div class="selected-device-inline-status">
<div id="channelPlaying" class="selected-channel-box text-truncate">No channel</div>
<span id="streamStatusTag" class="badge bg-secondary" style="display:none;">No data</span>
</div>
</div>
<div class="control-actions">
<button id='btn_display_player' class="btn btn-outline-primary d-flex flex-row align-items-center">
<div><i class="fa fa-arrows-alt"></i></div>
<div class="action-label">Player</div>
</button>
<button onclick='off()' class="btn btn-outline-danger d-flex flex-row align-items-center">
<div><i class="fa fa-stop"></i></div>
<div class="action-label">Stop</div>
</button>
<div class="snapshot-toggle form-check form-switch mb-0">
<span class="snapshot-toggle-icon"><i class="fa fa-camera"></i></span>
<input class="form-check-input" type="checkbox" id="autoSnapshotToggle" checked>
<label class="form-check-label" for="autoSnapshotToggle">Auto snapshot</label>
</div>
<div class="dropdown d-flex align-items-stretch">
<button class="btn btn-outline-secondary dropdown-toggle d-flex flex-row align-items-center" id="navbarDropdown" role="button" data-bs-toggle="dropdown" aria-expanded="false">
<div><i class="fa fa-cog"></i></div>
<div class="action-label">Settings</div>
</button>
<ul class="dropdown-menu" aria-labelledby="navbarDropdown">
<li><a onclick="version()" class="dropdown-item" href="#">Version</a></li>
<li><a onclick="updateSource()" class="dropdown-item" href="#">Update version</a></li>
<li><a onclick="displayDeviceName()" class="dropdown-item" href="#">Change device name</a></li>
<li><a onclick="reboot()" class="dropdown-item" href="#">Reboot</a></li>
<li><a onclick="wifi()" class="dropdown-item" href="#">Wifi</a></li>
<li><a onclick="openEpgSettings()" class="dropdown-item" href="#">Configure EPG</a></li>
<li><a onclick="openSnapshotIntervalSettings()" class="dropdown-item" href="#">Snapshot interval</a></li>
<li><a onclick="openGeminiKeySettings()" class="dropdown-item" href="#">Gemini API Key</a></li>
<li><a onclick="openTerminal()" class="dropdown-item" href="#">Terminal</a></li>
<li><a onclick="takeSnapshot()" class="dropdown-item" href="#">Take Snapshot</a></li>
<li><a onclick="getIP()" class="dropdown-item" href="#">Get IP</a></li>
<li class="dropdown-submenu">
<a class="dropdown-item dropdown-toggle" href="#" id="multisubDropdown">Multisub</a>
<ul class="dropdown-menu">
<li><a onclick="setMultisub(0)" class="dropdown-item" href="#">0</a></li>
<li><a onclick="setMultisub(1)" class="dropdown-item" href="#">1</a></li>
<li><a onclick="setMultisub(2)" class="dropdown-item" href="#">2</a></li>
<li><a onclick="setMultisub(3)" class="dropdown-item" href="#">3</a></li>
<li><a onclick="setMultisub(4)" class="dropdown-item" href="#">4</a></li>
<li><a onclick="setMultisub(5)" class="dropdown-item" href="#">5</a></li>
<li><a onclick="setMultisub(6)" class="dropdown-item" href="#">6</a></li>
<li><a onclick="setMultisub(7)" class="dropdown-item" href="#">7</a></li>
<li><a onclick="setMultisub(8)" class="dropdown-item" href="#">8</a></li>
<li><a onclick="setMultisub(9)" class="dropdown-item" href="#">9</a></li>
<li><a onclick="setMultisub(10)" class="dropdown-item" href="#">10</a></li>
<li><a onclick="setMultisub(11)" class="dropdown-item" href="#">11</a></li>
</ul>
</li>
<li class="dropdown-submenu">
<a class="dropdown-item dropdown-toggle" href="#" id="multisubDropdown">VPN</a>
<ul class="dropdown-menu">
<li><a onclick="management_VPN('connect')" class="dropdown-item" href="#">Connect</a></li>
<li><a onclick="management_VPN('status')" class="dropdown-item" href="#">Status</a></li>
<li><a onclick="management_VPN('disconnect')" class="dropdown-item" href="#">Disconnect</a></li>
</ul>
</li>
</ul>
</div>
</div>
<div id='player' style="display:none;margin-top:3px">
<div class="d-flex flex-row flex-nowrap align-items-center">
<div class="d-flex flex-row" style="height:40px;padding:5px 5px 0 5px;">
<input type="range" min="0" max="100" value="0" id="Range" onchange="changeRange()">
</div>
<span id='valRange' style="border-radius:10px;width:50px;text-align:center;padding:5px 10px 0 10px;background-color:seashell;">0</span>
<button onclick='pause()' class="btn btn-outline-primary d-flex flex-row" style="margin-right:5px;margin-left:5px;">
<div><i class="fa fa-pause"></i></div>
<div style="margin-left:5px;">Pause</div>
</button>
<button onclick='resume()' class="btn btn-outline-primary d-flex flex-row" style="margin-right:5px;">
<div><i class="fa fa-play"></i></div>
<div style="margin-left:5px;">Resume</div>
</button>
<button onclick='off()' class="btn btn-outline-danger d-flex flex-row" style="margin-right:5px;">
<div><i class="fa fa-stop"></i></div>
<div style="margin-left:5px;">Stop</div>
</button>
</div>
</div>
</div>
<div class="selected-device-preview">
<div class="selected-device-preview-frame">
<img id="selectedDeviceSnapshot" src="" alt="Selected device snapshot" style="display:none;">
<div id="selectedDeviceSnapshotPlaceholder" class="selected-device-preview-placeholder">Preview</div>
</div>
</div>
</div>
<div style='margin-top:15px;display:none' id="warning" class="alert alert-warning" role="alert"></div>
</div>
</section>
<section class="home-panel">
<h2 class="home-panel-title">Channel selector</h2>
<div class="channel-rail">
<button class="nav-btn" id="scroll-left" type="button" aria-label="Scroll left">&#x25C0;</button>
<div class="scroll-wrapper" id="channel-scroll"></div>
<button class="nav-btn" id="scroll-right" type="button" aria-label="Scroll right">&#x25B6;</button>
</div>
<div class="home-toolbar">
<button onclick='get_recents()' class="btn btn-outline-primary d-flex flex-row align-items-center">
<div><i class="fa fa-chevron-left"></i></div>
<div class="toolbar-label">Latest</div>
</button>
<input id='input_search' type="text" class="form-control home-search" aria-label="Search channels" placeholder="Search channels">
<button id='btn_search' class="btn btn-outline-success d-flex flex-row align-items-center">
<div><i class="fas fa-search"></i></div>
<div class="toolbar-label">Search</div>
</button>
<button id="btn_ai_search" onclick='openAiSearch()' class="btn btn-outline-info d-flex flex-row align-items-center">
<div><i class="fas fa-robot"></i></div>
<div class="toolbar-label">AI</div>
</button>
<button onclick='$("#typeDisplay").val("list").trigger("change")' class="btn btn-outline-secondary d-flex flex-row align-items-center">
<div><i class="fa fa-list"></i></div>
<div class="toolbar-label">List</div>
</button>
<button onclick='$("#typeDisplay").val("icon").trigger("change")' class="btn btn-outline-secondary d-flex flex-row align-items-center">
<div><i class="fa fa-th-large"></i></div>
<div class="toolbar-label">Icons</div>
</button>
</div>
<div id="pagination-container" class="d-flex justify-content-center"></div>
<div id="listChannels"></div>
</section>
</div>
</div>
</div>
</div>
<!-- ZONE GUIDE -->
<div id="tab_guide">
<div class="container">
<h2 class="mb-4">EPG Viewer <i class="fas fa-tv"></i></h2>
<div class="mb-3">
<label for="channel-select" class="form-label">Select channels to display:</label>
<div class="input-group mb-2">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input id="epg_channel_search" type="text" class="form-control" placeholder="Filter channels...">
</div>
<select id="channel-select" class="form-select" multiple size="8"></select>
<small class="text-muted">Tip: leave empty to show the first 5 channels.</small>
</div>
<div class="d-flex gap-2 mb-3">
<button id="btn_refresh_epg" class="btn btn-primary"><i class="fas fa-sync-alt"></i> Refresh timeline</button>
</div>
<div id="timeline" class="mb-4"></div>
</div>
</div>
<!-- ZONE ABOUT -->
<div id="tab_about">
</div>
<!-- ZONE MODAL CHANGE DEVICE NAME-->
<div class="modal" id="changeNameModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Change Device Name</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<input class="w-50 mb-2 mt-2 form-control" type="text" id="deviceName_forchange">
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button onclick="changeDeviceName()" type="button" class="btn btn-primary">Save changes</button>
</div>
</div>
</div>
</div>
<!-- ZONE MODAL SPINNER -->
<div class="modal fade" id="waitingModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Loading ... </h5>
</div>
<div class="modal-body">
<div class="spinner-border text-primary" style="width: 3rem; height: 3rem;" role="status">
<span class="sr-only">Loading...</span>
</div>
</div>
</div>
</div>
</div>
<!-- ZONE MODAL SNAPSHOT -->
<div class="modal fade" id="modalSnapshot" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Snapshot </h5>
</div>
<div class="modal-body">
<img src="static/snapshot">
</div>
</div>
</div>
</div>
<!-- ZONE MODAL TERMINAL -->
<div class="modal fade" id="modalTerminal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Terminal </h5>
</div>
<div class="modal-body">
<div class="input-group mb-3">
<span class="input-group-text" id="basic-addon1">Command</span>
<input type="text" class="form-control" placeholder="Text Command" aria-label="Command" aria-describedby="basic-addon1" id="txt_command">
</div>
<button type="button" class="btn btn-primary">Send command</button>
<div class="alert alert-secondary" role="alert" id="txt_result_command">
</div>
</div>
</div>
</div>
</div>
<!-- ZONE MODAL EPG SETTINGS -->
<div class="modal fade" id="modal_epg_settings" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">EPG Settings</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="epgSettingsAlert"></div>
<label for="epg_url_input" class="form-label">EPG URL</label>
<input type="text" class="form-control" id="epg_url_input" placeholder="https://example.com/epg.xml">
<div class="form-text">URL used to fetch the program guide.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="btn_save_epg_url">Save</button>
</div>
</div>
</div>
</div>
<div class="modal fade" id="modal_snapshot_interval_settings" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Snapshot Interval</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="snapshotIntervalSettingsAlert"></div>
<label for="snapshot_interval_input" class="form-label">Seconds between 2 snapshots</label>
<input type="number" min="2" step="1" class="form-control" id="snapshot_interval_input" placeholder="10">
<div class="form-text">This value is applied to the currently selected device.</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="btn_save_snapshot_interval">Save</button>
</div>
</div>
</div>
</div>
<!-- MODAL GEMINI API KEY SETTINGS -->
<div class="modal fade" id="modal_gemini_key_settings" tabindex="-1" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-robot"></i> Gemini API Key</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="geminiKeyAlert"></div>
<label for="gemini_key_input" class="form-label">Google Gemini API Key</label>
<input type="password" class="form-control" id="gemini_key_input" placeholder="AIza...">
<div class="form-text">Used for AI TV program search with Google Search Grounding.</div>
<div class="form-check form-switch mt-3">
<input class="form-check-input" type="checkbox" id="toggle_ai_button" checked>
<label class="form-check-label" for="toggle_ai_button">Show AI button in toolbar</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="btn_save_gemini_key">Save</button>
</div>
</div>
</div>
</div>
<!-- MODAL AI SEARCH -->
<div class="modal fade" id="modal_ai_search" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-robot"></i> AI TV Search</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="aiSearchAlert"></div>
<div class="input-group mb-3">
<input type="text" class="form-control" id="ai_search_input" placeholder="Ex: À quelle heure passe Le Journal de TF1 ce soir ?" aria-label="AI search query">
<button class="btn btn-outline-info" type="button" id="btn_ai_search_submit"><i class="fas fa-paper-plane"></i> Ask</button>
</div>
<div id="ai_search_result" style="display:none;">
<hr>
<div id="ai_search_result_body" class="p-2" style="white-space: pre-wrap; max-height: 400px; overflow-y: auto;"></div>
<div id="ai_search_sources" class="mt-2 small text-muted"></div>
</div>
<div id="ai_search_spinner" style="display:none;" class="text-center py-3">
<div class="spinner-border text-info" role="status"><span class="visually-hidden">Loading...</span></div>
<div class="mt-2 text-muted">Searching with Gemini + Google...</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- ZONE MODAL PROGRESS -->
<div class="modal fade" id="progressModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-sm" role="document">
<div class="modal-content">
<div class="modal-header">
<h5 id='titleProgress' class="modal-title">Downloading ... </h5>
</div>
<div class="modal-body">
<div id='idProgressBar' class="progress-bar" style="width:0%">0%</div>
</div>
</div>
</div>
</div>
</div>
<!-- MODAL EDIT PLAYLISTS -->
<div class="modal" tabindex="-1" id="modal_add_playlist">
<div class="modal-dialog">
<form id="upload-form">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="playlist_modal_title">Edit Playlist</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<!-- Input ID PLAYLIST-->
<input type="hidden" name="id_playlist_to_action" id="id_playlist_to_action" value="">
<!-- Input Title-->
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text" id="addon-wrapping">Title</span>
</div>
<input id='playlist_title' type="text" class="w-50 form-control" placeholder="Title" aria-label="Title" aria-describedby="addon-wrapping">
<div class="m-2 invalid-feedback">Title empty !</div>
<div class="m-2 valid-feedback">Title ok</div>
</div>
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text">Type</span>
</div>
<select id="playlist_type" class="w-50 form-select">
<option value="m3u">M3U playlist</option>
<option value="xtream_codes">Xtream Codes</option>
</select>
</div>
<!-- Input Url-->
<div class="input-group p-2 playlist-source playlist-source-m3u">
<div class="input-group-prepend">
<span class="input-group-text" id="addon-wrapping">Original URL</span>
</div>
<input id='playlist_url' type="text" class="w-50 form-control" placeholder="URL" aria-label="URL" aria-describedby="addon-wrapping">
<div class="m-2 invalid-feedback">URL not correct</div>
<div class="m-2 valid-feedback">URL ok</div>
</div>
<div class="playlist-source playlist-source-xtream" style="display:none;">
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text">Server</span>
</div>
<input id="xtream_server" type="text" class="w-50 form-control" placeholder="http://provider.tld:8080">
<div class="m-2 invalid-feedback">Server required</div>
<div class="m-2 valid-feedback">Server ok</div>
</div>
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text">Username</span>
</div>
<input id="xtream_username" type="text" class="w-50 form-control" placeholder="Username">
<div class="m-2 invalid-feedback">Username required</div>
<div class="m-2 valid-feedback">Username ok</div>
</div>
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text">Password</span>
</div>
<input id="xtream_password" type="password" class="w-50 form-control" placeholder="Password">
<div class="m-2 invalid-feedback">Password required</div>
<div class="m-2 valid-feedback">Password ok</div>
</div>
</div>
<!-- Date End -->
<div class="input-group p-2">
<div class="input-group-prepend">
<span class="input-group-text" id="addon-wrapping">End Date</span>
</div>
<input id='playlist_end_date' type="date" class="w-50 form-control" placeholder="Date End" aria-label="Date end" aria-describedby="addon-wrapping">
<div class="m-2 invalid-feedback">Date not correct</div>
<div class="m-2 valid-feedback">Date ok</div>
</div>
<div class="p-2">
<div class="d-flex align-items-center justify-content-between gap-2 mb-2">
<label class="form-label mb-0" for="playlist_comment">Comment</label>
<button type="button" class="btn btn-outline-secondary btn-sm" id="btn_update_playlist_comment" disabled>Update Comment</button>
</div>
<textarea id="playlist_comment" class="form-control" rows="3" placeholder="Additional information about this playlist"></textarea>
</div>
<!-- Input Color Tag -->
<div class="p-2">
<label class="form-label d-block">Playlist Color</label>
<div class="d-flex gap-2" id="color-selector">
<!-- Repeat this block for each color -->
<input type="radio" class="btn-check" name="playlist_color" id="color-red" value="#e74c3c" autocomplete="off">
<label class="btn rounded-circle border border-secondary" for="color-red"
style="width: 32px; height: 32px; background-color: #e74c3c;"></label>
<input type="radio" class="btn-check" name="playlist_color" id="color-orange" value="#e67e22" autocomplete="off">
<label class="btn rounded-circle border border-secondary" for="color-orange"
style="width: 32px; height: 32px; background-color: #e67e22;"></label>
<input type="radio" class="btn-check" name="playlist_color" id="color-yellow" value="#f1c40f" autocomplete="off">
<label class="btn rounded-circle border border-secondary" for="color-yellow"
style="width: 32px; height: 32px; background-color: #f1c40f;"></label>
<input type="radio" class="btn-check" name="playlist_color" id="color-green" value="#2ecc71" autocomplete="off">
<label class="btn rounded-circle border border-secondary" for="color-green"
style="width: 32px; height: 32px; background-color: #2ecc71;"></label>
<input type="radio" class="btn-check" name="playlist_color" id="color-blue" value="#3498db" autocomplete="off">
<label class="btn rounded-circle border border-secondary" for="color-blue"
style="width: 32px; height: 32px; background-color: #3498db;"></label>
<input type="radio" class="btn-check" name="playlist_color" id="color-purple" value="#9b59b6" autocomplete="off">
<label class="btn rounded-circle border border-secondary" for="color-purple"
style="width: 32px; height: 32px; background-color: #9b59b6;"></label>
</div>
<div class="form-text mt-1">Choose a color tag</div>
</div>
<!-- INPUT FILE -->
<div class="d-flex flex-row bd-highlight mb-3 p-2 playlist-source playlist-source-m3u">
<input type="file" id="file-input" name="file" />
</div>
<div class="px-2 pb-2 text-muted small playlist-source playlist-source-m3u">
You can either select a local `.m3u` file or leave the file empty and import directly from the Original URL.
</div>
<div class="px-2 pb-2 text-muted small playlist-source playlist-source-xtream" style="display:none;">
Xtream Codes import will save the playlist and fetch live streams, movies, and series episodes directly from the provider API.
</div>
<!-- PROGRESS FILE -->
<div id="progress-container" style="display: none;">
<progress class="progress-bar" role="progressbar" id="progress-bar" value="0" max="100"></progress>
<span id="progress-text">0%</span>
</div>
<input type='hidden' id="fileName">
<p id="response"></p>
<!-- ALERT -->
<div id="add_playlist_alert"></div>
<div class="modal-footer">
<button class="btn btn-primary" type="button" id="upload-button">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- MODAL CONFIRMATON DELETE PLAYLIST -->
<div class="modal" tabindex="-1" id="modal_confirmation">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Modal title</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Confirmation delete playlist ?</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" id="btn_confirmation_delete_playlist" class="btn btn-danger">Delete</button>
</div>
</div>
</div>
</div>
<!-- MODAL NO DEVICE SELECTED -->
<div class="modal fade" id="noDeviceModal" tabindex="-1" aria-labelledby="noDeviceModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="noDeviceModalLabel">Warning</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
No device has been selected! Please select a device before proceeding.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
</body>
</html>
<!-- Style -->
<style>
img.loaded {
opacity: 1;
}
.card_device {
margin:15px;
width: 12rem;
}
.card_device:hover {
border-color: #007bff;
transform: scale(1.05); /* Slight zoom effect */
}
</style>
<script>
var quote = String.fromCharCode(39)
var list_playlist = []
var result_search = []
var changePosition = false
var epgChannels = []
var deviceProgressState = {}
var PROGRESS_STALE_MS = 15000
var playlistImportProgress = 0
var playlistImportInFlight = false
var selectedDeviceSnapshotIntervalSeconds = 10
var socket = io('https://iptv.mrk.ovh', {
secure: true,
rejectUnauthorized: false // Only for self-signed certificates
});
socket.on('connect', function() {
$('#sidClient').val(socket.id)
console.log("client connected sid = " + $('#sidClient').val())
});
socket.on('communication', function(data) {
console.log('communication', data)
// RECEPTION RETURN WARNING
if (data['typeCommand'] == 'warning') {
$('#warning').html(data['payload'])
$('#warning').show('slow')
setTimeout(() => { $('#warning').hide('slow') }, 5000);
}
// RECEPTION RETURN PLAYING
if (data['typeCommand'] == 'playing') {
if (changePosition) {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "moveto", payload: $('#Range').val(), "packet":0});
changePosition = false
}
var payload = data['payload']
var percentage = Math.round(payload['percentage']*100)/100
$('#channelPlaying').html(payload['name'])
$('#valRange').html(percentage)
$('#Range').val(Math.round(payload['percentage']) )
updateListDevices()
}
// PROGRESS BAR DISPLAY
if (data['typeCommand'] == 'progress_analysis') {
$('#progress-container').show()
var payload = data['payload']
var percentage = Number(payload['percentage'] || 0)
if (!Number.isFinite(percentage)) {
percentage = 0
}
percentage = Math.max(playlistImportProgress, Math.min(1, percentage))
playlistImportProgress = percentage
var displayPercent = Math.round(percentage * 100)
$("#progress-bar").val(displayPercent);
$("#progress-text").text(displayPercent + "%");
if (payload['completed']) {
if (payload['id_playlist']) {
$('#id_playlist_to_action').val(payload['id_playlist']);
}
if (payload['error']) {
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">' + payload['error'] + '</div>');
} else {
var num_channel = payload['num_channel'] || 0;
if (num_channel > 0) {
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Imported items: ' + String(num_channel) + '</div>');
update_list_playlist();
$('#modal_add_playlist').modal('hide');
} else {
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">No items returned by Xtream Codes.</div>');
}
}
$("#progress-container").hide();
playlistImportInFlight = false;
playlistImportProgress = 0;
$('#upload-button').prop('disabled', false).text('Save');
}
}
})
function computeProgressStatus(device) {
var idDevice = device.id_device;
if (!idDevice) {
return {label: 'No data', className: 'bg-secondary'};
}
var tracker = deviceProgressState[idDevice] || {lastProgress: null, lastChange: null, lastSeen: null};
var progressValue = device.data_running && device.data_running.progress;
progressValue = Number(progressValue);
if (!Number.isFinite(progressValue)) {
progressValue = null;
}
var now = Date.now();
var label = 'No data';
var className = 'bg-secondary';
if (progressValue !== null) {
if (tracker.lastProgress === null || tracker.lastProgress !== progressValue) {
tracker.lastChange = now;
}
tracker.lastSeen = now;
tracker.lastProgress = progressValue;
var stale = tracker.lastChange ? now - tracker.lastChange : PROGRESS_STALE_MS + 1;
if (stale <= PROGRESS_STALE_MS) {
label = 'Live';
className = 'bg-success';
} else {
label = 'Frozen';
className = 'bg-danger';
}
} else if (tracker.lastChange) {
var staleExisting = now - tracker.lastChange;
if (staleExisting <= PROGRESS_STALE_MS) {
label = 'Live';
className = 'bg-success';
} else {
label = 'Frozen';
className = 'bg-danger';
}
}
deviceProgressState[idDevice] = tracker;
return {label: label, className: className};
}
function renderStreamBadge(status) {
return `<span class="badge ${status.className}">${status.label}</span>`;
}
function getPlaylistDot(color) {
if (!color || color === '#cccccc') {
return '';
}
return `<span class="playlist-dot" style="background-color:${color};"></span>`;
}
function updateCurrentDeviceStream(status, hasChannel) {
var $tag = $('#streamStatusTag');
if (!hasChannel) {
$tag.hide();
return;
}
$tag.show();
$tag.removeClass('bg-success bg-danger bg-secondary').addClass(status.className);
$tag.text(status.label);
}
function updateSelectedDeviceSnapshot(device, hasChannel) {
var $image = $('#selectedDeviceSnapshot');
var $placeholder = $('#selectedDeviceSnapshotPlaceholder');
if (!device) {
$image.hide().attr('src', '');
$placeholder.text('Preview').show();
return;
}
if (!hasChannel) {
$image.hide().attr('src', '');
$placeholder.text('Preview').show();
return;
}
if (!device.snapshot_url) {
$image.hide().attr('src', '');
$placeholder.text('Preview').show();
return;
}
var refreshToken = device.snapshot_updated_at || Date.now();
$image.attr('src', device.snapshot_url + '?t=' + refreshToken).show();
$placeholder.hide();
}
function updateAutoSnapshotToggle(device) {
var $toggle = $('#autoSnapshotToggle');
if (!device || !device.id_device) {
$toggle.prop('checked', true).prop('disabled', true);
return;
}
var enabled = device.auto_snapshot_enabled !== false;
$toggle.prop('checked', enabled).prop('disabled', false);
}
function updateSnapshotIntervalState(device) {
if (!device || !device.id_device) {
selectedDeviceSnapshotIntervalSeconds = 10;
return;
}
var interval = Number(device.snapshot_interval_seconds);
if (!Number.isFinite(interval) || interval < 2) {
interval = selectedDeviceSnapshotIntervalSeconds;
}
selectedDeviceSnapshotIntervalSeconds = interval;
}
function resetPlaylistModal() {
$('#upload-form')[0].reset();
$('#id_playlist_to_action').val('');
$('#fileName').val('');
$('#add_playlist_alert').html('');
$('#progress-container').hide();
$('#progress-bar').val(0);
$('#progress-text').text('0%');
playlistImportProgress = 0;
playlistImportInFlight = false;
$('#upload-button').prop('disabled', false).text('Save');
$('#btn_update_playlist_comment').prop('disabled', true);
$('#playlist_title, #playlist_url, #playlist_end_date, #xtream_server, #xtream_username, #xtream_password, #playlist_comment').removeClass('is-valid is-invalid');
$('#color-selector').removeClass('border border-danger rounded p-1');
$('input[name="playlist_color"]').prop('checked', false);
$('#playlist_type').val('m3u');
togglePlaylistSourceFields();
}
function togglePlaylistSourceFields() {
var playlistType = $('#playlist_type').val();
$('.playlist-source-m3u').toggle(playlistType === 'm3u');
$('.playlist-source-xtream').toggle(playlistType === 'xtream_codes');
}
function openPlaylistModal(mode, playlist) {
resetPlaylistModal();
$('#playlist_modal_title').text(mode === 'edit' ? 'Edit Playlist' : 'Add Playlist');
if (playlist) {
$('#id_playlist_to_action').val(playlist.id_playlist || '');
$('#playlist_title').val(playlist.title_playlist || '');
$('#playlist_url').val(playlist.url_playlist || '');
$('#playlist_end_date').val(playlist.date_end_playlist || '');
$('#playlist_comment').val(playlist.comment || '');
$('#playlist_type').val(playlist.playlist_type || 'm3u');
$('#xtream_server').val(playlist.xtream_server || '');
$('#xtream_username').val(playlist.xtream_username || '');
$('#xtream_password').val(playlist.xtream_password || '');
$('#btn_update_playlist_comment').prop('disabled', !(playlist.id_playlist || '').length);
if (playlist.selectedColor) {
$(`input[name="playlist_color"][value="${playlist.selectedColor}"]`).prop('checked', true);
}
}
togglePlaylistSourceFields();
$('#modal_add_playlist').modal('show');
}
$(document).ready(function () {
applyAiButtonVisibility();
$(window).on('scroll resize', lazyLoad);
updateListDevices()
setInterval(updateListDevices, 5000)
update_list_playlist()
display_most_frequent()
setInterval(display_most_frequent, 15000)
$('#btn_save_epg_url').click(saveEpgUrl)
$('#btn_save_snapshot_interval').click(saveSnapshotInterval)
$('#btn_refresh_epg').click(buildEpgTimeline)
$('#epg_channel_search').on('input', function(){ renderEpgChannelOptions($(this).val()); })
$('#playlist_type').on('change', togglePlaylistSourceFields)
$('#autoSnapshotToggle').on('change', function() {
if ($('#sidDevice').val().length === 0) {
$(this).prop('checked', !$(this).prop('checked'));
$('#noDeviceModal').modal('show');
return;
}
socket.emit('communication', {
fromSidDevice: $('#sidClient').val(),
toSidDevice: $('#sidDevice').val(),
typeCommand: 'toggleAutoSnapshot',
payload: $(this).is(':checked'),
packet: 0
});
}).prop('disabled', true);
});
// INITIALISAITON DES TABS
$('#tab_home').show()
$('#tab_about').hide()
$('#tab_playlist').hide()
$('#tab_guide').hide()
// Click search guide
$('#btn_search_channel_guide').click(function(){
var keyword = $('#input_search_channel_guide').val()
if (keyword.length>0) {
$.post('list_channel_guide', {keyword:encodeURIComponent(keyword)}).done(function(payload){
$('#list_channels_guide').html(payload)
})
}
})
// CLICK TAB HOME
$('#display_tab_home').click(function(){
var triggerTab = new bootstrap.Tab($('#display_tab_home'));
triggerTab.show();
$('#tab_home').show()
$('#tab_about').hide()
$('#tab_playlist').hide()
$('#tab_guide').hide()
get_search_channel(1)
})
// CLICK TAB PLAYLIST
$('#display_tab_playlist').click(function(){
var triggerTab = new bootstrap.Tab($('#display_tab_playlist'));
triggerTab.show();
$('#tab_home').hide()
$('#tab_about').hide()
$('#tab_playlist').show()
$('#tab_guide').hide()
})
// CLICK TAB About
$('#display_tab_about').click(function(){
var triggerTab = new bootstrap.Tab($('#display_tab_about'));
triggerTab.show();
$('#tab_home').hide()
$('#tab_about').show()
$('#tab_playlist').hide()
$('#tab_guide').hide()
})
// CLICK TAB GUIDE
$('#display_tab_guide').click(function(){
var triggerTab = new bootstrap.Tab($('#display_tab_guide'));
triggerTab.show();
$('#tab_home').hide()
$('#tab_about').hide()
$('#tab_playlist').hide()
$('#tab_guide').show()
loadEpgChannels();
})
$('#btn_search').click(function(){
get_search_channel(1)
})
$('#btn_initdb').click(function(){
$.get('init_db').done(function(data){
window.location.href = "/"
})
})
function display_most_frequent() {
$.get('most_recents').done(function(rawData){
const data = JSON.parse(rawData);
$('#channel-scroll').empty();
data.forEach(item => {
const idChannel = item.id_channel || item._id;
var playlist = list_playlist.find(p => p.id_playlist === item.id_playlist);
var color = playlist?.selectedColor || '#cccccc';
var colorCircle = getPlaylistDot(color);
$('#channel-scroll').append(`
<div class="channel-card clickable-card"
data-url="${item.url}"
data-name="${item.name}"
data-id_playlist="${item.id_playlist}"
data-id="${idChannel}">
<div class="channel-logo-wrapper">
<img src="logos/${idChannel}.jpg" alt="${item.name}" class="channel-logo"
onerror="this.onerror=null; this.src='get_logo?id_channel=${idChannel}&id_playlist=${item.id_playlist}';">
</div>
<div class="channel-card-label">
${item.name}<div style="margin-top:6px;">${colorCircle}</div>
</div>
</div>
`);
});
const scrollAmount = 140;
$('#scroll-left').off('click').on('click', () => {
$('#channel-scroll').scrollLeft($('#channel-scroll').scrollLeft() - scrollAmount);
});
$('#scroll-right').off('click').on('click', () => {
$('#channel-scroll').scrollLeft($('#channel-scroll').scrollLeft() + scrollAmount);
});
})
}
$(document).on('click', '.clickable-card', function () {
const url = $(this).data('url');
const name = $(this).data('name');
const id_channel = $(this).data('id');
const id_playlist = $(this).data('id_playlist');
if ($('#sidDevice').val().length==0) {
$('#noDeviceModal').modal('show');
} else {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "on", payload: {name:name, url:url, id_channel:id_channel, id_playlist:id_playlist}, "packet":0});
}
});
function isInViewport(element) {
const rect = element[0].getBoundingClientRect();
return (
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
rect.right <= (window.innerWidth || document.documentElement.clientWidth)
);
}
function lazyLoad() {
$('.lazy').each(function () {
const img = $(this);
if (isInViewport(img) && !img.hasClass('loaded')) {
img.attr('src', img.data('src')); // Load the image
img.on('load', function () {
img.addClass('loaded'); // Add loaded class for fade-in
});
}
});
}
// DISPLAY CHANNELS
function displayResult() {
$('#listChannels').show()
if ($('#typeDisplay').val() == 'list') html = '<div class="channel-results-list">'
else html = '<div class="channel-grid">'
console.log('result_search',result_search)
result_search.forEach(element => {
var id_channel = element['id_channel']
var name_channel = element['Name']
var url_channel = element['url']
var id_playlist = element['id_playlist']
var url_channel_macos = url_channel.replace("http://", 'rtmp://')
var oid = ''
var class_display = 'style="display:none"'
if ('oid' in element) {
oid = element['oid']
class_display = ""
}
// Find the color from list_playlist based on id_playlist
var playlist = list_playlist.find(p => p.id_playlist === id_playlist);
var color = playlist?.selectedColor || '#cccccc'; // Default color if not found
console.log('color', color, 'id_playlist', id_playlist, 'playlist', playlist)
var colorCircle = getPlaylistDot(color);
//url_channel_macos = url_channel.replace("https://", 'rtmp://')
if ($('#typeDisplay').val() == 'list') {
html += `<div class="channel-row">
<div class="channel-row-main btn_play_media"
data-url_channel="${url_channel}"
data-name_channel="${name_channel}"
data-id_channel="${id_channel}"
data-id_playlist="${id_playlist}">
<img src="logos/${id_channel}.jpg" class="channel-row-logo"
onerror="this.onerror=null; this.src='get_logo?id_channel=${id_channel}&id_playlist=${id_playlist}';">
<span class="channel-row-name text-truncate">${name_channel}</span>
${colorCircle}
</div>
<button ${class_display} class="channel-delete" type="button" onclick="event.stopPropagation(); delete_recent('${oid}')">
<i class="fas fa-trash"></i>
</button>
</div>
`
} else {
html += `<div class="channel-grid-item">
<div class="channel-logo-wrapper">
<img class="channel-logo" src="logos/${id_channel}.jpg"
onerror="this.onerror=null; this.src='get_logo?id_channel=${id_channel}&id_playlist=${id_playlist}';" alt="${name_channel}">
</div>
<p class="btn_play_media card-text mb-3"
data-url_channel="${url_channel}"
data-name_channel="${name_channel}"
data-id_channel="${id_channel}"
data-id_playlist="${id_playlist}">${name_channel}</p>
<div class="d-flex justify-content-center align-items-center gap-2">${colorCircle}</div>
<div class="d-flex justify-content-center gap-2 mt-3">
<button data-display_url="${url_channel}" class="btn_display_url btn btn-sm btn-outline-info">url</button>
<a class="btn btn-sm btn-primary" href="vlc://${url_channel_macos}">vlc ios</a>
</div>
</div>`
}
})
if ($('#typeDisplay').val() == 'list') html += '</div>'
else html += '</div>'
$('#listChannels').html(html)
$('.btn_play_media').click(function(){
var name = $(this).data('name_channel')
var url = $(this).data('url_channel')
var id_channel = $(this).data('id_channel')
var id_playlist = $(this).data('id_playlist')
if ($('#sidDevice').val().length==0) {
$('#noDeviceModal').modal('show');
} else {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "on", payload: {name:name, url:url, id_channel:id_channel, id_playlist:id_playlist}, "packet":0});
}
})
$('.btn_display_url').click(function() {
var url = $(this).data('display_url')
alert(url)
})
}
function delete_recent(oid) {
$.post('delete_recent', {oid:oid}).done(function(data){
get_recents()
display_most_frequent()
})
}
// GET RESULT SEARCH
function get_search_channel(num_page) {
var keyword = $('#input_search').val()
if (keyword.length>0) {
$.post('list_channel', {keyword:encodeURIComponent(keyword), num_page:num_page}).done(function(payload){
$('#list_channels').html('')
var html =''
num_total_page = payload.num_total_page
current_page = payload.current_page
result_search = payload.data
console.log(num_total_page, current_page)
renderPagination(num_total_page, current_page);
displayResult()
lazyLoad();
})
}
}
// RENDER PAGINATION
function renderPagination(num_max_page, current_page = 1) {
const $container = $('#pagination-container');
$container.empty();
const $nav = $('<nav aria-label="Page navigation"></nav>');
const $ul = $('<ul class="pagination justify-content-center"></ul>');
const max_visible_pages = 5; // number of pages to show around current
const createPageItem = (page, isActive = false, isDisabled = false, text = null) => {
if (isDisabled) {
return $(`
<li class="page-item disabled">
<span class="page-link">${text || page}</span>
</li>
`);
}
return $(`
<li class="page-item ${isActive ? 'active' : ''}">
<a class="page-link" href="#" data-page="${page}">${text || page}</a>
</li>
`);
};
const pages = [];
if (num_max_page <= max_visible_pages + 4) {
// If total pages are few, show all
for (let i = 1; i <= num_max_page; i++) {
pages.push(i);
}
} else {
pages.push(1); // first page
if (current_page > 3) {
pages.push('...');
}
const start = Math.max(2, current_page - 1);
const end = Math.min(num_max_page - 1, current_page + 1);
for (let i = start; i <= end; i++) {
pages.push(i);
}
if (current_page < num_max_page - 2) {
pages.push('...');
}
pages.push(num_max_page); // last page
}
pages.forEach(p => {
if (p === '...') {
$ul.append(createPageItem(null, false, true, '...'));
} else {
$ul.append(createPageItem(p, p === current_page));
}
});
$nav.append($ul);
$container.append($nav);
$ul.find('a.page-link').on('click', function (e) {
e.preventDefault();
const page = parseInt($(this).data('page'));
if (!isNaN(page) && page >= 1 && page <= num_max_page) {
console.log('reloading page', page);
get_search_channel(page);
}
});
}
// GET RESULT RECENTS
function get_recents() {
$.get('recents').done(function(data){
result_search = []
data_json = JSON.parse(data)
data_json.forEach(element => {
var id_channel = element['id_channel']
var name_channel = element['name_channel']
var url_channel = element['url_channel']
var url_logo = element['url_logo']
var id_playlist = element['id_playlist']
var _id = element['_id']
var selectedColor = element['selectedColor'] || '#cccccc'; // Default color if not found
result_search.push({oid:_id['$oid'], id_playlist:id_playlist, id_channel:id_channel, Name:name_channel, url:url_channel, selectedColor:selectedColor})
})
displayResult()
})
}
function renderEpgChannelOptions(filterText) {
var search = (filterText || '').toLowerCase();
var selectedBefore = $('#channel-select').val() || [];
$('#channel-select').empty();
epgChannels
.filter(function (ch) {
return ch.name.toLowerCase().includes(search);
})
.forEach(function (ch) {
var $opt = $(`<option value="${ch.id}">${ch.name}</option>`);
if (selectedBefore.includes(ch.id)) {
$opt.prop('selected', true);
}
$('#channel-select').append($opt);
});
}
function loadEpgChannels() {
$('#channel-select').empty();
$('#timeline').html('<div class="alert alert-info">Loading channels...</div>');
$.get('/channels_epg')
.done(function (resp) {
epgChannels = resp.channels || [];
renderEpgChannelOptions($('#epg_channel_search').val());
buildEpgTimeline();
if (epgChannels.length === 0) {
$('#timeline').html('<div class="alert alert-warning">No channels found in the EPG feed.</div>');
}
})
.fail(function () {
$('#timeline').html('<div class="alert alert-danger">Unable to load channels from the EPG feed.</div>');
});
}
function getSelectedEpgChannels() {
var selected = $('#channel-select').val() || [];
if (selected.length === 0) {
return epgChannels.slice(0, 5);
}
return epgChannels.filter(function (ch) { return selected.includes(ch.id); });
}
function buildEpgTimeline() {
var channelsToLoad = getSelectedEpgChannels();
if (channelsToLoad.length === 0) {
$('#timeline').html('<div class="alert alert-warning">No channels selected.</div>');
return;
}
$('#timeline').html('<div class="alert alert-secondary">Building timeline...</div>');
var requests = channelsToLoad.map(function (ch) {
return new Promise(function (resolve) {
$.get('/epg', { channel: ch.id })
.done(function (resp) { resolve({ channel: ch, programs: resp.programs || [] }); })
.fail(function () { resolve({ channel: ch, programs: [] }); });
});
});
Promise.all(requests).then(renderEpgTimeline);
}
function renderEpgTimeline(data) {
var now = new Date();
var minStart = new Date(8640000000000000); // sentinel max
var maxStop = new Date(0); // sentinel min
var totalPrograms = 0;
var currentFocusLeft = null;
data.forEach(function (entry) {
entry.programs.forEach(function (p) {
var s = new Date(p.start);
var e = new Date(p.stop);
if (s < minStart) minStart = s;
if (e > maxStop) maxStop = e;
totalPrograms += 1;
});
});
if (totalPrograms === 0) {
$('#timeline').html('<div class="alert alert-warning">No EPG programmes available for the selected channels.</div>');
return;
}
// If nothing updated the sentinels, fall back around "now"
if (minStart.getTime() === 8640000000000000) {
minStart = new Date(now.getTime() - 30 * 60000);
maxStop = new Date(now.getTime() + 4 * 3600000);
} else {
// Add padding to give context around edges
minStart = new Date(minStart.getTime() - 15 * 60000);
maxStop = new Date(maxStop.getTime() + 15 * 60000);
}
var minutesTotal = Math.max(60, (maxStop - minStart) / 60000); // at least 1 hour
var pxPerMinute = 2.5;
var timelineWidth = minutesTotal * pxPerMinute;
var stepMinutes = 30;
var $wrapper = $('<div id="timeline-wrapper"></div>');
$wrapper.append('<div class="epg-header"><div>Channel</div><div>Timeline</div></div>');
var $timeGrid = $('<div class="epg-time-grid"></div>');
var $timeBar = $('<div class="epg-timebar"></div>').css('width', timelineWidth + 'px');
for (var m = 0; m <= minutesTotal; m += stepMinutes) {
var slotLeft = m * pxPerMinute;
var slotTime = new Date(minStart.getTime() + m * 60000);
var label = slotTime.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var $slot = $('<div class="time-slot"></div>').css('left', slotLeft + 'px').text(label);
$timeBar.append($slot);
}
var nowOffset = (now - minStart) / 60000 * pxPerMinute;
if (nowOffset >= 0 && nowOffset <= timelineWidth) {
var $nowLine = $('<div class="epg-now"></div>').css('left', nowOffset + 'px');
$timeBar.append($nowLine);
}
$timeGrid.append($timeBar);
data.forEach(function (entry) {
var $row = $('<div class="epg-row"></div>');
$row.append(`<div class="channel-name">${entry.channel.name}</div>`);
var $track = $('<div class="program-track"></div>').css('width', timelineWidth + 'px');
if (nowOffset >= 0 && nowOffset <= timelineWidth) {
$track.append($('<div class="epg-now"></div>').css('left', nowOffset + 'px'));
}
entry.programs.forEach(function (p) {
var start = new Date(p.start);
var stop = new Date(p.stop);
// Clamp to visible window
if (stop <= minStart || start >= maxStop) {
return;
}
var startClamped = Math.max(start, minStart);
var stopClamped = Math.min(stop, maxStop);
var width = ((stopClamped - startClamped) / 60000) * pxPerMinute;
var left = ((startClamped - minStart) / 60000) * pxPerMinute;
var title = p.title || 'No title';
var desc = p.desc || '';
var timeLabel = start.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + ' - ' + stop.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
var $prog = $(`
<div class="epg-program" title="${title}">
<div class="program-title">${title}</div>
<div class="program-time">${timeLabel}</div>
</div>
`);
$prog.css({ left: left + 'px', width: width + 'px' });
$prog.attr({
'data-title': title,
'data-start': start.toISOString(),
'data-stop': stop.toISOString(),
'data-desc': desc,
'data-channel': entry.channel.name
});
var isCurrent = (now >= start && now < stop);
if (isCurrent) {
$prog.addClass('current');
if (currentFocusLeft === null) {
currentFocusLeft = left;
} else {
currentFocusLeft = Math.min(currentFocusLeft, left);
}
}
if (desc.length > 0) {
$prog.attr('data-bs-toggle', 'tooltip');
$prog.attr('data-bs-placement', 'top');
$prog.attr('title', title + ' — ' + desc);
}
$track.append($prog);
});
$row.append($track);
$timeGrid.append($row);
});
$wrapper.append($timeGrid);
$('#timeline').html($wrapper);
// Enable Bootstrap tooltips
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
// Log clicked programme block data
$('#timeline').off('click', '.epg-program').on('click', '.epg-program', function () {
var $el = $(this);
console.log('EPG block clicked', {
title: $el.data('title'),
desc: $el.data('desc'),
start: $el.data('start'),
stop: $el.data('stop'),
channel: $el.data('channel')
});
});
// Focus timeline around the current programme (or "now" line if none)
var $grid = $('#timeline .epg-time-grid');
var targetLeft = currentFocusLeft !== null ? currentFocusLeft : nowOffset;
var scrollTo = Math.max(targetLeft - ($grid.width() / 2), 0);
$grid.scrollLeft(scrollTo);
}
// UPDATE LIST PLAYLIST
function update_list_playlist() {
$.get('get_list_playlists').done(function(data){
$('#list_playlist').html('')
var html = '<table class="table"> \
<thead>\
<tr>\
<th scope="col">#</th>\
<th scope="col">Title</th>\
<th scope="col">End Date</th>\
<th scope="col">Num channels</th>\
<th scope="col"></th>\
<th scope="col"></th>\
<!--<th scope="col">Sid</th>-->\
</tr>\
</thead>\
<tbody>'
var dataJSON = JSON.parse(data)
list_playlist = dataJSON
for (var i=0;i<dataJSON.length;i++) {
var id_playlist = dataJSON[i].id_playlist
var state_playlist = dataJSON[i].activation
var html_state = ''
if (state_playlist == 1) {
html_state = 'class="btn btn-secondary btn-sm" >Active'
} else {
html_state = 'class="btn btn-outline-secondary btn-sm" >Inactive'
}
html += `<tr class="lineTable_device" id="zone_${dataJSON[i].title_playlist}">\
<th scope="row">${i.toString()}</th>\
<td>
<span class="me-2" style="display: inline-block; width: 12px; height: 12px; border-radius: 50%; background-color: ${dataJSON[i].selectedColor || '#ccc'}; vertical-align: middle;"></span>
${dataJSON[i].title_playlist}
</td>
<td>${dataJSON[i].date_end_playlist}</td>\
<td>${dataJSON[i].num_channels}</td>\
<!--<td style="color:darkblue" id="zone_edit_playlist_${id_playlist}"><div class="d-flex flex-row"><div><i class="fas fa-edit"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Edit</div></div></td>-->\
<td style="color:red" data-param1="id_playlist" id="zone_delete_playlist_${i}"><div class="d-flex flex-row"><div><i class="fas fa-trash-alt"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Delete</div></div></td>\
<td style="color:green" data-param1="id_playlist" id="zone_edit_playlist_${i}"><div class="d-flex flex-row"><div><i class="fas fa-edit"></i></div><div class="d-none d-lg-block" style="margin-left: 5px;">Edit</div></div></td>\
<!--<td>'+dataJSON[i].id_playlist+'</td>-->\
<input id="state_activation_${id_playlist}" type="hidden" val=""> \
<td><div id="btn_activation_playlist_${id_playlist}" ${html_state}</div></td>\
</tr>`
}
html += ' </tbody></table>'
$('#list_playlist').html(html)
for (var i=0;i<dataJSON.length;i++) {
var id_playlist = dataJSON[i].id_playlist
var element_delete = $('#zone_delete_playlist_'+i)
var state_playlist = dataJSON[i].activation
var element_edit = $('#zone_edit_playlist_'+i)
$("#state_activation_"+id_playlist).val(state_playlist)
element_delete.click(function(){
var index_id = $(this).attr('id').replace("zone_delete_playlist_", "")
var idp = list_playlist[index_id].id_playlist
console.log('Delete playlist', idp)
$('#id_playlist_to_action').val(idp)
$('#modal_confirmation').modal('show')
})
element_edit.click(function(){
var index_id = $(this).attr('id').replace("zone_edit_playlist_", "")
var playlist = list_playlist[index_id]
console.log('Edit playlist', playlist.id_playlist)
openPlaylistModal('edit', playlist)
})
$('#btn_activation_playlist_'+id_playlist).click(function(){
update_state_playlist(this)
})
}
function update_state_playlist(element) {
var id = $(element).attr('id').replace("btn_activation_playlist_", "");
if ($("#state_activation_"+id).val() == "1") {
$.get('activation_playlist', {activation:0, id_playlist:id}).done(function(data){
var id_data = data['id_playlist']
$("#state_activation_"+id).val(0)
$("#btn_activation_playlist_"+id).addClass("btn-outline-secondary")
$("#btn_activation_playlist_"+id).removeClass("btn-secondary")
$("#btn_activation_playlist_"+id).html("Inactive")
})
} else {
$.get('activation_playlist', {activation:1, id_playlist:id}).done(function(data){
var id_data = data['id_playlist']
$("#state_activation_"+id_data).val(1)
$("#btn_activation_playlist_"+id_data).addClass("btn-secondary")
$("#btn_activation_playlist_"+id_data).removeClass("btn-outline-secondary")
$("#btn_activation_playlist_"+id_data).html("Active")
})
}
}
})
}
// UPDATE LIST DEVICES
function updateListDevices() {
$.get('list_devices').done(function (data) {
var html = '';
var dataJSON = JSON.parse(data)
var selectedDeviceId = $('#id_device').val();
var selectedDeviceName = $('#NameDevice').html();
var currentDeviceUpdated = false;
var selectedDeviceSnapshotData = null;
var selectedDeviceHasChannel = false;
for (var i=0;i<dataJSON.length;i++) {
var sid_device = dataJSON[i].sid
var timestamp = dataJSON[i].last_datetime
var actualTS = Math.floor(Date.now() / 1000)
var delta = actualTS - timestamp
var status = ''
var vpn_status = dataJSON[i].vpn_status
if (vpn_status) {
vpn_status_icon = `<i id="vpn_icon_${sid_device}" class="fas fa-lock vpn-icon" style="color:green; cursor:pointer;" data-id="${dataJSON[i].id_device}" data-action="disconnect" title="Click to disconnect"></i>`
}
if (vpn_status === 'unknown' || vpn_status === false) {
vpn_status_icon = `<i class="fas fa-unlock vpn-icon" style="color:lightgrey; cursor:pointer;" data-id="${dataJSON[i].id_device}" data-action="connect" title="Click to connect"></i>`
}
function getColor(value) {
let percent = value / 60;
// Interpolation entre vert foncé (0,128,0) et rouge (255,0,0)
let r = Math.round(255 * percent); // de 0 → 255
let g = Math.round(128 * (1 - percent)); // de 128 → 0
let b = 0;
return `rgb(${r}, ${g}, ${b})`;
}
var color = getColor(delta)
var data_running = dataJSON[i].data_running || {}
var id_playlist = data_running.id_playlist
var playlist = list_playlist.find(p => p.id_playlist === id_playlist);
var selectedColor = playlist?.selectedColor || '#cccccc'; // Default color if not found
var colorCircle = getPlaylistDot(selectedColor);
var hasChannel = data_running && data_running.name_channel;
var streamStatus = null;
if (hasChannel) {
streamStatus = computeProgressStatus(dataJSON[i]);
}
var channelBadgeClass = 'bg-secondary';
if (hasChannel && streamStatus) {
channelBadgeClass = streamStatus.className; // bg-success for live, bg-danger for frozen
} else if (hasChannel) {
channelBadgeClass = 'bg-success';
}
var media = dataJSON[i].mediaRunning || 'No channel'
var mediaClass = 'is-idle';
if (channelBadgeClass === 'bg-success') {
mediaClass = 'is-live';
} else if (channelBadgeClass === 'bg-danger') {
mediaClass = 'is-frozen';
}
if (delta <= 60) {
status = `<span style="color: ${color};">Online <span style="font-size: 0.8em;">${Math.floor(delta)} s</span></span>`;
status2 = '#ebfaeb'
}
/*
if (delta <= 60) {
const canvasId = `sparkline_${sid_device}`;
status = `
<div style="display: flex; align-items: center; gap: 6px;">
<span style="color: ${color}; font-size: 0.9em;">Online <span style="font-size: 0.8em;">${Math.floor(delta)} s</span></span>
<canvas id="${canvasId}" width="80" height="25" style="width: 80px; height: 25px; background:#f5f5f5; border-radius: 3px;"></canvas>
</div>
`;
status2 = '#ebfaeb';
// Stockage et MAJ des données globales
if (!window.sparklineData) window.sparklineData = {};
if (!window.sparklineCharts) window.sparklineCharts = {};
if (!window.sparklineData[sid_device]) {
window.sparklineData[sid_device] = [];
}
// Ajout de la nouvelle valeur (delta)
window.sparklineData[sid_device].push(delta);
if (window.sparklineData[sid_device].length > 20) {
window.sparklineData[sid_device].shift();
}
// Délai pour laisser le DOM rendre les canvas
setTimeout(() => {
const canvas = document.getElementById(canvasId);
if (!canvas) return;
const ctx = canvas.getContext('2d');
const colorLine = getColor(delta);
const chartData = {
labels: Array(window.sparklineData[sid_device].length).fill(''),
datasets: [{
data: window.sparklineData[sid_device],
borderColor: colorLine,
backgroundColor: colorLine.replace(')', ', 0.1)').replace('rgb', 'rgba'),
borderWidth: 2,
tension: 0.4,
pointRadius: 0
}]
};
const chartOptions = {
animation: false,
responsive: false,
plugins: { legend: { display: false } },
scales: {
x: { display: false },
y: { display: false }
}
};
if (window.sparklineCharts[sid_device]) {
window.sparklineCharts[sid_device].data = chartData;
window.sparklineCharts[sid_device].options = chartOptions;
window.sparklineCharts[sid_device].update();
} else {
window.sparklineCharts[sid_device] = new Chart(ctx, {
type: 'line',
data: chartData,
options: chartOptions
});
}
}, 500); // Laisse le DOM terminer l'injection
}*/
if (delta > 86400) {
status = '<span style="color: SlateBlue;">Offline</span>'
status2 = '#e6e6ff'
}
if ((delta > 60) && (delta <= 86400)) {
status = '<span style="color: red;">' + Math.floor(delta / 60).toString() + ' min</span>'
status2 = '#ffe6e6'
}
var ipAddress = dataJSON[i].ip_address || '';
var ipCountry = dataJSON[i].ip_country || '';
var ipLine = ipAddress ? `<div class="device-ip text-truncate">${ipAddress}${ipCountry ? ' &middot; ' + ipCountry : ''}</div>` : '';
var activeClass = (selectedDeviceId && selectedDeviceId === dataJSON[i].id_device) ? 'active' : '';
html += `<div class="device-card ${activeClass}" id="btn_select_device_${dataJSON[i].id_device}" data-param1="${dataJSON[i].id_device}" data-param2="${dataJSON[i].name}" data-param3="${dataJSON[i].sid}" data-param4="${dataJSON[i].vpn_status}">
<div class="device-card-header">
<div class="device-name text-truncate">${dataJSON[i].name}${ipLine}</div>
<div class="device-actions">
<div class="device-status">${status}</div>
<div>${vpn_status_icon}</div>
<button class="device-action delete-device" type="button" data-id_device="${dataJSON[i].id_device}" id="btn_delete_device_${dataJSON[i].id_device}" title="Delete device">
<i class="fas fa-trash-alt"></i>
</button>
</div>
</div>
<div class="device-card-footer">
<div class="device-channel-pill ${mediaClass} text-truncate">${media}</div>
${colorCircle}
</div>
</div>`
var isCurrentDevice = false;
if (selectedDeviceId && selectedDeviceId === dataJSON[i].id_device) {
isCurrentDevice = true;
} else {
var deviceName = dataJSON[i].name;
if (Array.isArray(deviceName)) {
isCurrentDevice = (selectedDeviceName == deviceName[0]);
} else {
isCurrentDevice = (selectedDeviceName == deviceName);
}
}
if (isCurrentDevice) {
currentDeviceUpdated = true;
selectedDeviceSnapshotData = dataJSON[i];
$('#sidDevice').val( dataJSON[i].sid)
var data_running = dataJSON[i].data_running || {}
var hasChannel = Object.keys(data_running).length > 0 && data_running['name_channel'];
selectedDeviceHasChannel = hasChannel;
if (Object.keys(data_running).length > 0) {
var percentage = Math.round(data_running['percentage']*10)/10
$('#channelPlaying').html(data_running['name_channel'])
$('#valRange').html(percentage)
$('#Range').val(Math.round(data_running['percentage']) )
} else {
$('#channelPlaying').html("No channel")
$('#valRange').html(0)
$('#Range').val(0 )
}
updateCurrentDeviceStream(streamStatus || {label: 'No data', className: 'bg-secondary'}, hasChannel)
}
}
if (!currentDeviceUpdated) {
updateCurrentDeviceStream({label: 'No data', className: 'bg-secondary'}, false);
updateSelectedDeviceSnapshot(null, false);
updateAutoSnapshotToggle(null);
updateSnapshotIntervalState(null);
} else {
updateSelectedDeviceSnapshot(selectedDeviceSnapshotData, selectedDeviceHasChannel);
updateAutoSnapshotToggle(selectedDeviceSnapshotData);
updateSnapshotIntervalState(selectedDeviceSnapshotData);
}
$('#listDevices').html(html)
//display_most_frequent()
function configure_current_device(element) {
const id_device = $(element).data('param1');
const name_device = $(element).data('param2');
const sid = $(element).data('param3');
const vpn_status = $(element).data('param4');
console.log(element)
console.log("configure current device", id_device, name_device, sid, vpn_status)
$('#id_device').val(id_device)
$('#sidDevice').val(sid)
$('#NameDevice').html(name_device)
updateListDevices()
//channelInterval = setInterval(getChannelRunning, 5000)
}
for (var i=0;i<dataJSON.length;i++) {
$('#btn_select_device_'+dataJSON[i].id_device).click(function(){
configure_current_device(this)
})
$("#btn_delete_device_"+dataJSON[i].id_device).click(function(event){
event.stopPropagation();
var id_device = $(this).data('id_device');
console.log("delete device", id_device)
$.get("delete_device",{id_device:id_device}).done(function(data){
updateListDevices()
})
})
}
})
}
function getChannelRunning() {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "getChannelRunning", payload: '', "packet":0});
}
// CLICK TAB SEARCH
$('#display_tab_search').click(function(){
var triggerTab = new bootstrap.Tab($('#display_tab_search'));
triggerTab.show();
$('#tab_home').hide()
$('#tab_search').show()
$('#tab_playlist').hide()
})
// CLICK ADD PLAYLIST
$('#btn_configure_playlist').click(function(){
openPlaylistModal('add')
})
// Check valid URL
function isValidURL(string) {
var pattern = new RegExp('^(https?:\\/\\/)?' + // Protocol
'((([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,})|' + // Domain name
'localhost|' + // OR localhost
'\\d{1,3}(\\.\\d{1,3}){3})' + // OR IPv4
'(\\:\\d+)?' + // Optional port
'(\\/[-a-zA-Z0-9@:%._\\+~#=]*)*' + // Path
'(\\?[;&a-zA-Z0-9@:%._\\+~#=]*)?' + // Query string
'(\\#[-a-zA-Z0-9@:%._\\+~#=]*)?$', 'i'); // Fragment locator
return pattern.test(string);
}
// Update filemame
$("#file-input").on("change", function () {
$("#fileName").val(this.files[0]?.name || "No file selected.");
});
// CLICK BTN btn_confirmation_delete_playlist
$('#btn_confirmation_delete_playlist').click(function(){
$.get('delete_playlist', {id_playlist : $('#id_playlist_to_action').val() }).done(function(data){
update_list_playlist()
$('#modal_confirmation').modal('hide')
})
})
$('#btn_update_playlist_comment').click(function(){
var idPlaylist = $('#id_playlist_to_action').val().trim();
var playlistComment = $('#playlist_comment').val().trim();
if (!idPlaylist) {
$('#add_playlist_alert').html('<div class="alert alert-warning" role="alert">Save the playlist first before updating only the comment.</div>');
return;
}
$('#btn_update_playlist_comment').prop('disabled', true).text('Updating...');
$.post('update_playlist_comment', {
id_playlist: idPlaylist,
comment: playlistComment
}).done(function(data){
$('#playlist_comment').removeClass('is-invalid').addClass('is-valid');
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Comment updated.</div>');
var playlist = list_playlist.find(function(p){ return p.id_playlist === data.id_playlist; });
if (playlist) {
playlist.comment = data.comment;
}
update_list_playlist();
}).fail(function(xhr){
var message = 'Unable to update the comment.';
if (xhr.responseJSON && xhr.responseJSON.error) {
message = xhr.responseJSON.error;
}
$('#playlist_comment').removeClass('is-valid').addClass('is-invalid');
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">' + message + '</div>');
}).always(function(){
$('#btn_update_playlist_comment').prop('disabled', false).text('Update Comment');
});
})
// CLICK UPLOAD FILE PLAYLIST
$("#upload-button").click(function () {
if (playlistImportInFlight) {
return;
}
var playlistType = $('#playlist_type').val();
var title = $('#playlist_title').val().trim();
var endDate = $('#playlist_end_date').val();
var playlistUrl = $('#playlist_url').val().trim();
var playlistComment = $('#playlist_comment').val().trim();
var xtreamServer = $('#xtream_server').val().trim();
var xtreamUsername = $('#xtream_username').val().trim();
var xtreamPassword = $('#xtream_password').val().trim();
var selectedColor = $('input[name="playlist_color"]:checked').val();
var localFile = $("#file-input")[0].files[0];
function setFieldState(selector, isValid) {
$(selector).toggleClass('is-valid', !!isValid);
$(selector).toggleClass('is-invalid', !isValid);
}
var validationTitle = title.length > 0;
var validationDate = !!endDate;
var validationColor = typeof selectedColor !== 'undefined';
setFieldState('#playlist_title', validationTitle);
setFieldState('#playlist_end_date', validationDate);
$('#color-selector').toggleClass('border border-danger rounded p-1', !validationColor);
if (playlistType === 'xtream_codes') {
var validationXtreamServer = xtreamServer.length > 0 && isValidURL(xtreamServer);
var validationXtreamUsername = xtreamUsername.length > 0;
var validationXtreamPassword = xtreamPassword.length > 0;
setFieldState('#xtream_server', validationXtreamServer);
setFieldState('#xtream_username', validationXtreamUsername);
setFieldState('#xtream_password', validationXtreamPassword);
$('#playlist_url').removeClass('is-valid is-invalid');
if (!(validationTitle && validationDate && validationColor && validationXtreamServer && validationXtreamUsername && validationXtreamPassword)) {
return;
}
$('#add_playlist_alert').html('<div class="alert alert-info" role="alert">Importing Xtream Codes playlist...</div>');
$("#progress-container").show();
$("#progress-bar").val(5);
$("#progress-text").text("5%");
playlistImportProgress = 0.05;
playlistImportInFlight = true;
$('#upload-button').prop('disabled', true).text('Importing...');
$.post('import_xtream_codes', {
id_playlist: $('#id_playlist_to_action').val(),
title: title,
date_end: endDate,
comment: playlistComment,
sidClient: $('#sidClient').val(),
selectedColor: selectedColor,
xtream_server: xtreamServer,
xtream_username: xtreamUsername,
xtream_password: xtreamPassword
}).done(function(data){
// The import runs in the background on the server; completion is reported
// via the progress_analysis socket event, not this HTTP response.
$('#id_playlist_to_action').val(data.id_playlist || $('#id_playlist_to_action').val());
$('#add_playlist_alert').html('<div class="alert alert-info" role="alert">' + (data.message || 'Importing Xtream Codes playlist...') + '</div>');
playlistImportInFlight = true;
$('#upload-button').prop('disabled', true).text('Importing...');
}).fail(function(xhr){
var message = 'Xtream import failed.';
if (xhr.responseJSON && xhr.responseJSON.error) {
message = xhr.responseJSON.error;
}
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">' + message + '</div>');
}).always(function(){
var keepImportState = $('#add_playlist_alert .alert-info').length > 0 && playlistImportInFlight;
if (!keepImportState) {
$("#progress-container").hide();
playlistImportInFlight = false;
$('#upload-button').prop('disabled', false).text('Save');
}
});
return;
}
var validationUrl = isValidURL(playlistUrl);
setFieldState('#playlist_url', validationUrl);
$('#xtream_server, #xtream_username, #xtream_password').removeClass('is-valid is-invalid');
if (!(validationTitle && validationDate && validationColor && validationUrl)) {
return;
}
const uploadPlaylistFile = function (fileToUpload, uploadedFilename) {
const formData = new FormData();
formData.append("file", fileToUpload);
formData.append("id_playlist_to_action", $('#id_playlist_to_action').val());
$("#fileName").val(uploadedFilename);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/upload", true);
xhr.upload.onprogress = function (event) {
if (event.lengthComputable) {
const percentComplete = Math.round((event.loaded / event.total) * 100);
$("#progress-bar").val(percentComplete);
$("#progress-text").text(percentComplete + "%");
}
};
xhr.onloadstart = function () {
$("#progress-container").show();
};
xhr.onload = function () {
if (xhr.status === 200) {
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Upload successful!</div>');
var response = JSON.parse(xhr.response);
var id_playlist = response['id_playlist'];
if (response.status == 'ok') {
$.post('analyse_m3u_file', {
'id_playlist': id_playlist,
'title': title,
'url': playlistUrl,
'date_end': endDate,
'comment': playlistComment,
'sidClient': $('#sidClient').val(),
'filename': $("#fileName").val(),
'selectedColor': selectedColor
}).done(function(data){
var num_channel = data.num_channel;
if (num_channel > 0) {
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Num channel : ' + String(num_channel) + '</div>');
} else {
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">No channel in the file</div>');
}
update_list_playlist();
$('#modal_add_playlist').modal('hide');
}).fail(function(xhr){
var message = 'Playlist analysis failed.';
if (xhr.responseJSON && xhr.responseJSON.error) {
message = xhr.responseJSON.error;
}
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">' + message + '</div>');
}).always(function(){
$("#progress-container").hide();
});
}
} else {
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">Upload failed!</div>');
$("#progress-container").hide();
}
};
xhr.onerror = function () {
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">An error occurred while uploading.</div>');
$("#progress-container").hide();
};
xhr.send(formData);
};
if (localFile) {
uploadPlaylistFile(localFile, localFile.name);
return;
}
$('#add_playlist_alert').html('<div class="alert alert-info" role="alert">Downloading playlist from original URL...</div>');
$("#progress-container").show();
$("#progress-bar").val(0);
$("#progress-text").text("0%");
fetch(playlistUrl)
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.blob();
})
.then(function(blob) {
const inferredFilename = playlistUrl.split('/').pop().split('?')[0] || 'playlist.m3u';
const remoteFile = new File([blob], inferredFilename, { type: blob.type || 'audio/x-mpegurl' });
$("#progress-bar").val(50);
$("#progress-text").text("50%");
uploadPlaylistFile(remoteFile, inferredFilename);
})
.catch(function(error) {
$("#progress-container").hide();
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">Unable to download playlist from URL: ' + error.message + '</div>');
});
});
$("#typeDisplay").change(function(){
displayResult()
})
$('#btn_display_player').click(function () {
if($('#player').css('display') == 'none'){
$('#player').show('slow');
$('#valRange').html='0'
$('#Range').val(0)
} else {
$('#player').hide('slow');
}
})
$(".dropdown-submenu .dropdown-toggle").on("click", function (e) {
e.preventDefault(); // Prevent link from navigating
e.stopPropagation(); // Prevent closing the whole dropdown
let $submenu = $(this).next(".dropdown-menu");
// Toggle the submenu visibility
$(".dropdown-submenu .dropdown-menu").not($submenu).removeClass("show"); // Close other submenus
$submenu.toggleClass("show");
});
// Close submenus when clicking outside
$(document).on("click", function (e) {
if (!$(e.target).closest(".dropdown-submenu").length) {
$(".dropdown-submenu .dropdown-menu").removeClass("show");
}
});
function openEpgSettings() {
$('#epgSettingsAlert').html('');
$('#epg_url_input').removeClass('is-invalid');
$.get('/epg_url')
.done(function (data) {
$('#epg_url_input').val(data.epg_url || '');
})
.fail(function () {
$('#epgSettingsAlert').html('<div class="alert alert-danger" role="alert">Unable to load the current EPG URL.</div>');
$('#epg_url_input').val('');
})
.always(function () {
$('#modal_epg_settings').modal('show');
});
}
function saveEpgUrl() {
$('#epgSettingsAlert').html('');
var epgUrl = $('#epg_url_input').val().trim();
if (epgUrl.length === 0) {
$('#epg_url_input').addClass('is-invalid');
return;
}
$('#epg_url_input').removeClass('is-invalid');
$.post('/epg_url', { epg_url: epgUrl })
.done(function () {
$('#epgSettingsAlert').html('<div class="alert alert-success" role="alert">EPG URL saved.</div>');
setTimeout(function () { $('#modal_epg_settings').modal('hide'); }, 800);
})
.fail(function (resp) {
var msg = 'Unable to save the EPG URL.';
if (resp.responseJSON && resp.responseJSON.error) {
msg = resp.responseJSON.error;
}
$('#epgSettingsAlert').html('<div class="alert alert-danger" role="alert">' + msg + '</div>');
});
}
function openSnapshotIntervalSettings() {
$('#snapshotIntervalSettingsAlert').html('');
if ($('#sidDevice').val().length === 0 || $('#id_device').val().length === 0) {
$('#noDeviceModal').modal('show');
return;
}
$('#snapshot_interval_input')
.val(selectedDeviceSnapshotIntervalSeconds)
.removeClass('is-invalid');
$('#modal_snapshot_interval_settings').modal('show');
}
function saveSnapshotInterval() {
$('#snapshotIntervalSettingsAlert').html('');
if ($('#sidDevice').val().length === 0 || $('#id_device').val().length === 0) {
$('#modal_snapshot_interval_settings').modal('hide');
$('#noDeviceModal').modal('show');
return;
}
var intervalValue = Number($('#snapshot_interval_input').val());
if (!Number.isInteger(intervalValue) || intervalValue < 2) {
$('#snapshot_interval_input').addClass('is-invalid');
$('#snapshotIntervalSettingsAlert').html('<div class="alert alert-danger" role="alert">Enter an integer value greater than or equal to 2.</div>');
return;
}
$('#snapshot_interval_input').removeClass('is-invalid');
selectedDeviceSnapshotIntervalSeconds = intervalValue;
socket.emit('communication', {
fromSidDevice: $('#sidClient').val(),
toSidDevice: $('#sidDevice').val(),
typeCommand: 'setSnapshotInterval',
payload: intervalValue,
packet: 0
});
$('#snapshotIntervalSettingsAlert').html('<div class="alert alert-success" role="alert">Snapshot interval updated.</div>');
setTimeout(function () {
$('#modal_snapshot_interval_settings').modal('hide');
updateListDevices();
}, 500);
}
// --- GEMINI API KEY SETTINGS ---
function applyAiButtonVisibility() {
var show = localStorage.getItem('show_ai_button') !== 'false';
$('#btn_ai_search').toggleClass('d-none', !show);
}
function openGeminiKeySettings() {
$('#geminiKeyAlert').html('');
$.get('/gemini_key', function(data) {
$('#gemini_key_input').val(data.gemini_key || '');
});
var show = localStorage.getItem('show_ai_button') !== 'false';
$('#toggle_ai_button').prop('checked', show);
$('#modal_gemini_key_settings').modal('show');
}
$('#toggle_ai_button').on('change', function() {
localStorage.setItem('show_ai_button', $(this).is(':checked') ? 'true' : 'false');
applyAiButtonVisibility();
});
$('#btn_save_gemini_key').click(function() {
var key = $('#gemini_key_input').val().trim();
if (!key) {
$('#geminiKeyAlert').html('<div class="alert alert-danger">Please enter a valid API key.</div>');
return;
}
$.post('/gemini_key', { gemini_key: key }, function() {
$('#geminiKeyAlert').html('<div class="alert alert-success">API key saved.</div>');
setTimeout(function() { $('#modal_gemini_key_settings').modal('hide'); }, 800);
}).fail(function() {
$('#geminiKeyAlert').html('<div class="alert alert-danger">Error saving key.</div>');
});
});
// --- AI SEARCH ---
function openAiSearch() {
$('#aiSearchAlert').html('');
$('#ai_search_result').hide();
$('#ai_search_spinner').hide();
$('#ai_search_input').val('');
$('#modal_ai_search').modal('show');
}
function runAiSearch() {
var query = $('#ai_search_input').val().trim();
if (!query) return;
$('#aiSearchAlert').html('');
$('#ai_search_result').hide();
$('#ai_search_spinner').show();
$('#btn_ai_search_submit').prop('disabled', true);
$.post('/gemini_search', { query: query }, function(data) {
$('#ai_search_spinner').hide();
$('#btn_ai_search_submit').prop('disabled', false);
if (data.error) {
$('#aiSearchAlert').html('<div class="alert alert-danger">' + $('<div>').text(data.error).html() + '</div>');
return;
}
$('#ai_search_result_body').text(data.answer);
var sourcesHtml = '';
if (data.sources && data.sources.length > 0) {
sourcesHtml = '<strong>Sources :</strong> ';
sourcesHtml += data.sources.map(function(s) {
return '<a href="' + $('<div>').text(s.uri).html() + '" target="_blank">' + $('<div>').text(s.title || s.uri).html() + '</a>';
}).join(' &bull; ');
}
$('#ai_search_sources').html(sourcesHtml);
$('#ai_search_result').show();
}).fail(function(xhr) {
$('#ai_search_spinner').hide();
$('#btn_ai_search_submit').prop('disabled', false);
var msg = (xhr.responseJSON && xhr.responseJSON.error) ? xhr.responseJSON.error : 'Server error.';
$('#aiSearchAlert').html('<div class="alert alert-danger">' + $('<div>').text(msg).html() + '</div>');
});
}
$('#btn_ai_search_submit').click(runAiSearch);
$('#ai_search_input').on('keypress', function(e) { if (e.which === 13) runAiSearch(); });
function off(){
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "off", payload: 0, "packet":0});
}
function version(){
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "version", payload: 0, "packet":0});
}
function resume(){
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "resume", payload: 0, "packet":0});
}
function pause(){
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "pause", payload: 0, "packet":0});
}
function updateSource(){
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "update_source", payload: '', "packet":0});
}
function reboot() {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "reboot", payload: '', "packet":0});
}
function changeDeviceName() {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "changeDeviceName", payload: $('#deviceName_forchange').val(), "packet":0});
$('#changeNameModal').modal('hide')
}
function wifi() {
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "listSSID", payload: '', "packet":0});
$('#formDeviceName').hide('slow')
}
function changeRange() {
$('#valRange').html($('#Range').val())
//changePosition = true
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "moveto", payload: $('#Range').val(), "packet":0});
updateListDevices()
}
function getIP(command){
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "getIP", payload: '', "packet":0});
}
function management_VPN(command){
console.log('management VPN', command)
socket.emit('communication', {fromSidDevice : $('#sidClient').val(), toSidDevice: $('#sidDevice').val(), typeCommand : "vpn", payload: command, "packet":0});
}
function displayDeviceName() {
$('#deviceName_forchange').val($('#NameDevice').html())
$('#changeNameModal').modal('show')
}
</script>