Files
web/views/details.twig

450 lines
24 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{###########################################################################
# Copyright (c) 2025, Антон Аксенов
# This file is part of iptv.axenov.dev web interface
# MIT License: https://git.axenov.dev/IPTV/web/src/branch/master/LICENSE
###########################################################################}
{% extends "template.twig" %}
{% block title %}[{{ playlist.code }}] {{ playlist.name }} - {{ config('app.title') }}{% endblock %}
{% block head %}
<style>
img.tvg-logo{max-width:80px;max-height:80px;padding:2px;border-radius:5px}
tr.chrow td{padding:3px}
td.chindex{width:1%}
td.chlogo{width:100px}
div.chlist-table{max-height:550px}
textarea.m3u-raw{font-size:.7rem}
</style>
<script>
function setDefaultLogo(imgtag) {
imgtag.onerror = null
imgtag.src = '/no-tvg-logo.png'
}
</script>
{% endblock %}
{% block header %}
<h2>О плейлисте: {{ playlist.name }}</h2>
{% if (playlist.channels|length > 500) %}
<div class="alert alert-warning small" role="alert">
В плейлисте очень много каналов. На загрузку их списка и логотипов потребуется некоторое время.
</div>
{% endif %}
{% if playlist.isOnline is same as(false) %}
<div class="alert alert-danger small" role="alert">
Ошибка плейлиста: {{ playlist.content }}
</div>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col-lg-7">
<ul class="nav nav-tabs">
<li class="nav-item small">
<a class="nav-link active"
type="button"
href="#tab-data"
data-bs-toggle="tab"
data-bs-target="#tab-data"
>
<ion-icon name="radio-outline"></ion-icon>&nbsp;Основные данные
</a>
</li>
<li class="nav-item small">
<a class="nav-link"
type="button"
href="#tab-raw"
data-bs-toggle="tab"
data-bs-target="#tab-raw"
>
<ion-icon name="document-text-outline"></ion-icon>&nbsp;Исходный текст
</a>
</li>
</ul>
<div class="tab-content small">
<div class="tab-pane fade show active" id="tab-data" tabindex="0">
<table class="table table-dark table-hover small mb-lg-5">
<tbody>
<tr>
<th class="w-25" scope="row">Код</th>
<th class="text-break">
{% if playlist.isOnline is same as(true) %}
<span class="font-monospace text-success">{{ playlist.code }}</span>
<span class="badge small text-dark bg-success">онлайн</span>
{% elseif playlist.isOnline is same as(false) %}
<span class="font-monospace text-danger">{{ playlist.code }}</span>
<span class="badge small text-dark bg-danger">оффлайн</span>
{% elseif playlist.isOnline is same as(null) %}
<span class="font-monospace">{{ playlist.code }}</span>
<span class="badge small text-dark bg-secondary" title="Не проверялся">unknown</span>
{% endif %}
{% if "adult" in playlist.tags %}
<span class="badge small bg-warning text-dark" title="Есть каналы для взрослых!">18+</span>
{% endif %}
</th>
</tr>
<tr>
<th scope="row">Описание</th>
<td class="text-break"><p class="mb-0">{{ playlist.description }}</p></td>
</tr>
<tr>
<th scope="row">Ccылка для ТВ</th>
<td><b onclick="prompt('Скопируй адрес плейлиста', 'm3u.su/{{ playlist.code }}')"
data-bs-toggle="tooltip"
data-bs-placement="top"
title="Нажми на ссылку, чтобы скопировать её в буфер обмена"
class="font-monospace cursor-pointer text-break">m3u.su/{{ playlist.code }}</b></td>
</tr>
<tr>
<th scope="row">Источник</th>
<td class="text-break">{{ playlist.source }}</td>
</tr>
<tr>
<th scope="row">Наполнение</th>
<td class="text-break">
группы:&nbsp;{{ playlist.groups|length }},
каналы:&nbsp;{{ playlist.channels|length }}
(<span class="text-success">{{ playlist.onlineCount }}</span>&nbsp;+&nbsp;<span class="text-danger">{{ playlist.offlineCount }}</span>)
</td>
</tr>
<tr>
<th scope="row">Возможности</th>
<td class="text-break">
<ion-icon name="newspaper-outline"></ion-icon>&nbsp;Программа передач:&nbsp;{{ playlist.hasTvg ? 'есть' : 'нет' }}<br>
<ion-icon name="play-back"></ion-icon>&nbsp;Перемотка (архив):&nbsp;{{ playlist.hasCatchup ? 'есть' : 'нет' }}
</td>
</tr>
<tr class="text-secondary">
<th scope="row">M3U</th>
<td class="text-break">{{ playlist.url }}</td>
</tr>
<tr class="text-secondary">
<th class="w-25" scope="row">Проверка плейлиста</th>
<td class="text-break">
<span title="Фактическая метка времени окончания проверки плейлиста">
{{ to_date(playlist.checkedAt) }}
</span>
</td>
</tr>
{% if playlist.isOnline is same as(false) %}
<tr class="text-secondary">
<th class="w-25" scope="row">Ошибка проверки</th>
<td class="text-break">{{ playlist.content }}</td>
</tr>
{% endif %}
</tbody>
</table>
{% if (playlist.attributes) %}
<h4>Дополнительные атрибуты</h4>
<table class="table table-dark table-hover small font-monospace">
<tbody>
{% for attribute,value in playlist.attributes %}
<tr>
<th class="w-25" scope="row">{{ attribute }}</th>
<td class="text-break">{{ value }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
</div>
<div class="tab-pane fade" id="tab-raw" tabindex="1">
<button class="btn btn-sm btn-success mye-md-3"
id="saveM3UBtn"
onclick="savePlaylist('{{ playlist.code }}')"
>
<ion-icon name="download-outline"></ion-icon>&nbsp;{{ playlist.code }}.m3u8
</button>
<button class="btn btn-sm btn-outline-light my-3"
id="saveM3UBtn"
data-bs-toggle="modal"
data-bs-target="#qrcode-popup"
>
<ion-icon name="qr-code-outline"></ion-icon>&nbsp;QR-код
</button>
<textarea class="form-control bg-dark text-light font-monospace mb-3 mb-md-0 m3u-raw"
rows="40"
id="m3u-raw"
readonly
>{{ playlist.content }}</textarea>
<div class="modal fade" id="qrcode-popup" tabindex="-1">
<div class="modal-dialog ">
<div class="modal-content bg-dark">
<div class="modal-header">
<h1 class="modal-title fs-5">QR-код со ссылкой на плейлист {{ playlist.code }}</h1>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body text-center">
<img src="/{{ playlist.code }}/qrcode" alt="">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-lg-5">
<h4>Список каналов:&nbsp;<span id="chcount">{{ playlist.channels|length }}</span></h4>
{% if (playlist.channels|length > 0) %}
{% if (playlist.groups|length > 1) %}
<div class="row my-3">
<div class="col-12">
<div class="input-group">
<select id="groupSelector"
class="form-select form-select-sm border-secondary bg-dark text-light"
onchange="updateFilter()"
>
<option selected value="all">Все группы</option>
{% for group in playlist.groups %}
<option value="{{ group.id }}">{{ group.name }}</option>
{% endfor %}
</select>
<button type="button"
onclick="resetGroup()"
class="btn btn-sm btn-outline-secondary"
title="Сбросить группу"
>
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
</div>
</div>
{% endif %}
<div class="row my-3">
<div class="col-12">
<div class="input-group">
<input type="text"
id="search-field"
class="form-control form-control-sm border-secondary bg-dark text-light fuzzy-search"
placeholder="Поиск каналов..."
title="Начни вводить название"
/>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfAll"
autocomplete="off"
onclick="updateFilter()"
checked
>
<label class="btn btn-sm btn-outline-secondary"
for="chfAll"
title="Выбрать все каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>
</label>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfOnline"
autocomplete="off"
onclick="updateFilter()"
>
<label class="btn btn-sm btn-outline-success"
for="chfOnline"
title="Выбрать только онлайн каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>
</label>
<input type="radio"
class="btn-check"
name="chFilter"
id="chfOffline"
autocomplete="off"
onclick="updateFilter()"
>
<label class="btn btn-sm btn-outline-danger"
for="chfOffline"
title="Выбрать только оффлайн каналы"
>
<ion-icon name="radio-button-on-outline"></ion-icon>
</label>
<button type="button"
class="btn btn-sm btn-outline-secondary"
onclick="resetSearch()"
title="Сбросить фильтрацию"
>
<ion-icon name="close-outline"></ion-icon>
</button>
</div>
<div class="my-3">
{% for tag in playlist.tags %}
<input type="checkbox"
class="btn-check"
id="btn-tag-{{ tag }}"
data-tag="{{ tag }}"
autocomplete="off"
onclick="updateFilter()"
>
<label class="badge btn btn-sm btn-outline-secondary rounded-pill"
for="btn-tag-{{ tag }}"
title="Нажми для фильтрации каналов по тегу, нажми ещё раз чтобы снять фильтр"
>#{{ tag }}</label>
{% endfor %}
</div>
</div>
</div>
<div class="chlist-table overflow-auto">
<table id="chlist" class="table table-dark table-hover small">
<tbody class="list">
{% for channel in playlist.channels %}
<tr class="chrow"
data-id="{{ channel.id }}"
data-group="{{ channel.groupId ?? 'all' }}"
data-online="{{ channel.isOnline ? 1 : 0 }}"
data-chtags="{{ channel.tags|join('|') }}"
title="&#010;HTTP: {{ channel.status ?: '(неизвестно)' }}&#010;Error: {{ channel.error ?: '(нет)' }}"
>
<td class="chindex">{{ loop.index }}</td>
<td class="chlogo text-center">
{% if (channel.attributes['tvg-logo']) %}
<img class="tvg-logo"
alt="Логотип канала '{{ channel.title }}'"
title="Логотип канала '{{ channel.title }}'"
src="{{ channel.attributes['tvg-logo'] }}"
onerror="setDefaultLogo(this)"
/>
{% else %}
<img class="tvg-logo"
alt="Нет логотипа для канала '{{ channel.title }}'"
title="Нет логотипа для канала '{{ channel.title }}'"
src="/no-tvg-logo.png"
/>
{% endif %}
</td>
<td class="text-break">
<ion-icon name="radio-button-on-outline"
{% if (channel.isOnline) %}
class="me-1 text-success"
title="Состояние: онлайн"
{% else %}
class="me-1 text-danger"
title="Состояние: оффлайн"
{% endif %}
></ion-icon>
{% if "adult" in channel.tags %}
<span class="badge small bg-warning text-dark" title="Канал для взрослых!">18+</span>
{% endif %}
<span class="chname">{{ channel.title }}</span>
<div class="text-secondary small">
{% if (channel.attributes['tvg-id']) %}
<div title="tvg-id">
<ion-icon name="star-outline" class="me-1"></ion-icon>&nbsp;{{ channel.attributes['tvg-id'] }}
</div>
{% endif %}
{% if (channel.contentType != null) %}
<div title="MIME type">
<ion-icon name="eye-outline" class="me-1"></ion-icon>&nbsp;{{ channel.contentType }}
</div>
{% endif %}
{% if channel.tags|length > 0 %}
<ion-icon name="pricetag-outline" class="me-1"></ion-icon>
{% for tag in channel.tags %}
<span class="chtag">#{{ tag }}</span>
{% endfor %}
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block footer %}
<script src="/js/list.min.js"></script>
<script>
const options = {
valueNames: [
'chname',
{data: ['online', 'group', 'tag', 'chtags']}
],
};
const list = new List('chlist', options)
list.on('updated', (data) => document.getElementById('chcount').innerText = data.visibleItems.length)
document.getElementById('search-field').addEventListener('keyup', (e) => list.search(e.target.value))
function savePlaylist() {
const link = document.createElement("a");
const content = document.getElementById("m3u-raw").value
const file = new Blob([content], { type: 'text/plain' });
link.href = URL.createObjectURL(file);
link.download = "{{ playlist.code }}.m3u8";
link.click();
URL.revokeObjectURL(link.href);
}
function resetGroup() {
document.getElementById('groupSelector').value = 'all'
updateFilter()
}
function resetSearch() {
list.search('')
document.getElementById('search-field').value = ''
document.getElementById('chfAll').checked = true
document.querySelectorAll('input[id*="btn-tag-"]:checked').forEach(tag => tag.checked = false)
updateFilter()
}
function updateFilter() {
const groupHash = document.getElementById('groupSelector')?.value ?? 'all';
const tagsElements = document.querySelectorAll('input[id*="btn-tag-"]:checked')
const tagsSelected = []
tagsElements.forEach(tag => tagsSelected.push(tag.attributes['data-tag'].value));
const activeType = document.querySelector('input[name="chFilter"]:checked').id;
switch (activeType) {
case 'chfAll':
list.filter(item => {
const chTags = item.values().chtags.split('|');
const isGroupValid = item.values().group === groupHash || groupHash === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && hasValidTags;
})
break
case 'chfOnline':
list.filter(item => {
const isOnline = item.values().online === '1'
const chTags = item.values().chtags.split('|');
const isGroupValid = item.values().group === groupHash || groupHash === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOnline && hasValidTags
})
break
case 'chfOffline':
list.filter(item => {
const isOffline = item.values().online === '0'
const chTags = item.values().chtags.split('|');
const isGroupValid = item.values().group === groupHash || groupHash === 'all';
const tagsIntersection = tagsSelected.filter(tagSelected => chTags.includes(tagSelected));
const hasValidTags = tagsIntersection.length > 0 || tagsSelected.length === 0;
return isGroupValid && isOffline && hasValidTags
})
break
}
}
</script>
{% endblock %}