OctoSpaccHub/source/TiVuOcto/index.html

658 lines
19 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>
<h2>About TiVuOcto</h2><p>
A minimal webapp for playing IPTV streams.
Includes free TV channels from the entire world
with a responsive UI, clean UX, no ads, no spyware.
</p>
<h3>Disclaimer</h3><p>
This app allows users to play video live streams
based on Internet standards, from either embedded sources,
or user-specified sources that the app is not affiliated with.
<br/>
The embedded sources are specified below and publicly available,
only including fully legal free-to-watch/public-domain content.
This app does not make any pay-per-view content freely available,
nor does it include any DRM/geoblocking circumvention measures.
</p>
<h3>Licensing</h3><p>
Copyright (C) 2024 OctoSpacc
<br/>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
<br/>
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
<br/>
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <a href="https://www.gnu.org/licenses/">https://www.gnu.org/licenses/</a>.
</p>
<h3>Thanks and Third Party Libraries</h3>
<p>This app wouldn't have been possible without the following:</p><ul>
<li><a href="https://videojs.com">Video.js</a> player [<a href="https://github.com/videojs/video.js/blob/main/LICENSE">Apache 2.0</a>]</li>
<li><a href="https://muicss.com">MUI CSS</a> framework [<a href="https://github.com/muicss/mui/blob/master/LICENSE.txt">MIT</a>]</li>
<li><a href="https://github.com/Free-TV/IPTV">Free-TV IPTV</a> playlist</li>
<li><a href="https://iptv-org.github.io">iptv-org IPTV</a> playlist [<a href="https://github.com/iptv-org/iptv/blob/master/LICENSE">public domain</a>]</li>
</ul>
<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>