├── .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 |
51 |
52 |
53 | 54 |
55 |
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 |
6 |

Demo website

7 | m3u8 media 8 |
9 | 10 |
11 |

Built with

12 | hls.js 13 |
14 | 15 |
16 |

Created with love by

17 | Haikel Fazzani 18 |
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
    26 | {children} 27 | 28 | {channels && channels.length > 0 && channels.map((c, i) =>
  • 31 |
    { onPlay(c) }}> 33 | {currentChannel.url === c.url 34 | ? 35 | : } 36 | {c.name} 37 |
    38 | 39 |
    { onAddOrRemoveFromFavorites(c) }} title="Add to favorites"> 40 | f.url == c.url) ? "#e91e63" : "#fff"} /> 41 |
    42 |
  • )} 43 |
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
    25 | {favorites.map((c, i) =>
  • 28 |
    { currentChannelActions.set({ ...c, qualityIndex: -1 }); }}> 30 | {currentChannel.url === c.url 31 | ? 32 | : } 33 | {c.name} 34 |
    35 | 36 | 37 |
    { onAddOrRemoveFromFavorites(c); }} title="Remove from favorites"> 38 | 39 |
    40 |
  • )} 41 |
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
    28 | 29 |
  • 30 | 36 |
  • 37 | 38 | {chs.length > 0 && chs.map((c, i) =>
  • 41 |
    { currentChannelActions.set({ ...c }); }}> 43 | {currentChannel.url === c.url 44 | ? 45 | : } 46 | {c.name} 47 |
    48 | 49 | {/*
    { channelsActions.addToFavorites(c) }} title="Add to favorites"> 50 | f.url == c.url) ? "#e91e63" : "#fff"} /> 51 |
    */} 52 |
  • )} 53 |
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 |
35 |
36 | 37 | 43 |
44 | 45 | 49 | 50 | 56 | 57 |
58 | 61 | 62 | 63 |
64 |
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 |
27 | 28 | 29 |
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 t 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/CircleIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function CircleIcon({ color = 'currentColor' }) { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/EllipsisIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function EllipsisIcon({ color = 'currentColor' }) { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/HeartIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function HeartIcon({ color = 'currentColor' }) { 4 | return 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/icons/InfoIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function InfoIcon() { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/LoadIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function LoadIcon() { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/PlayIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function PlayIcon({ width = "18", height = "18" }) { 4 | return 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/icons/PlusIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function PlusIcon() { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/TimesIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function TimesIcon() { 4 | return 5 | 6 | } 7 | -------------------------------------------------------------------------------- /src/icons/TrashIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function TrashIcon() { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/TvIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function TvIcon() { 4 | return 5 | 6 | 7 | } 8 | -------------------------------------------------------------------------------- /src/icons/YoutubeIcon.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function YoutubeIcon({ color = 'currentColor' }) { 4 | return 5 | 6 | 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 | }); --------------------------------------------------------------------------------