3256 lines
131 KiB
HTML
3256 lines
131 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-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">◀</button>
|
|
<div class="scroll-wrapper" id="channel-scroll"></div>
|
|
<button class="nav-btn" id="scroll-right" type="button" aria-label="Scroll right">▶</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 + "%");
|
|
}
|
|
|
|
|
|
})
|
|
|
|
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 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}</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){
|
|
if (data.status === 'already_running') {
|
|
$('#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 || 'Xtream import already running...') + '</div>');
|
|
playlistImportInFlight = true;
|
|
$('#upload-button').prop('disabled', true).text('Importing...');
|
|
return;
|
|
}
|
|
var num_channel = data.num_channel || 0;
|
|
$('#id_playlist_to_action').val(data.id_playlist || $('#id_playlist_to_action').val());
|
|
if (num_channel > 0) {
|
|
$('#add_playlist_alert').html('<div class="alert alert-success" role="alert">Imported items: ' + String(num_channel) + '</div>');
|
|
} else {
|
|
$('#add_playlist_alert').html('<div class="alert alert-danger" role="alert">No items returned by Xtream Codes.</div>');
|
|
}
|
|
update_list_playlist();
|
|
$('#modal_add_playlist').modal('hide');
|
|
}).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(' • ');
|
|
}
|
|
$('#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>
|