├── .gitignore
├── LICENSE
├── capture.png
├── index.html
├── manifest-v2.json
├── manifest.js
├── package.json
├── plugin.js
├── public
├── icons
│ ├── 128.png
│ ├── 34.png
│ └── 512.png
├── manifest.json
├── popup.css
├── popup.html
└── popup.js
├── src
├── App.jsx
├── components
│ ├── About.jsx
│ ├── ChannelQualityInfo.jsx
│ ├── ChannelQualityList.jsx
│ ├── Header.jsx
│ ├── ListChannels.jsx
│ ├── ListFavorites.jsx
│ ├── ListYt.jsx
│ ├── Modal.jsx
│ └── VideoContainer.jsx
├── forms
│ ├── FormAddNewChannel.jsx
│ └── FormLoadFromUrl.jsx
├── icons
│ ├── CaretDownIcon.jsx
│ ├── CircleIcon.jsx
│ ├── EllipsisIcon.jsx
│ ├── HeartIcon.jsx
│ ├── InfoIcon.jsx
│ ├── LoadIcon.jsx
│ ├── PlayIcon.jsx
│ ├── PlusIcon.jsx
│ ├── TimesIcon.jsx
│ ├── TrashIcon.jsx
│ ├── TvIcon.jsx
│ └── YoutubeIcon.jsx
├── index.css
├── main.jsx
└── store
│ ├── useAside.js
│ ├── useChannels.js
│ ├── useCurrentChannel.js
│ └── useModal.js
└── vite.config.js
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 | package-lock.json
15 |
16 | # Editor directories and files
17 | .vscode/*
18 | !.vscode/extensions.json
19 | .idea
20 | .DS_Store
21 | *.suo
22 | *.ntvs*
23 | *.njsproj
24 | *.sln
25 | *.sw?
26 | *.zip
27 | *.rar
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2019 Haikel Fazzani
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/capture.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chromo-lib/m3u8/13c81e52308f0b26977b6bb1439bebbd6e409096/capture.png
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | HLS Streaming Player
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/manifest-v2.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 2,
3 | "version": "1.0.9",
4 | "name": "HLS Streaming Player",
5 | "description": "MP4, WebM, Iframe and HLS Streaming Player with free TV channels",
6 | "icons": {
7 | "34": "icons/34.png",
8 | "128": "icons/128.png",
9 | "512": "icons/512.png"
10 | },
11 | "browser_action": {
12 | "default_title": "HLS Streaming Player",
13 | "default_popup": "popup.html"
14 | },
15 | "permissions": [
16 | "tabs"
17 | ],
18 | "content_security_policy": "script-src 'self'; object-src 'self'"
19 | }
20 |
--------------------------------------------------------------------------------
/manifest.js:
--------------------------------------------------------------------------------
1 | import { readdirSync, readFileSync, writeFileSync } from 'fs'
2 | import { resolve } from 'path';
3 |
4 | const isChrome = process.env.BROWSER === undefined ? true : process.env.BROWSER === 'chrome';
5 |
6 | if (!isChrome) {
7 |
8 | const files = readdirSync(process.cwd() + '/dist');
9 |
10 | files.forEach(file => {
11 | if (file.includes('.js') && /\.js$/gi.test(file)) {
12 | const content = readFileSync(resolve(process.cwd(), 'dist', file), 'utf8');
13 | const rep = content.replace(/chrome\./g, 'browser.');
14 | const reps = rep.replace(/chrome\.action|browser.action/g, 'browser.browserAction');
15 | writeFileSync(resolve(process.cwd(), 'dist', file), reps);
16 | }
17 | });
18 |
19 | const content = readFileSync(resolve(process.cwd(), 'manifest-v2.json'), 'utf8');
20 | writeFileSync(resolve(process.cwd(), 'dist', 'manifest.json'), content);
21 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "m3u8",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "description": "🎥 HLS Streaming Player",
7 | "scripts": {
8 | "dev": "vite",
9 | "build": "vite build",
10 | "preview": "vite preview",
11 | "chrome:manifest": "BROWSER=chrome node manifest.js",
12 | "firefox:manifest": "BROWSER=firefox node manifest.js",
13 | "build:firefox": "NODE_ENV=production BROWSER=firefox vite build && npm run firefox:manifest",
14 | "build:chrome": "NODE_ENV=production BROWSER=chrome vite build && npm run chrome:manifest",
15 | "zip:chrome": "NODE_ENV=production BROWSER=chrome npm run build:chrome && npm run chrome:manifest && (cd dist; zip -r ../chrome.zip .)",
16 | "zip:firefox": "NODE_ENV=production BROWSER=firefox npm run build:firefox && npm run firefox:manifest && (cd dist; zip -r ../firefox.zip .)"
17 | },
18 | "dependencies": {
19 | "hls.js": "^1.2.8",
20 | "react": "^18.2.0",
21 | "react-dom": "^18.2.0",
22 | "react-sweet-state": "^2.6.5"
23 | },
24 | "devDependencies": {
25 | "@types/react": "^18.0.24",
26 | "@types/react-dom": "^18.0.8",
27 | "@vitejs/plugin-react": "^2.2.0",
28 | "vite": "^3.2.3"
29 | }
30 | }
--------------------------------------------------------------------------------
/plugin.js:
--------------------------------------------------------------------------------
1 | export function rollupReplaceWord({ from, to }) {
2 | return {
3 | name: 'transform-file',
4 |
5 | transform(src, id) {
6 | // console.log( id); path + filename
7 |
8 | let code = src.replace(new RegExp(`${from}\\.`, 'g'), to + '.');
9 |
10 | if (to === 'browser') {
11 | code = code.replace(/chrome\.action|browser.action/g, 'browser.browserAction');
12 | }
13 |
14 | return {
15 | code,
16 | map: null // provide source map if available
17 | }
18 | }
19 | }
20 | }
--------------------------------------------------------------------------------
/public/icons/128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chromo-lib/m3u8/13c81e52308f0b26977b6bb1439bebbd6e409096/public/icons/128.png
--------------------------------------------------------------------------------
/public/icons/34.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chromo-lib/m3u8/13c81e52308f0b26977b6bb1439bebbd6e409096/public/icons/34.png
--------------------------------------------------------------------------------
/public/icons/512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/Chromo-lib/m3u8/13c81e52308f0b26977b6bb1439bebbd6e409096/public/icons/512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "manifest_version": 3,
3 | "version": "1.0.9",
4 | "name": "HLS Streaming Player",
5 | "description": "MP4, WebM, Iframe and HLS Streaming Player with free TV channels",
6 | "action": {
7 | "default_popup": "popup.html",
8 | "default_icon": "icons/34.png"
9 | },
10 | "icons": {
11 | "34": "icons/34.png",
12 | "128": "icons/128.png",
13 | "512": "icons/512.png"
14 | },
15 | "permissions": [
16 | "tabs"
17 | ],
18 | "web_accessible_resources": [
19 | {
20 | "resources": [
21 | "*.js"
22 | ],
23 | "matches": [
24 | ""
25 | ]
26 | }
27 | ],
28 | "content_security_policy": {
29 | "extension_pages": "script-src 'self'; object-src 'self'"
30 | }
31 | }
--------------------------------------------------------------------------------
/public/popup.css:
--------------------------------------------------------------------------------
1 | * {box-sizing: border-box;}
2 |
3 | body {
4 | margin: 0;
5 | background-color: black;
6 | padding: 10px;
7 | font-size: 1rem;
8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
9 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
10 | sans-serif;
11 | -webkit-font-smoothing: antialiased;
12 | -moz-osx-font-smoothing: grayscale;
13 | letter-spacing: 2px;
14 | }
15 |
16 | button {
17 | min-width: 150px;
18 | padding: 10px;
19 | cursor: pointer;
20 | text-transform: uppercase;
21 | }
--------------------------------------------------------------------------------
/public/popup.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | HLS Streaming Player
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/public/popup.js:
--------------------------------------------------------------------------------
1 | const onOpenPlayer = () => {
2 | chrome.tabs.create({ url: 'index.html' });
3 | }
4 |
5 | document.getElementById('open-player').addEventListener('click', onOpenPlayer);
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback, useEffect, useState } from 'react';
2 | import ListFavorites from './components/ListFavorites';
3 | import VideoContainer from './components/VideoContainer';
4 | import TvIcon from './icons/TvIcon';
5 | import HeartIcon from './icons/HeartIcon';
6 | import ListChannels from './components/ListChannels';
7 | import Modal from './components/Modal';
8 | import useChannels from './store/useChannels';
9 | import ListYt from './components/ListYt';
10 | import Header from './components/Header';
11 | import YoutubeIcon from './icons/YoutubeIcon';
12 |
13 | export default function App() {
14 | const [channelsState, channelsActions] = useChannels();
15 | const [channel, setChannel] = useState('');
16 | const [tempChannels, setTempChannels] = useState(channelsState.defaultChannels);
17 |
18 | const tabs = [
19 | { name: 'Channels', icon: },
20 | { name: 'Youtube', icon: },
21 | { name: 'Favorites', icon: }
22 | ];
23 |
24 | const [tabIndex, setTabIndex] = useState(0);
25 |
26 | useEffect(() => {
27 | channelsActions.load(channelsState.url)
28 | .then(channels => {
29 | setTempChannels(channels)
30 | })
31 | }, []);
32 |
33 | const onChangeTab = useCallback((index) => {
34 | setTabIndex(index)
35 | }, []);
36 |
37 | const onSearch = (e) => {
38 | try {
39 | const ch = e.target.value.toLowerCase();
40 | const filtered = channelsState.defaultChannels.filter(c => c.name.toLowerCase().includes(ch));
41 | setTempChannels(filtered);
42 | setChannel(ch);
43 | } catch (error) {
44 | setTempChannels([]);
45 | setChannel('')
46 | }
47 | }
48 |
49 | return (
50 |
56 |
57 |
82 |
83 |
84 | );
85 | };
86 |
--------------------------------------------------------------------------------
/src/components/About.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function About() {
4 | return (<>
5 |
9 |
10 |
11 |
Built with
12 |
hls.js
13 |
14 |
15 |
19 | >)
20 | }
21 |
--------------------------------------------------------------------------------
/src/components/ChannelQualityInfo.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import useCurrentChannel from '../store/useCurrentChannel';
3 |
4 | export default function ChannelQualityInfo() {
5 | const [currentChannel] = useCurrentChannel();
6 | const qualityLevels = currentChannel.qualityLevels;
7 | const qualityIndex = currentChannel.qualityIndex;
8 |
9 | const index = qualityIndex > -1 ? qualityIndex : 0;
10 |
11 | if (qualityLevels.length && qualityLevels[index]) {
12 |
13 | return
14 | - Video Codec: {qualityLevels[index].videoCodec || '?'}
15 | - Audio Codec: {qualityLevels[index].audioCodec || '?'}
16 | - Height: {qualityLevels[index].height || '?'}
17 | - Width: {qualityLevels[index].width || '?'}
18 | - bitrate: {qualityLevels[index].bitrate/1000 || '?'}Kps
19 | - Quality: {qualityIndex > -1 ? (qualityLevels[index].name || qualityLevels[index].height+'p') : 'auto'}
20 |
21 | }
22 | else {
23 | return <>>
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/src/components/ChannelQualityList.jsx:
--------------------------------------------------------------------------------
1 | import React, {useCallback} from 'react'
2 | import useCurrentChannel from '../store/useCurrentChannel';
3 | import CaretDownIcon from '../icons/CaretDownIcon';
4 |
5 | export default function ChannelQualityList() {
6 |
7 | const [currentChannel, currentChannelActions] = useCurrentChannel()
8 | const qualityLevels = currentChannel.qualityLevels;
9 | const qualityIndex = currentChannel.qualityIndex;
10 |
11 | const onChange = useCallback(levelIndex => {
12 | currentChannelActions.setQualityIndex(levelIndex);
13 | }, []);
14 |
15 | if (qualityLevels && currentChannel.type === 'm3u8') {
16 | return
17 |
18 |
26 |
27 |
28 | - { onChange(-1) }}>auto
29 | {qualityLevels.map((q, i) => - { onChange(i) }}>{q.height}p
)}
33 |
34 |
35 | }
36 | else {
37 | return <>>
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | import React, { useCallback } from 'react';
2 |
3 | import ChannelQualityList from './ChannelQualityList';
4 | import FormAddNewChannel from '../forms/FormAddNewChannel';
5 |
6 | import useModal from '../store/useModal';
7 | import FormLoadFromUrl from '../forms/FormLoadFromUrl';
8 | import About from './About';
9 |
10 | import PlusIcon from '../icons/PlusIcon';
11 | import LoadIcon from '../icons/LoadIcon';
12 | import InfoIcon from '../icons/InfoIcon';
13 | import EllipsisIcon from '../icons/EllipsisIcon';
14 |
15 | export default function Header() {
16 | const [_, modalActions] = useModal();
17 |
18 | const onMenu = useCallback((e) => {
19 | const title = e.target.title || e.target.parentNode.title;
20 |
21 | switch (title) {
22 | case 'About':
23 | modalActions.setContent({ title: 'About', content: });
24 | break;
25 |
26 | case 'Play Or Add New Channel':
27 | modalActions.setContent({ title: 'Add New Channel', content: });
28 | break;
29 |
30 | case 'Load Channels From URL':
31 | modalActions.setContent({ title: 'Load From Url', content: });
32 | break;
33 |
34 | default:
35 | break;
36 | }
37 | }, []);
38 |
39 | return
40 |
41 |
42 |
43 |
44 |
45 | -
49 |
50 | add new channel
51 |
52 |
53 | -
57 |
58 | Load Channels From URL
59 |
60 |
61 | -
65 |
66 | About
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/ListChannels.jsx:
--------------------------------------------------------------------------------
1 | import React,{useCallback} from 'react';
2 | import useChannels from '../store/useChannels';
3 | import useCurrentChannel from '../store/useCurrentChannel';
4 | import PlayIcon from '../icons/PlayIcon';
5 | import TvIcon from '../icons/TvIcon';
6 | import HeartIcon from '../icons/HeartIcon';
7 |
8 | function ListChannels({ children, channels }) {
9 | const [channelsState, channelsActions] = useChannels();
10 | const [currentChannel, currentChannelActions] = useCurrentChannel();
11 |
12 | const onAddOrRemoveFromFavorites = (channel) => {
13 | if (channelsState.favorites.find(c => c.url === channel.url)) {
14 | channelsActions.removeFromFavorites(channel);
15 | }
16 | else {
17 | channelsActions.addToFavorites(channel)
18 | }
19 | }
20 |
21 | const onPlay = useCallback((channel) => {
22 | currentChannelActions.set({ ...channel });
23 | },[currentChannel.url]);
24 |
25 | return
44 | }
45 |
46 | export default ListChannels
--------------------------------------------------------------------------------
/src/components/ListFavorites.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import useChannels from '../store/useChannels';
3 | import useCurrentChannel from '../store/useCurrentChannel';
4 | import PlayIcon from '../icons/PlayIcon';
5 | import TvIcon from '../icons/TvIcon';
6 | import TrashIcon from '../icons/TrashIcon';
7 |
8 | export default function ListFavorites() {
9 | const [channelsState, channelsActions] = useChannels();
10 | const { favorites } = channelsState;
11 | const [currentChannel, currentChannelActions] = useCurrentChannel();
12 |
13 | if (favorites.length < 1) return <>>
14 |
15 | const onAddOrRemoveFromFavorites = (channel) => {
16 | if (channelsState.favorites.find(c => c.url === channel.url)) {
17 | channelsActions.removeFromFavorites(channel);
18 | }
19 | else {
20 | channelsActions.addToFavorites(channel)
21 | }
22 | }
23 |
24 | return
42 | }
--------------------------------------------------------------------------------
/src/components/ListYt.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import useCurrentChannel from '../store/useCurrentChannel';
3 | import PlayIcon from '../icons/PlayIcon';
4 | import TvIcon from '../icons/TvIcon';
5 |
6 | function ListYt({ channels }) {
7 | const [currentChannel, currentChannelActions] = useCurrentChannel();
8 | const [chs, setChs] = useState([]);
9 | const [channel, setChannel] = useState('');
10 |
11 | useEffect(() => {
12 | setChs(channels);
13 | }, [channels])
14 |
15 | const onSearch = (e) => {
16 | try {
17 | const ch = e.target.value.toLowerCase();
18 | const filtered = channels.filter(c => c.name.toLowerCase().includes(ch));
19 | setChs(filtered);
20 | setChannel(ch)
21 | } catch (error) {
22 | setChs(channels);
23 | setChannel('')
24 | }
25 | }
26 |
27 | return
54 | }
55 |
56 | export default React.memo(ListYt)
--------------------------------------------------------------------------------
/src/components/Modal.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import CircleIcon from '../icons/CircleIcon';
3 | import TimesIcon from '../icons/TimesIcon';
4 | import useModal from '../store/useModal';
5 |
6 | export default function Modal() {
7 | const [modalState, modalActions] = useModal();
8 |
9 | return
10 |
11 |
12 |
13 |
14 | {modalState.title}
15 |
16 |
17 |
18 |
19 |
20 |
{modalState.content}
21 |
22 | }
23 |
--------------------------------------------------------------------------------
/src/components/VideoContainer.jsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from 'react';
2 | import Hls from 'hls.js';
3 | import useCurrentChannel from '../store/useCurrentChannel';
4 |
5 | import ChannelQualityInfo from './ChannelQualityInfo';
6 | import PlayIcon from '../icons/PlayIcon';
7 | import useModal from '../store/useModal';
8 |
9 | let hls = null;
10 |
11 | export default function VideoContainer() {
12 | const videoRef = useRef();
13 | const [currentChannel, currentChannelActions] = useCurrentChannel();
14 | const [_, modalActions] = useModal();
15 |
16 | const onManifestParsed = (_, data) => {
17 | currentChannelActions.setQualityLevels(data.levels)
18 | }
19 |
20 | const onHlsError = (event, data) => {
21 | //console.log('HLS.Events.ERROR: ', event, data);
22 | if (data.fatal) {
23 | switch (data.type) {
24 | case Hls.ErrorTypes.NETWORK_ERROR:
25 | modalActions.setContent({ title: 'NETWORK_ERROR', content: {currentChannel.url}
});
26 | hls.destroy();
27 | break;
28 | case Hls.ErrorTypes.MEDIA_ERROR:
29 | modalActions.setContent({ title: 'MEDIA_ERROR', content: {currentChannel.url}
});
30 | hls.recoverMediaError();
31 | break;
32 | default:
33 | hls.destroy();
34 | break;
35 | }
36 | }
37 |
38 | localStorage.clear('current-channel');
39 | }
40 |
41 | useEffect(() => {
42 | if (!videoRef.current) return;
43 | const video = videoRef.current;
44 |
45 | if (hls) hls.destroy();
46 |
47 | if (currentChannel.type === 'm3u8') {
48 | if (Hls.isSupported() && currentChannel.type === 'm3u8') {
49 | hls = new Hls();
50 | hls.loadSource(currentChannel.url);
51 | hls.attachMedia(video);
52 | hls.currentLevel = parseInt(currentChannel.qualityIndex, 10);
53 |
54 | hls.on(Hls.Events.MANIFEST_PARSED, onManifestParsed);
55 | hls.on(Hls.Events.ERROR, onHlsError);
56 | }
57 | else {
58 | video.src = currentChannel.url;
59 | video.addEventListener('canplay', async () => {
60 | await video.play();
61 | });
62 | }
63 | }
64 |
65 | return () => {
66 | if (hls) {
67 | hls.off(Hls.Events.MANIFEST_PARSED, onManifestParsed);
68 | hls.off(Hls.Events.ERROR, onHlsError);
69 | }
70 | }
71 | }, [currentChannel.url, currentChannel.qualityIndex]);
72 |
73 | return <>
74 | {currentChannel && currentChannel.type === 'iframe'
75 | ?
82 | : }
83 |
84 |
85 |
86 |
87 |
{currentChannel.name}
88 |
({currentChannel.url})
89 |
90 |
91 |
92 | >
93 | }
94 |
--------------------------------------------------------------------------------
/src/forms/FormAddNewChannel.jsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 |
3 | import useChannels from '../store/useChannels';
4 | import useCurrentChannel from '../store/useCurrentChannel';
5 | import PlayIcon from '../icons/PlayIcon';
6 | import PlusIcon from '../icons/PlusIcon';
7 |
8 | export default function FormAddNewChannel() {
9 | const [currentChannel, currentChannelActions] = useCurrentChannel();
10 | const [channelsState, channelsActions] = useChannels();
11 |
12 | const initState = { name: '', url: '', type: 'm3u8' };
13 | const [newChannel, setNewChannel] = useState(initState);
14 |
15 | const onPlayChannelChange = e => {
16 | setNewChannel({ ...newChannel, [e.target.name]: e.target.value });
17 | }
18 |
19 | const onPlayChannel = e => {
20 | e.preventDefault();
21 | currentChannelActions.set(newChannel);
22 | }
23 |
24 | const onAddChannel = () => {
25 | if (newChannel.type === 'iframe') { channelsActions.addNewIframeChannel(newChannel); }
26 | else { channelsActions.addNew(newChannel); }
27 | }
28 |
29 | const onReset = () =>{
30 | setNewChannel(initState)
31 | }
32 |
33 | return <>
34 |
65 |
66 |
70 | >
71 | }
72 |
--------------------------------------------------------------------------------
/src/forms/FormLoadFromUrl.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import useChannels from '../store/useChannels';
3 | import useModal from '../store/useModal';
4 |
5 | const example = `[
6 | {
7 | "url": "https://siloh-ns1.plutotv.net/lilo/production/bein/master_1.m3u8",
8 | "name": "Bein Sport XTRA",
9 | "language': "en"
10 | }
11 | ]`;
12 |
13 | export default function FormLoadFromUrl() {
14 | const [_, channelsActions] = useChannels();
15 | const [modalState, modalActions] = useModal();
16 |
17 | const onLoad = async e => {
18 | e.preventDefault();
19 | const url = e.target.elements[0].value;
20 | await channelsActions.load(url);
21 | modalActions.toggle();
22 | window.location.reload()
23 | }
24 |
25 | return <>
26 |
30 |
31 | Playlist object structure:
32 | {example}
33 | >
34 | }
35 |
--------------------------------------------------------------------------------
/src/icons/CaretDownIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function CaretDownIcon() {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/CircleIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function CircleIcon({ color = 'currentColor' }) {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/EllipsisIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function EllipsisIcon({ color = 'currentColor' }) {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/HeartIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function HeartIcon({ color = 'currentColor' }) {
4 | return
6 | }
7 |
--------------------------------------------------------------------------------
/src/icons/InfoIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function InfoIcon() {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/LoadIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function LoadIcon() {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/PlayIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function PlayIcon({ width = "18", height = "18" }) {
4 | return
6 | }
7 |
--------------------------------------------------------------------------------
/src/icons/PlusIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function PlusIcon() {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/TimesIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function TimesIcon() {
4 | return
6 | }
7 |
--------------------------------------------------------------------------------
/src/icons/TrashIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function TrashIcon() {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/TvIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function TvIcon() {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/icons/YoutubeIcon.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | export default function YoutubeIcon({ color = 'currentColor' }) {
4 | return
7 | }
8 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | scrollbar-width: thin;
4 | }
5 |
6 | :root {
7 | --white:#fff;
8 | --black:#000;
9 | --red:#f74e4e;
10 | --yellow: #EED75F;
11 | --shadow:0 1px 10px #34383c0f, 0 2px 4px #34383c14;
12 | }
13 |
14 | body {
15 | width: 100vw;
16 | height: 100vh;
17 | margin: 0;
18 | background-color: #050505;
19 | color: #fff;
20 | font-size: .9rem;
21 | letter-spacing: 0.6px;
22 | font-family: Arial, Helvetica, sans-serif;
23 | }
24 |
25 | #root {
26 | height: 100%;
27 | width: 100%;
28 | }
29 |
30 | main {
31 | height: 100%;
32 | width: 100%;
33 | display: flex;
34 | justify-content: space-between;
35 | padding: 10px;
36 | }
37 |
38 | section {
39 | width: calc(100% - 250px);
40 | height: 100%;
41 | text-align: center;
42 | display: flex;
43 | justify-content: center;
44 | align-items: center;
45 | overflow: hidden;
46 | }
47 |
48 | aside {
49 | width: 250px;
50 | height: 100%;
51 | background-color: rgb(44, 44, 44);
52 | overflow: hidden;
53 |
54 | box-shadow: var(--shadow);
55 | }
56 |
57 | aside .list-tv {
58 | height: 300px;
59 | }
60 |
61 | button,
62 | input,
63 | select {
64 | padding: 12px;
65 | outline: none;
66 | border: 0;
67 | }
68 |
69 | option,
70 | optgroup {
71 | background-color: #181818;
72 | color: #fff;
73 | }
74 |
75 | button {
76 | background-color: var(--yellow);
77 | display: flex;
78 | align-items: center;
79 | justify-content: center;
80 | text-transform: uppercase;
81 | cursor: pointer;
82 | transition: opacity .25s;
83 | }
84 |
85 | button:hover {
86 | opacity: 0.8;
87 | }
88 |
89 | ul {
90 | margin: 0;
91 | padding: 0;
92 | list-style: none;
93 | }
94 |
95 | li {
96 | padding: 10px 15px;
97 | transition: background-color .25s;
98 | text-transform: capitalize;
99 | border-bottom: 1px solid #181818;
100 | }
101 |
102 | li:hover {
103 | background-color: #ffffff33;
104 | }
105 |
106 | pre {
107 | background-color: #181818;
108 | padding: 10px;
109 | }
110 |
111 | p { word-break: break-all; }
112 |
113 | .tabs li { border-right: 1px solid #181818;}
114 | .tabs li:last-child { border-right: 0;}
115 |
116 | .active {
117 | color: var(--yellow);
118 | font-weight: 600;
119 | }
120 |
121 | .d-flex {
122 | display: flex;
123 | }
124 |
125 | .d-none {
126 | display: none;
127 | }
128 |
129 | .flex-column {
130 | flex-direction: column;
131 | }
132 |
133 | .align-center {
134 | align-items: center;
135 | }
136 |
137 | .justify-between {
138 | justify-content: space-between;
139 | }
140 |
141 | .grid-2-1 {
142 | display: grid;
143 | grid-template-columns: 75% 22%;
144 | justify-content: space-between;
145 | gap: 20px;
146 | }
147 |
148 | .max-content { width: max-content; }
149 |
150 | .w-100 {
151 | width: 100%;
152 | }
153 |
154 | .h-100 {
155 | min-height: 100%;
156 | }
157 |
158 | .br7 {
159 | border-radius: 7px;
160 | }
161 |
162 | .border-0 {
163 | border: 0;
164 | }
165 |
166 | .mr-2 {
167 | margin-right: 20px;
168 | }
169 |
170 | .mb-2 {
171 | margin-bottom: 20px;
172 | }
173 |
174 | .ml-2 {
175 | margin-left: 20px;
176 | }
177 |
178 | .ml-1 {
179 | margin-left: 10px;
180 | }
181 |
182 | .mt-1 {
183 | margin-top: 10px;
184 | }
185 |
186 | .mb-1 {
187 | margin-bottom: 10px;
188 | }
189 |
190 | .mr-1 {
191 | margin-right: 10px;
192 | }
193 |
194 |
195 | .py-1 {
196 | padding: 10px 0;
197 | }
198 |
199 | .p-0 {
200 | padding: 0;
201 | }
202 |
203 | .m-0 {
204 | margin: 0;
205 | }
206 |
207 | .mb-0 {
208 | margin-bottom: 0;
209 | }
210 |
211 | .mt-0 {
212 | margin-top: 0;
213 | }
214 |
215 | .cp {
216 | cursor: pointer;
217 | }
218 |
219 | .uppercase {
220 | text-transform: uppercase;
221 | }
222 |
223 | .center {
224 | text-align: center;
225 | }
226 |
227 | .truncate {
228 | width: 75%;
229 | white-space: nowrap;
230 | overflow: hidden;
231 | text-overflow: ellipsis;
232 | }
233 |
234 | .text-left {
235 | text-align: left;
236 | }
237 |
238 | .bg-blue {
239 | background-color: #3f51b5;
240 | color: var(--white);
241 | }
242 |
243 | .bg-yellow {
244 | background-color: var(--yellow);
245 | }
246 |
247 | .bg-light {
248 | background-color: #181818;
249 | }
250 |
251 | .bg-gray {
252 | background-color: #ffffff1f;
253 | }
254 |
255 | .bg-red {
256 | background-color: var(--red);
257 | color: var(--white);
258 | }
259 |
260 | .bg-inherit {
261 | background-color: inherit;
262 | color: inherit;
263 | border: 0;
264 | }
265 |
266 | .bg-white {background-color: var(--white); color: var(--black);}
267 |
268 | .white {
269 | color: var(--white) !important;
270 | }
271 |
272 | .yellow {
273 | color: var(--yellow);
274 | }
275 |
276 | .overflow {
277 | overflow: auto;
278 | }
279 |
280 | .blur {
281 | backdrop-filter: blur(2px);
282 | }
283 |
284 | .shadow {box-shadow: var(--shadow);}
285 |
286 | /********************************************/
287 | .dropdown {
288 | position: relative;
289 | background-color: transparent;
290 | padding: 0 15px;
291 | }
292 |
293 | .dropdown button { font-size: .9rem; background-color: inherit; color: #fff; letter-spacing: 2px; }
294 |
295 | .dropdown ul {
296 | display: none;
297 | position: absolute;
298 | border: 1px solid #747474;
299 | z-index: 999999;
300 | border-radius: 7px;
301 | }
302 |
303 | .dropdown li {
304 | font-size: 0.9rem;
305 | padding: 7px 15px;
306 | background-color: #585858;
307 | border-bottom: 1px solid #747474;
308 | cursor: pointer;
309 | }
310 |
311 | .dropdown:hover ul, .dropdown button:hover ul {display: block;}
312 |
313 | /**********************************************/
314 | .modal {
315 | max-width: 90%;
316 | max-height: 90%;
317 | position: fixed;
318 | right: 10px;
319 | bottom: 10px;
320 | flex-direction: column;
321 | background-color: #585858;
322 | border-radius: 7px;
323 | border: 1px solid #747474;
324 | }
325 |
326 | .modal>div {
327 | min-width: 200px;
328 | max-width: 450px;
329 | padding: 10px;
330 | box-shadow: 0 1px 10px #34383c0f, 0 2px 4px #34383c14;
331 | }
332 |
333 | .modal header {
334 | padding: 10px;
335 | border-bottom: 1px solid #747474;
336 | }
337 |
338 | /********************************************/
339 | .video-container {
340 | height: 100%;
341 | width: 100%;
342 | max-height: 100%;
343 | max-width: 80%;
344 | padding: 5px 0;
345 | display: flex;
346 | flex-direction: column;
347 | justify-content: space-between;
348 | }
349 |
350 | .video-container * {
351 | padding: 0;
352 | border: 0;
353 | font-size: 12px;
354 | }
355 |
356 | iframe { min-height: 350px; box-shadow: 0 1px 10px #34383c0f, 0 2px 4px #34383c14; }
357 |
358 | video {
359 | width: 100%;
360 | box-shadow: 0 1px 10px #34383c0f, 0 2px 4px #34383c14;
361 | }
362 | /********************************************/
363 | ::-webkit-scrollbar {
364 | width: 7px;
365 | height: 7px;
366 | }
367 |
368 | ::-webkit-scrollbar-thumb {
369 | background: #585858;
370 | border-radius: 7px;
371 | }
372 |
373 | ::-webkit-scrollbar-thumb:hover {
374 | background: inherit;
375 | }
--------------------------------------------------------------------------------
/src/main.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client'
3 | import App from './App';
4 | import './index.css';
5 |
6 | ReactDOM.createRoot(document.getElementById('root')).render(
7 |
8 |
9 |
10 | )
--------------------------------------------------------------------------------
/src/store/useAside.js:
--------------------------------------------------------------------------------
1 | import { createStore, createHook } from 'react-sweet-state';
2 |
3 | const Store = createStore({
4 | initialState: {
5 | show: false,
6 | width: '250px'
7 | },
8 |
9 | actions: {
10 | toggle: () => ({ setState, getState }) => {
11 | setState({ ...getState(), show: !getState().show });
12 | },
13 | },
14 |
15 | name: 'useAside',
16 | });
17 |
18 | const useAside = createHook(Store);
19 | export default useAside;
--------------------------------------------------------------------------------
/src/store/useChannels.js:
--------------------------------------------------------------------------------
1 | import { createStore, createHook } from 'react-sweet-state';
2 |
3 | const localFavorites = localStorage.getItem('favorites');
4 | const favorites = localFavorites ? JSON.parse(localFavorites) : [];
5 | const playlistURL = localStorage.getItem('playlist') || 'https://api.npoint.io/c6ee3f6b723c086b35af';
6 | // https://bitbucket.org/!api/2.0/snippets/haikel/4Eq4ox/065fea1d6a2a37a229e465450b7d0e473400e129/files/m3u8.txt
7 |
8 | const Store = createStore({
9 | initialState: {
10 | defaultChannels: [],
11 | iframeChannels: (() => {
12 | if (localStorage.getItem('iframe-channels')) return JSON.parse(localStorage.getItem('iframe-channels'))
13 | else return [{ name: 'Impossiblue to work from home', url: 'https://www.youtube.com/embed/jXFevxFOk7g', type: "iframe" }]
14 | })(),
15 |
16 | favorites,
17 | loading: false,
18 | url: playlistURL
19 | },
20 |
21 | actions: {
22 | addNew: (channel) => ({ setState, getState }) => {
23 | const favorites = [...getState().favorites];
24 | if (channel.url.length > 15 && !getState().favorites.some(c => c.url === channel.url)) {
25 | favorites.unshift(channel);
26 | setState({ ...getState(), favorites })
27 | localStorage.setItem('favorites', JSON.stringify(favorites));
28 | }
29 | },
30 | addToFavorites: (channel) => ({ setState, getState }) => {
31 | const favorites = [...getState().favorites];
32 | if (!getState().favorites.some(c => c.url === channel.url)) {
33 | favorites.unshift(channel);
34 | setState({ ...getState(), favorites })
35 | localStorage.setItem('favorites', JSON.stringify(favorites));
36 | }
37 | },
38 | removeFromFavorites: (channel) => ({ setState, getState }) => {
39 | const favorites = getState().favorites.filter(c => c.url !== channel.url);
40 | setState({ ...getState(), favorites })
41 | localStorage.setItem('favorites', JSON.stringify(favorites));
42 | },
43 | setDefaultChannels: (defaultChannels) => ({ setState, getState }) => {
44 | setState({ ...getState(), defaultChannels });
45 | },
46 | load: (url) => async ({ setState, getState }) => {
47 | if (getState().loading === true) return;
48 |
49 | setState({ ...getState(), loading: true });
50 |
51 | const response = await fetch(url);
52 | const defaultChannels = await response.json();
53 |
54 | setState({ ...getState(), loading: false, defaultChannels, url });
55 | localStorage.setItem('playlist', url);
56 | return defaultChannels;
57 | },
58 | addNewIframeChannel: (channel) => ({ setState, getState }) => {
59 | const channels = getState().iframeChannels || [];
60 | if (channels.length < 1 || !channels.some(c => c.url === channel.url)) {
61 | channels.unshift(channel);
62 | setState({ ...getState(), channels })
63 | localStorage.setItem('iframe-channels', JSON.stringify(channels));
64 | window.location.reload();
65 | }
66 | },
67 | },
68 |
69 | name: 'useChannels',
70 | });
71 |
72 | const useChannels = createHook(Store);
73 | export default useChannels;
--------------------------------------------------------------------------------
/src/store/useCurrentChannel.js:
--------------------------------------------------------------------------------
1 | import { createStore, createHook } from 'react-sweet-state';
2 |
3 | const localQualityIndex = localStorage.getItem('quality') || -1;
4 | const localChannel = localStorage.getItem('current-channel');
5 | const channel = localChannel ? JSON.parse(localChannel) : {
6 | name: 'nagtv',
7 | type: "m3u8",
8 | url: 'https://admdn2.cdn.mangomolo.com/nagtv/smil:nagtv.stream.smil/chunklist.m3u8'
9 | };
10 |
11 | const Store = createStore({
12 | initialState: {
13 | ...channel,
14 | qualityIndex: +localQualityIndex, // auto: -1
15 | qualityLevels: []
16 | },
17 |
18 | actions: {
19 | set: (channel) => ({ setState, getState }) => {
20 | setState({ ...getState(), ...channel });
21 | localStorage.setItem('current-channel', JSON.stringify(channel));
22 | },
23 | setQualityLevels: (qualityLevels) => ({ setState, getState }) => {
24 | setState({ ...getState(), qualityLevels });
25 | },
26 | setQualityIndex: (qualityIndex) => ({ setState, getState }) => {
27 | setState({ ...getState(), qualityIndex });
28 | localStorage.setItem('quality', qualityIndex);
29 | },
30 | },
31 |
32 | name: 'useCurrentChannel',
33 | });
34 |
35 | const useCurrentChannel = createHook(Store);
36 | export default useCurrentChannel;
--------------------------------------------------------------------------------
/src/store/useModal.js:
--------------------------------------------------------------------------------
1 | import { createStore, createHook } from 'react-sweet-state';
2 |
3 | const Store = createStore({
4 | initialState: {
5 | show: false,
6 | content: null,
7 | title: ''
8 | },
9 |
10 | actions: {
11 | toggle: () => ({ setState, getState }) => {
12 | setState({ ...getState(), show: !getState().show });
13 | },
14 |
15 | setContent: ({ title, content }) => ({ setState, getState }) => {
16 | setState({ ...getState(), show: true, title, content });
17 | },
18 | },
19 |
20 | name: 'useModal',
21 | });
22 |
23 | const useModal = createHook(Store);
24 | export default useModal;
--------------------------------------------------------------------------------
/vite.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 | import { rollupReplaceWord } from './plugin';
4 |
5 | console.log('process ===> ', process.env.BROWSER, process.env.NODE_ENV);
6 | const isChrome = process.env.BROWSER === undefined ? true : process.env.BROWSER === 'chrome';
7 | const from = isChrome ? 'browser' : 'chrome'; // this var for replaceWord plugin
8 | const to = isChrome ? 'chrome' : 'browser'; // this var for replaceWord plugin
9 |
10 | // https://vitejs.dev/config/
11 | export default defineConfig({
12 | plugins: [
13 | {
14 | ...rollupReplaceWord({ from, to }),
15 | enforce: 'pre'
16 | },
17 | react()
18 | ]
19 | });
--------------------------------------------------------------------------------