Files
OctoSpaccHub/source/TiVuOcto/index.html

621 lines
17 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

<!DOCTYPE html>
<html>
<head>
<title>TiVuOcto 📺️</title>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<meta property="og:title" content="TiVuOcto 📺️"/>
<meta property="OctoSpaccHubSdk:Url" content="https://hub.octt.eu.org/TiVuOcto/"/>
<meta property="OctoSpaccHubSdk:WebManifestExtra" content="'display':'standalone', 'icons':[{ 'src':'./icon.jpeg', 'type':'image/jpeg', 'sizes':'1024x1024' }],"/>
<link rel="apple-touch-icon" href="./icon.jpeg"/>
<link rel="stylesheet" href="./node_modules/muicss/dist/css/mui.min.css"/>
<link rel="stylesheet" href="./node_modules/video.js/dist/video-js.min.css"/>
<style>
:root {
--headerHeight: 48px;
--sidebarWidth: 200px;
--colorBackground: #000;
--colorBackground2: #102;
--colorForeground: #fff;
--colorForeground2: #bbb;
--colorAccent: #305;
--colorAccent2: #203;
--colorAccent3: #407;
}
html,
body {
background-color: var(--colorBackground);
}
body a {
color: var(--colorForeground2);
}
html,
body,
input,
textarea,
button {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.004);
color: var(--colorForeground) !important;
}
.mui-textfield > input::placeholder {
color: var(--colorForeground2);
border-bottom-color: var(--colorForeground2);
}
.mui-dropdown hr {
margin: revert;
}
.mui-dropdown__menu > div > li > * {
display: block;
padding: 3px 20px;
clear: both;
font-weight: 400;
line-height: 1.429;
color: rgba(0,0,0,.87);
text-decoration: none;
white-space: nowrap;
}
.mui-dropdown__menu > div > li > *:focus,
.mui-dropdown__menu > div > li > *:hover {
text-decoration: none;
color: rgba(0,0,0,.87);
background-color: #EEE;
}
#header {
position: fixed;
top: 0;
right: 0;
left: 0;
z-index: 2;
transition: left 0.2s;
}
#sidedrawer {
position: fixed;
top: 0;
bottom: 0;
width: var(--sidebarWidth);
left: calc(0px - var(--sidebarWidth));
overflow: auto;
z-index: 2;
background-color: var(--colorBackground2);
transition: transform 0.2s;
word-break: break-word;
}
#content-wrapper {
margin-top: var(--headerHeight);
overflow-x: hidden;
margin-left: 0px;
transition: margin-left 0.2s;
}
@media (min-width: 768px) {
#header {
left: var(--sidebarWidth);
}
#sidedrawer {
transform: translate(var(--sidebarWidth));
}
#content-wrapper {
margin-left: var(--sidebarWidth);
}
#footer {
margin-left: var(--sidebarWidth);
}
body[data-hide-sidedrawer="1"] #header {
left: 0;
}
body[data-hide-sidedrawer="1"] #sidedrawer {
transform: translate(0px);
}
body[data-hide-sidedrawer="1"] #content-wrapper,
body[data-hide-sidedrawer="1"] #footer {
margin-left: 0;
}
}
#sidedrawer[data-active="true"] {
transform: translate(var(--sidebarWidth));
}
#header .sidedrawer-toggle,
#header .sidemenu-toggle {
line-height: 20px;
color: var(--colorForeground);
cursor: pointer;
user-select: none;
}
#header .sidedrawer-toggle {
margin-right: 10px;
}
#header .sidedrawer-toggle > img {
vertical-align: middle;
padding-bottom: 0.5em;
}
#header .sidemenu-toggle {
font-size: 21px;
}
#header .sidedrawer-toggle:focus,
#header .sidedrawer-toggle:hover,
#header .sidemenu-toggle:focus,
#header .sidemenu-toggle:hover {
opacity: 70%;
text-decoration: none;
}
#header .mui-dropdown {
float: right;
}
#sidedrawer-brand {
padding-left: 20px;
}
#sidedrawer > div.side-padded {
box-sizing: border-box;
width: 100%;
padding: 1em;
}
#sidedrawer .mui-dropdown {
z-index: 2;
}
#sidedrawer .mui-dropdown > button {
width: 100%;
background: var(--colorAccent3);
}
#sidedrawer > ul ul {
list-style: none;
padding: 1em;
}
#sidedrawer > ul {
padding-left: 0px;
}
#sidedrawer > ul > li > ul > li,
#sidedrawer > ul > li > ul > li > a {
padding: 0.5em;
padding-top: 1em;
padding-bottom: 1em;
}
#sidedrawer > ul > li > ul > li > a:target {
background: var(--colorAccent2);
}
#sidedrawer > ul > li > ul > li > a:focus,
#sidedrawer > ul > li > ul > li > a:hover {
background: var(--colorAccent);
}
#sidedrawer > ul > li > a {
position: sticky;
top: 0;
z-index: 1;
}
#sidedrawer ul#channels-list > li > a {
display: block;
padding: 15px 22px;
background-color: var(--colorBackground2);
font-weight: bold;
}
#sidedrawer ul#channels-list > li > a:focus,
#sidedrawer ul#channels-list > li > a:hover {
background-color: var(--colorAccent) !important;
}
#sidedrawer > ul > li > ul > li > a {
font-size: x-large;
display: block;
}
#sidedrawer > ul > li > ul > li > a > img {
width: auto;
height: auto;
max-width: 100%;
max-height: 160px;
/* <https://stackoverflow.com/questions/12690444/css-border-on-png-image-with-transparent-parts/70470266#70470266> */
filter:
drop-shadow(2px 0 0 var(--colorForeground))
drop-shadow(0 2px 0 var(--colorForeground))
drop-shadow(-2px 0 0 var(--colorForeground))
drop-shadow(0 -2px 0 var(--colorForeground));
}
#header .mui-appbar {
height: var(--headerHeight);
min-height: var(--headerHeight);
line-height: var(--headerHeight);
background-color: var(--colorAccent);
}
#video-player {
width: 100%;
height: calc(100vh - var(--headerHeight));
}
#no-video,
#label-loading,
#app-info {
box-sizing: border-box;
overflow: auto;
padding-left: 1em;
padding-right: 1em;
}
#no-video, #label-loading {
margin-top: 1em;
}
</style>
<style id="video-player-style"> #video-player { display: none; } </style>
</head>
<body>
<div id="sidedrawer" class="mui--no-user-select">
<div id="sidedrawer-brand" class="mui--appbar-line-height">
<span class="mui--text-title"></span>
</div>
<div class="mui-divider"></div>
<div class="side-padded">
<div class="mui-dropdown" id="channel-select">
<button class="mui-btn mui-btn--primary" data-mui-toggle="dropdown">
<span id="channel-selected">Channels</span> <span class="mui-caret"></span>
</button>
<ul class="mui-dropdown__menu">
<!-- TODO: implement these, add playlists, delete playlists, delete history
<li><a>⭐️ Favorites</a></li>
<li><a>📅️ Recent</a></li>
<hr/> -->
<li><a data-m3u="https://raw.githubusercontent.com/Free-TV/IPTV/master/playlist.m3u8">
Free-TV IPTV
</a></li>
<li><a data-m3u="https://iptv-org.github.io/iptv/index.m3u">
iptv-org IPTV
</a></li>
</ul>
</div>
<!--<div class="mui-textfield"><input type="text" placeholder="Search..."/></div>-->
</div>
<ul id="channels-list"></ul>
</div>
<header id="header">
<div class="mui-appbar mui--appbar-line-height">
<div class="mui-container-fluid"><!--
--><a class="sidedrawer-toggle mui--visible-xs-inline-block mui--visible-sm-inline-block js-show-sidedrawer"><img src="./menu.svg"/></a><!--
--><a class="sidedrawer-toggle mui--hidden-xs mui--hidden-sm js-hide-sidedrawer"><img src="./menu.svg"/></a><!--
--><span class="mui--text-title"></span>
<div class="mui-dropdown">
<a class="sidemenu-toggle" data-mui-toggle="dropdown"></a>
<ul class="mui-dropdown__menu mui-dropdown__menu--right">
<div id="stream-actions" hidden="true">
<!--<li><label><input type="checkbox"/> Favorite Channel ⭐️</label></li>-->
<li><a name="stream-info">Stream Info 📜️</a></li>
<hr/>
</div>
<div>
<li><a href="#/about">About TiVuOcto </a></li>
</div>
</ul>
</div>
</div>
</div>
</header>
<div id="content-wrapper" class="header-margined">
<div id="app-info">
<p id="no-javascript" style="padding-top: 1em;">
⚠️ You need to <a href="https://enable-javascript.com">enable JavaScript</a> to run this app.
</p>
<!-- <README /> -->
<h3>Changelog</h3>
<h4>2024-08-01</h4><ul>
<li>Improved UI: better-sized icons, added all button highlights, fixed element margins and mobile scaling, and add loading feedback text.</li>
<li>Improved autoplay to handle default <code>allowed</code> and <code>disallowed</code> in addition to <code>allowed-muted</code>.</li>
<li>Fixed the m3u8 parsing logic, which now doesn't exclude channels without EPG id.</li>
<li>Added a playlist selector dropdown, and with that a new IPTV list: iptv-org.</li>
<li>Improved the logic for loading a stream, now also handling reloading via reclick.</li>
<li>Added an experimental button to view information about the currently playing stream.</li>
</ul>
<h4>2024-07-30</h4><ul>
<li>First MVP version using the Free-TV IPTV list, and about screen.</li>
<li>Channel list in sidebar, divided by country with flag emojis.</li>
</ul>
</div>
<p id="label-loading" hidden="true">Loading...</p><!-- TODO make this more useful -->
<p id="no-video" hidden="true">Select a valid channel to play video.</p>
<video id="video-player" class="video-js" controls="controls" data-setup="{}"></video>
</div>
<script src="./node_modules/muicss/dist/js/mui.min.js"></script>
<script src="./node_modules/video.js/dist/video.min.js"></script>
<script>(async function(){
var COUNTRIES = {
ar: '🇸🇦️',
en: '🏴󠁧󠁢󠁥󠁮󠁧󠁿️',
es: '🇪🇸️',
albania: '🇦🇱️',
andorra: '🇦🇩️',
argentina: '🇦🇷️',
australia: '🇦🇺️',
austria: '🇦🇹️',
azerbaijan: '🇦🇿️',
belarus: '🇧🇾️',
belgium: '🇧🇪️',
bosnia_and_herzegovina: '🇧🇦️',
brazil: '🇧🇷️',
bulgaria: '🇧🇬️',
canada: '🇨🇦️',
chad: '🇹🇩️',
chile: '🇨🇱️',
china: '🇨🇳️',
costa_rica: '🇨🇷️',
croatia: '🇭🇷️',
cyprus: '🇨🇾️',
czech_republic: '🇨🇿️',
denmark: '🇩🇰️',
dominican_republic: '🇩🇴️',
estonia: '🇪🇪️',
faroe_islands: '🇫🇴️',
finland: '🇫🇮️',
france: '🇫🇷️',
georgia: '🇬🇪️',
germany: '🇩🇪️',
greece: '🇬🇷️',
greenland: '🇬🇱️',
hong_kong: '🇭🇰️',
hungary: '🇭🇺️',
iceland: '🇮🇸️',
india: '🇮🇳️',
iran: '🇮🇷️',
iraq: '🇮🇶️',
ireland: '🇮🇪️',
israel: '🇮🇱️',
italy: '🇮🇹️',
japan: '🇯🇵️',
korea: '🇰🇷️',
kosovo: '🇽🇰️',
latvia: '🇱🇻️',
lithuania: '🇱🇹️',
luxembourg: '🇱🇺️',
macau: '🇲🇴️',
malta: '🇲🇹️',
mexico: '🇲🇽️',
moldova: '🇲🇩️',
monaco: '🇲🇨️',
montenegro: '🇲🇪️',
netherlands: '🇳🇱️',
north_korea: '🇰🇵️',
north_macedonia: '🇲🇰️',
norway: '🇳🇴️',
paraguay: '🇵🇾️',
peru: '🇵🇪️',
poland: '🇵🇱️',
portugal: '🇵🇹️',
qatar: '🇶🇦️',
romania: '🇷🇴️',
russia: '🇷🇺️',
san_marino: '🇸🇲️',
saudi_arabia: '🇸🇦️',
serbia: '🇷🇸️',
slovakia: '🇸🇰️',
slovenia: '🇸🇮️',
somalia: '🇸🇴️',
spain: '🇪🇸️',
sweden: '🇸🇪️',
switzerland: '🇨🇭️',
taiwan: '🇹🇼️',
trinidad: '🇹🇹️',
turkey: '🇹🇷️',
uk: '🇬🇧️',
ukraine: '🇺🇦️',
united_arab_emirates: '🇦🇪️',
usa: '🇺🇸️',
venezuela: '🇻🇪️',
};
var loadingEl = document.querySelector('#label-loading');
var noVideoEl = document.querySelector('#no-video');
var appInfoEl = document.querySelector('#app-info');
var streamActionsEl = document.querySelector('#stream-actions');
var videoStyleEl = document.querySelector('style#video-player-style');
var videoEl = document.querySelector('#video-player');
var videoPlayer = videojs('video-player');
var APPNAME = document.title;
var PLAYERHIDDEN = videoStyleEl.innerHTML;
document.querySelector('#no-javascript').remove();
appInfoEl.hidden = true;
loadingEl.hidden = false;
setTitle();
document.querySelector('#sidedrawer .mui--text-title').textContent = APPNAME;
function setTitle (title='') {
title = title.trim();
document.title = (title ? `${title}${APPNAME}` : APPNAME);
document.querySelector('#header .mui--text-title').textContent = (title || APPNAME);
}
function getQuotedValue (data, key) {
return data.split(`${key}="`)?.[1]?.split('"')?.[0];
}
function getToActiveChannel () {
var channelEl = document.querySelector(`#sidedrawer #channels-list a[href="${location.hash}"]`);
if (!channelEl) {
return;
}
channelEl.parentElement.parentElement.hidden = false;
channelEl.parentElement.scrollIntoView();
return channelEl;
}
// TODO handle livestreams not skipping to realtime after time wasted buffering
async function loadFromHash (event) {
// TODO refactor this to not block the showing of metascreens
if (!document.querySelector('#sidedrawer #channels-list').innerHTML) {
return setTimeout((function(){ loadFromHash(event); }), 80);
}
loadingEl.hidden = true;
var channelEl = getToActiveChannel();
if (channelEl) {
//document.body.classList.remove('screen-about');
//document.body.classList.add('screen-player');
noVideoEl.hidden = true;
appInfoEl.hidden = true;
channelEl.click();
setTitle(channelEl.textContent);
var src = `https://hlb0.octt.eu.org/cors-main.php/${channelEl.dataset.src}`;
var headers = (await fetch(src)).headers;
videoPlayer.pause();
videoPlayer.src({ src, type: (headers.get('content-type') || 'application/x-mpegURL') });
videoStyleEl.innerHTML = '';
streamActionsEl.hidden = false;
if (event.videoInitialLoad && (navigator.getAutoplayPolicy("mediaelement") !== "allowed")) {
var policy = navigator.getAutoplayPolicy("mediaelement");
if (policy === "allowed-muted") {
videoPlayer.muted(true);
} else if (policy === "disallowed") {
return;
}
}
videoPlayer.play();
} else {
setTitle();
streamActionsEl.hidden = true;
videoStyleEl.innerHTML = PLAYERHIDDEN;
videoPlayer.pause();
//document.body.classList.remove('screen-player');
var hashMain = location.hash.split('/')[1];
switch (hashMain) {
default:
noVideoEl.hidden = false;
appInfoEl.hidden = true;
if (hashMain && (document.querySelector('#channel-select #channel-selected').dataset.index !== hashMain)) {
document.querySelectorAll('#channel-select ul > li > a[data-m3u]')[hashMain]?.click();
setTimeout((function(){ loadFromHash(event); }), 80);
}
break; case 'about':
noVideoEl.hidden = true;
appInfoEl.hidden = false;
//document.body.classList.add('screen-about');
}
}
}
var sideDrawerEl = document.querySelector('#sidedrawer');
document.querySelector('.js-show-sidedrawer').onclick = (function(){
var overlayEl = mui.overlay('on', { onclose: function(){
sideDrawerEl.dataset.active = false;
document.body.appendChild(sideDrawerEl);
}});
document.body.appendChild(overlayEl);
overlayEl.appendChild(sideDrawerEl);
sideDrawerEl.dataset.active = true;
getToActiveChannel();
});
document.querySelector('.js-hide-sidedrawer').onclick = (function(){
document.body.dataset.hideSidedrawer = Number(!Number(document.body.dataset.hideSidedrawer || 0));
});
streamActionsEl.querySelector('[name="stream-info"]').onclick = (function(){
alert(JSON.stringify(videoPlayer.tech().vhs.playlists.media().attributes));
});
async function loadChannelsM3u (url, index) {
var channelsHtml = '';
var channelGroupEls = {};
// TODO fetch error handling
var channels = (await (await fetch(url)).text()).split('\n#EXTINF:-1').slice(1);
for (var channel of channels) {
var groupName = getQuotedValue(channel, 'group-title');
var channelUrl = channel.split('\n')[1];
var channelId = (getQuotedValue(channel, 'tvg-id') || encodeURIComponent(channelUrl));
channelGroupEls[groupName] ||= '';
channelGroupEls[groupName] += `<li>
<a id="/${index}/${channelId}" href="#/${index}/${channelId}" data-src="${channelUrl}">
<span data-metaclick="null"></span><span data-metaclick="hashchange"></span>
<img loading="lazy" src="${getQuotedValue(channel, 'tvg-logo')}"/>
${getQuotedValue(channel, 'tvg-name') || channel.split(',').slice(1).join(',').split('\n')[0]}
</a>
</li>`;
}
for (var group in channelGroupEls) {
var groupLower = group.toLowerCase();
channelsHtml += `<li>
<a class="category-toggle" href="javascript:void(0);">${
COUNTRIES[groupLower.replaceAll(' ', '_')] ||
COUNTRIES[groupLower.replace('vod', '').trim().replaceAll(' ', '_')] ||
COUNTRIES[groupLower.split('(')[1]?.split(')')[0]] ||
''} ${group}
</a><ul>${channelGroupEls[group]}</ul>
</li>`;
}
document.querySelector('#sidedrawer > #channels-list').innerHTML = channelsHtml;
Array.from(document.querySelectorAll('#sidedrawer > ul#channels-list > li > ul > li > a')).forEach(function(linkEl){
linkEl.onclick = (function(event){
if (event.isTrusted && (linkEl.getAttribute('href') === location.hash)) {
loadFromHash({});
}
});
});
Array.from(document.querySelectorAll('#sidedrawer a.category-toggle')).forEach(function(titleEl){
var groupEl = titleEl.nextElementSibling;
groupEl.hidden = true;
titleEl.onclick = (function(){
groupEl.hidden = !groupEl.hidden;
titleEl.scrollIntoView();
});
});
}
Array.from(document.querySelectorAll('#channel-select ul > li > a')).forEach(function(listEl, index){
listEl.onclick = (async function(){
var channelEl = document.querySelector('#channel-select #channel-selected');
channelEl.textContent = listEl.textContent;
channelEl.dataset.index = index;
var m3uUrl = listEl.dataset.m3u;
if (m3uUrl) {
await loadChannelsM3u(m3uUrl, index);
}
});
});
document.querySelector('#channel-select ul > li > a[data-m3u]').click();
Array.from(document.querySelectorAll('a')).forEach(function(linkEl){
if (!linkEl.href) {
linkEl.href = 'javascript:void(0);';
}
});
window.addEventListener('hashchange', loadFromHash);
loadFromHash({ videoInitialLoad: true });
})();</script>
</body>
</html>