35 changed files with 12255 additions and 143 deletions
@ -0,0 +1,54 @@ |
|||||||
|
name: Build & deploy |
||||||
|
|
||||||
|
on: |
||||||
|
push: |
||||||
|
branches: |
||||||
|
- master |
||||||
|
pull_request: |
||||||
|
branches: |
||||||
|
- master |
||||||
|
|
||||||
|
jobs: |
||||||
|
build: |
||||||
|
name: Build |
||||||
|
runs-on: ubuntu-latest |
||||||
|
|
||||||
|
steps: |
||||||
|
- name: Checkout code |
||||||
|
uses: actions/checkout@v2 |
||||||
|
|
||||||
|
- name: Install Node.js |
||||||
|
uses: actions/setup-node@v1 |
||||||
|
with: |
||||||
|
node-version: 13.x |
||||||
|
|
||||||
|
- name: Install NPM packages |
||||||
|
run: npm ci |
||||||
|
|
||||||
|
- name: Build project |
||||||
|
run: npm run build |
||||||
|
|
||||||
|
- name: Upload production-ready build files |
||||||
|
uses: actions/upload-artifact@v2 |
||||||
|
with: |
||||||
|
name: production-files |
||||||
|
path: ./build |
||||||
|
|
||||||
|
deploy: |
||||||
|
name: Deploy |
||||||
|
needs: build |
||||||
|
runs-on: ubuntu-latest |
||||||
|
if: github.ref == 'refs/heads/master' |
||||||
|
|
||||||
|
steps: |
||||||
|
- name: Download artifact |
||||||
|
uses: actions/download-artifact@v2 |
||||||
|
with: |
||||||
|
name: production-files |
||||||
|
path: ./build |
||||||
|
|
||||||
|
- name: Deploy to gh-pages |
||||||
|
uses: peaceiris/actions-gh-pages@v3 |
||||||
|
with: |
||||||
|
github_token: ${{ secrets.GITHUB_TOKEN }} |
||||||
|
publish_dir: ./build |
||||||
@ -0,0 +1,23 @@ |
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. |
||||||
|
|
||||||
|
# dependencies |
||||||
|
node_modules |
||||||
|
/.pnp |
||||||
|
.pnp.js |
||||||
|
|
||||||
|
# testing |
||||||
|
/coverage |
||||||
|
|
||||||
|
# production |
||||||
|
/build |
||||||
|
|
||||||
|
# misc |
||||||
|
.DS_Store |
||||||
|
.env.local |
||||||
|
.env.development.local |
||||||
|
.env.test.local |
||||||
|
.env.production.local |
||||||
|
|
||||||
|
npm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
@ -1,5 +1,5 @@ |
|||||||
# movie-web |
# movie-web |
||||||
|
Small web app for watching movies easily. Check it out at **[movie.squeezebox.dev](https://movie.squeezebox.dev)**. |
||||||
Available at: [movie.squeezebox.dev](https://movie.squeezebox.dev) |
## Credits |
||||||
|
- Thanks to [@JipFr](https://github.com/JipFr) for initial work on [movie-cli](https://github.com/JipFr/movie-cli) |
||||||
Credits to [@JipFr](https://github.com/JipFr) for initial work on [movie-cli](https://github.com/JipFr/movie-cli) |
- Thanks to [@mrjvs](https://github.com/mrjvs) for help porting to React |
||||||
|
|||||||
@ -1,59 +0,0 @@ |
|||||||
@font-face { |
|
||||||
font-family: 'JetBrainsMono'; |
|
||||||
src: url(../fonts/JetBrainsMono-Regular.woff2); |
|
||||||
font-weight: 400; |
|
||||||
font-style: normal; |
|
||||||
} |
|
||||||
|
|
||||||
html, body { |
|
||||||
height: 1vh; |
|
||||||
} |
|
||||||
|
|
||||||
body { |
|
||||||
margin: 0; |
|
||||||
color: #95979F; |
|
||||||
background-color: #0c0e14; |
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40' viewBox='0 0 40 40'%3E%3Cg fill-rule='evenodd'%3E%3Cg fill='%23586ca8' fill-opacity='0.12'%3E%3Cpath d='M0 38.59l2.83-2.83 1.41 1.41L1.41 40H0v-1.41zM0 1.4l2.83 2.83 1.41-1.41L1.41 0H0v1.41zM38.59 40l-2.83-2.83 1.41-1.41L40 38.59V40h-1.41zM40 1.41l-2.83 2.83-1.41-1.41L38.59 0H40v1.41zM20 18.6l2.83-2.83 1.41 1.41L21.41 20l2.83 2.83-1.41 1.41L20 21.41l-2.83 2.83-1.41-1.41L18.59 20l-2.83-2.83 1.41-1.41L20 18.59z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E"); |
|
||||||
font-family: 'JetBrainsMono'; |
|
||||||
} |
|
||||||
|
|
||||||
.messages { |
|
||||||
background-color: #2D313D; |
|
||||||
border-radius: 10px; |
|
||||||
width: 80%; |
|
||||||
padding-left: 10px; |
|
||||||
} |
|
||||||
|
|
||||||
.error { |
|
||||||
color: #f3565d; |
|
||||||
} |
|
||||||
|
|
||||||
.info { |
|
||||||
color: #2e5bbd; |
|
||||||
} |
|
||||||
|
|
||||||
.content { |
|
||||||
padding: 1rem; |
|
||||||
border-radius: 10px; |
|
||||||
background-color: #2D313D; |
|
||||||
width: 80%; |
|
||||||
} |
|
||||||
|
|
||||||
.video { |
|
||||||
width: 100%; |
|
||||||
} |
|
||||||
|
|
||||||
form { |
|
||||||
background-color: #2D313D; |
|
||||||
padding: 5px; |
|
||||||
width: 300px; |
|
||||||
text-align: center; |
|
||||||
} |
|
||||||
|
|
||||||
input[type="submit"] { |
|
||||||
width: 20%; |
|
||||||
} |
|
||||||
|
|
||||||
input[type="text"] { |
|
||||||
width: 70%; |
|
||||||
} |
|
||||||
Binary file not shown.
@ -1,34 +0,0 @@ |
|||||||
<!DOCTYPE html> |
|
||||||
<html lang="en"> |
|
||||||
<head> |
|
||||||
<meta charset="UTF-8"> |
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"> |
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
||||||
<title>movie-web</title> |
|
||||||
|
|
||||||
<link rel="stylesheet" href="./assets/css/style.css" type="text/css"> |
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script> |
|
||||||
<script src="https://cdn.jsdelivr.net/npm/fuse.js@6.4.6"></script> |
|
||||||
<script src="https://unpkg.com/json5@^2.0.0/dist/index.min.js"></script> |
|
||||||
<script src="assets/js/index.js"></script> |
|
||||||
</head> |
|
||||||
|
|
||||||
<body> |
|
||||||
<form action='#' onsubmit='findMovie();return false;'> |
|
||||||
<input type='text' id='search' placeholder='Find movie...'><!-- |
|
||||||
--><input type='submit'> |
|
||||||
</form> |
|
||||||
|
|
||||||
<div class='content'> |
|
||||||
<video id="video" class="video" controls autoplay></video> |
|
||||||
</div> |
|
||||||
|
|
||||||
<div class='messages'> |
|
||||||
<strong> |
|
||||||
<p id='error' class='error'></p> |
|
||||||
<p id='info' class='info'></p> |
|
||||||
</strong> |
|
||||||
</div> |
|
||||||
</body> |
|
||||||
</html> |
|
||||||
@ -0,0 +1,42 @@ |
|||||||
|
{ |
||||||
|
"name": "movie-web", |
||||||
|
"version": "0.1.0", |
||||||
|
"private": true, |
||||||
|
"homepage": "https://movie.squeezebox.dev", |
||||||
|
"dependencies": { |
||||||
|
"@testing-library/jest-dom": "^5.11.4", |
||||||
|
"@testing-library/react": "^11.1.0", |
||||||
|
"@testing-library/user-event": "^12.1.10", |
||||||
|
"fuse.js": "^6.4.6", |
||||||
|
"hls.js": "^1.0.7", |
||||||
|
"json5": "^2.2.0", |
||||||
|
"react": "^17.0.2", |
||||||
|
"react-dom": "^17.0.2", |
||||||
|
"react-scripts": "4.0.3", |
||||||
|
"web-vitals": "^1.0.1" |
||||||
|
}, |
||||||
|
"scripts": { |
||||||
|
"start": "react-scripts start", |
||||||
|
"build": "react-scripts build", |
||||||
|
"test": "react-scripts test", |
||||||
|
"eject": "react-scripts eject" |
||||||
|
}, |
||||||
|
"eslintConfig": { |
||||||
|
"extends": [ |
||||||
|
"react-app", |
||||||
|
"react-app/jest" |
||||||
|
] |
||||||
|
}, |
||||||
|
"browserslist": { |
||||||
|
"production": [ |
||||||
|
">0.2%", |
||||||
|
"not dead", |
||||||
|
"not op_mini all" |
||||||
|
], |
||||||
|
"development": [ |
||||||
|
"last 1 chrome version", |
||||||
|
"last 1 firefox version", |
||||||
|
"last 1 safari version" |
||||||
|
] |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
<!DOCTYPE html> |
||||||
|
<html lang="en"> |
||||||
|
<head> |
||||||
|
<meta charset="utf-8" /> |
||||||
|
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> |
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" /> |
||||||
|
<meta name="theme-color" content="#000000" /> |
||||||
|
<meta |
||||||
|
name="description" |
||||||
|
content="because watching movies legally is boring" |
||||||
|
/> |
||||||
|
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> |
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> |
||||||
|
<title>movie-web</title> |
||||||
|
</head> |
||||||
|
<body> |
||||||
|
<noscript style="color: white">You need to enable JavaScript to run this app.</noscript> |
||||||
|
<div id="root"></div> |
||||||
|
</body> |
||||||
|
</html> |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
{ |
||||||
|
"short_name": "movie-web", |
||||||
|
"name": "movie-web", |
||||||
|
"icons": [ |
||||||
|
{ |
||||||
|
"src": "favicon.ico", |
||||||
|
"sizes": "64x64 32x32 24x24 16x16", |
||||||
|
"type": "image/x-icon" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"src": "logo192.png", |
||||||
|
"type": "image/png", |
||||||
|
"sizes": "192x192" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"src": "logo512.png", |
||||||
|
"type": "image/png", |
||||||
|
"sizes": "512x512" |
||||||
|
} |
||||||
|
], |
||||||
|
"start_url": ".", |
||||||
|
"display": "standalone", |
||||||
|
"theme_color": "#191c24", |
||||||
|
"background_color": "#0c0e14" |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
# https://www.robotstxt.org/robotstxt.html |
||||||
|
User-agent: * |
||||||
|
Disallow: |
||||||
@ -0,0 +1,31 @@ |
|||||||
|
import './index.css'; |
||||||
|
import { SearchView } from './views/Search'; |
||||||
|
import { NotFound } from './views/NotFound'; |
||||||
|
import { MovieView } from './views/Movie'; |
||||||
|
import { useMovie, MovieProvider} from './hooks/useMovie'; |
||||||
|
|
||||||
|
function Router() { |
||||||
|
const { page } = useMovie(); |
||||||
|
|
||||||
|
if (page === "search") { |
||||||
|
return <SearchView/> |
||||||
|
} |
||||||
|
|
||||||
|
if (page === "movie") { |
||||||
|
return <MovieView/> |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<NotFound/> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
function App() { |
||||||
|
return ( |
||||||
|
<MovieProvider> |
||||||
|
<Router/> |
||||||
|
</MovieProvider> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export default App; |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
.feather.left { |
||||||
|
transform: rotate(180deg); |
||||||
|
} |
||||||
|
|
||||||
|
.arrow { |
||||||
|
display: inline-block; |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
import React from 'react' |
||||||
|
import './Arrow.css' |
||||||
|
|
||||||
|
// left?: boolean
|
||||||
|
export function Arrow(props) { |
||||||
|
return ( |
||||||
|
<div className="arrow"> |
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1rem" height="1rem" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={`feather ${props.left?'left':''}`}> |
||||||
|
<line x1="5" y1="12" x2="19" y2="12"></line> |
||||||
|
<polyline points="12 5 19 12 12 19"></polyline> |
||||||
|
</svg> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
.card { |
||||||
|
background-color: #22232A; |
||||||
|
padding: 3rem 4rem; |
||||||
|
width: 39rem; |
||||||
|
max-width: 100%; |
||||||
|
margin: 0 3rem; |
||||||
|
border-radius: 10px; |
||||||
|
box-sizing: border-box; |
||||||
|
transition: height 500ms ease-in-out, transform 800ms ease-in-out, opacity 800ms ease-in-out; |
||||||
|
} |
||||||
|
|
||||||
|
.card.full { |
||||||
|
width: 75rem; |
||||||
|
} |
||||||
|
|
||||||
|
.card-wrapper { |
||||||
|
transition: height 500ms ease-in-out; |
||||||
|
overflow: hidden; |
||||||
|
} |
||||||
|
|
||||||
|
.card.doTransition { |
||||||
|
opacity: 0; |
||||||
|
transform: translateY(-.7rem); |
||||||
|
} |
||||||
|
.card.doTransition.show { |
||||||
|
opacity: 1; |
||||||
|
transform: translateY(0rem); |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
import React from 'react' |
||||||
|
import './Card.css' |
||||||
|
|
||||||
|
// fullWidth: boolean
|
||||||
|
// show: boolean
|
||||||
|
// doTransition: boolean
|
||||||
|
export function Card(props) { |
||||||
|
|
||||||
|
const [showing, setShowing] = React.useState(false); |
||||||
|
const measureRef = React.useRef(null) |
||||||
|
const [height, setHeight] = React.useState(0); |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if (!measureRef?.current) return; |
||||||
|
setShowing(props.show); |
||||||
|
setHeight(measureRef.current.clientHeight) |
||||||
|
}, [props.show, measureRef]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="card-wrapper" style={{ |
||||||
|
height: props.doTransition ? (showing ? height : 0) : "initial", |
||||||
|
}}> |
||||||
|
<div className={`card ${ props.fullWidth ? 'full' : '' } ${ showing ? 'show' : '' } ${ props.doTransition ? 'doTransition' : '' }`} ref={measureRef}> |
||||||
|
{props.children} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,72 @@ |
|||||||
|
.inputBar { |
||||||
|
width: 100%; |
||||||
|
display: flex; |
||||||
|
height: 3rem; |
||||||
|
} |
||||||
|
|
||||||
|
.inputBar > *:first-child{ |
||||||
|
border-radius: 0 !important; |
||||||
|
border-top-left-radius: 10px !important; |
||||||
|
border-bottom-left-radius: 10px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.inputBar > *:last-child { |
||||||
|
border-radius: 0 !important; |
||||||
|
border-top-right-radius: 10px !important; |
||||||
|
border-bottom-right-radius: 10px !important; |
||||||
|
} |
||||||
|
|
||||||
|
.inputTextBox { |
||||||
|
border-width: 0; |
||||||
|
outline: none; |
||||||
|
|
||||||
|
background-color: #36363e; |
||||||
|
color: white; |
||||||
|
padding: .7rem 1.5rem; |
||||||
|
height: auto; |
||||||
|
flex: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.inputSearchButton { |
||||||
|
background-color: #A73B83; |
||||||
|
border-width: 0; |
||||||
|
color: white; |
||||||
|
padding: .5rem 2.1rem; |
||||||
|
|
||||||
|
font-weight: bold; |
||||||
|
|
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.inputSearchButton:hover { |
||||||
|
background-color: #9C3179; |
||||||
|
} |
||||||
|
|
||||||
|
.inputTextBox:hover { |
||||||
|
background-color: #3C3D44; |
||||||
|
} |
||||||
|
|
||||||
|
.inputSearchButton .text > .arrow { |
||||||
|
opacity: 0; |
||||||
|
transition: opacity 0.2s ease-in-out, transform 0.2s ease-in-out; |
||||||
|
position: absolute; |
||||||
|
right: -0.8rem; |
||||||
|
bottom: -0.2rem; |
||||||
|
} |
||||||
|
.inputSearchButton .text { |
||||||
|
display: flex; |
||||||
|
position: relative; |
||||||
|
transition: transform 0.2s ease-in-out; |
||||||
|
} |
||||||
|
.inputSearchButton:hover .text > .arrow { |
||||||
|
transform: translateX(8px); |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.inputSearchButton:hover .text { |
||||||
|
transform: translateX(-10px); |
||||||
|
} |
||||||
|
|
||||||
|
.inputSearchButton:active { |
||||||
|
background-color: #8b286a; |
||||||
|
} |
||||||
@ -0,0 +1,26 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { Arrow } from './Arrow'; |
||||||
|
import './InputBox.css' |
||||||
|
|
||||||
|
// props = { onSubmit: (str) => {}, placeholder: string}
|
||||||
|
export function InputBox({ onSubmit, placeholder }) { |
||||||
|
const [value, setValue] = React.useState(""); |
||||||
|
|
||||||
|
return ( |
||||||
|
<form className="inputBar" onSubmit={(e) => { |
||||||
|
e.preventDefault(); |
||||||
|
onSubmit(value) |
||||||
|
return false; |
||||||
|
}}> |
||||||
|
<input |
||||||
|
type='text' |
||||||
|
className="inputTextBox" |
||||||
|
id="inputTextBox" |
||||||
|
placeholder={placeholder} |
||||||
|
value={value} |
||||||
|
onChange={(e) => setValue(e.target.value)} |
||||||
|
/> |
||||||
|
<button className="inputSearchButton"><span className="text">Search<span className="arrow"><Arrow/></span></span></button> |
||||||
|
</form> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,45 @@ |
|||||||
|
.movieRow { |
||||||
|
display: flex; |
||||||
|
border-radius: 5px; |
||||||
|
background-color: #35363D; |
||||||
|
color: white; |
||||||
|
padding: .8rem 1.5rem; |
||||||
|
margin-top: .5rem; |
||||||
|
cursor: pointer; |
||||||
|
transition: transform 50ms ease-in-out; |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow p { |
||||||
|
margin: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow .left { |
||||||
|
flex: 1; |
||||||
|
display: flex; |
||||||
|
align-items: flex-start; |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow .watch { |
||||||
|
color: #D678B7; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow .watch .arrow { |
||||||
|
margin-left: .5rem; |
||||||
|
transition: transform 50ms ease-in-out; |
||||||
|
transform: translateY(.1rem); |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow:active { |
||||||
|
transform: scale(1.02); |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow:hover { |
||||||
|
background-color: #3A3B40; |
||||||
|
} |
||||||
|
|
||||||
|
.movieRow:hover .watch .arrow { |
||||||
|
transform: translateX(.3rem) translateY(.1rem); |
||||||
|
} |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { Arrow } from './Arrow' |
||||||
|
import './MovieRow.css' |
||||||
|
|
||||||
|
// title: string
|
||||||
|
// onClick: () => void
|
||||||
|
export function MovieRow(props) { |
||||||
|
return ( |
||||||
|
<div className="movieRow" onClick={() => props.onClick && props.onClick()}> |
||||||
|
<div className="left"> |
||||||
|
{props.title} |
||||||
|
</div> |
||||||
|
<div className="watch"> |
||||||
|
<p>Watch movie</p> |
||||||
|
<Arrow/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,43 @@ |
|||||||
|
.progress { |
||||||
|
text-align: center; |
||||||
|
color: #BCBECB; |
||||||
|
display: flex; |
||||||
|
align-items: center; |
||||||
|
justify-content: center; |
||||||
|
flex-direction: column; |
||||||
|
height: 5rem; |
||||||
|
margin-top: 1rem; |
||||||
|
transition: height 800ms ease-in-out, opacity 800ms ease-in-out; |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
|
||||||
|
.progress.hide { |
||||||
|
opacity: 0; |
||||||
|
height: 0rem; |
||||||
|
} |
||||||
|
|
||||||
|
.progress p { |
||||||
|
margin: 0; |
||||||
|
margin-bottom: 1rem; |
||||||
|
} |
||||||
|
|
||||||
|
.progress .bar { |
||||||
|
width: 13rem; |
||||||
|
max-width: 100%; |
||||||
|
background-color: #35363D; |
||||||
|
border-radius: 10px; |
||||||
|
height: 7px; |
||||||
|
display: inline-block; |
||||||
|
} |
||||||
|
|
||||||
|
.progress .bar .bar-inner { |
||||||
|
transition: width 400ms ease-in-out, background-color 100ms ease-in-out; |
||||||
|
background-color: #D463AE; |
||||||
|
border-radius: 10px; |
||||||
|
height: 100%; |
||||||
|
width: 0%; |
||||||
|
} |
||||||
|
|
||||||
|
.progress.failed .bar .bar-inner { |
||||||
|
background-color: #d85b66; |
||||||
|
} |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import React from 'react' |
||||||
|
import './Progress.css' |
||||||
|
|
||||||
|
// show: boolean
|
||||||
|
// progress: number
|
||||||
|
// steps: number
|
||||||
|
// text: string
|
||||||
|
// failed: boolean
|
||||||
|
export function Progress(props) { |
||||||
|
return ( |
||||||
|
<div className={`progress ${props.show?'':'hide'} ${props.failed?'failed':''}`}> |
||||||
|
{ props.text && props.text.length > 0 ? ( |
||||||
|
<p>{props.text}</p>) : null} |
||||||
|
<div className="bar"> |
||||||
|
<div className="bar-inner" style={{ |
||||||
|
width: (props.progress / props.steps * 100).toFixed(0) + "%" |
||||||
|
}}/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,36 @@ |
|||||||
|
.title { |
||||||
|
font-size: 2rem; |
||||||
|
color: white; |
||||||
|
max-width: 20rem; |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
margin-bottom: 3.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.title-size-medium { |
||||||
|
font-size: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.title-accent { |
||||||
|
color: #E880C5; |
||||||
|
font-weight: 600; |
||||||
|
margin: 0; |
||||||
|
padding: 0; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
margin-top: 1rem; |
||||||
|
display: inline-block; |
||||||
|
} |
||||||
|
|
||||||
|
.title-accent.title-accent-link { |
||||||
|
cursor: pointer; |
||||||
|
} |
||||||
|
|
||||||
|
.title-accent.title-accent-link .arrow { |
||||||
|
transition: transform 100ms ease-in-out; |
||||||
|
transform: translateY(.1rem); |
||||||
|
margin-right: .2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.title-accent.title-accent-link:hover .arrow { |
||||||
|
transform: translateY(.1rem) translateX(-.5rem); |
||||||
|
} |
||||||
@ -0,0 +1,25 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { useMovie } from '../hooks/useMovie' |
||||||
|
import { Arrow } from '../components/Arrow' |
||||||
|
import './Title.css' |
||||||
|
|
||||||
|
// size: "big" | "medium" | "small" | null
|
||||||
|
// accent: string | null
|
||||||
|
// accentLink: string | null
|
||||||
|
export function Title(props) { |
||||||
|
const { navigate } = useMovie(); |
||||||
|
const size = props.size || "big"; |
||||||
|
|
||||||
|
const accentLink = props.accentLink || ""; |
||||||
|
const accent = props.accent || ""; |
||||||
|
return ( |
||||||
|
<div> |
||||||
|
{accent.length > 0 ? ( |
||||||
|
<p onClick={ () => accentLink.length > 0 && navigate(accentLink)} className={`title-accent ${accentLink.length > 0 ? 'title-accent-link' : ''}`}> |
||||||
|
{accentLink.length > 0 ? (<Arrow left/>) : null}{accent} |
||||||
|
</p> |
||||||
|
) : null} |
||||||
|
<h1 className={"title " + ( size ? 'title-size-' + size : '' )}>{props.children}</h1> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
import React from 'react' |
||||||
|
import Hls from 'hls.js' |
||||||
|
import './VideoElement.css' |
||||||
|
|
||||||
|
// streamUrl: string
|
||||||
|
export function VideoElement({ streamUrl }) { |
||||||
|
const videoRef = React.useRef(null); |
||||||
|
|
||||||
|
React.useEffect(() => { |
||||||
|
if (!videoRef || !videoRef.current) return; |
||||||
|
|
||||||
|
const hls = new Hls(); |
||||||
|
|
||||||
|
if (!Hls.isSupported() && videoRef.current.canPlayType('application/vnd.apple.mpegurl')) { |
||||||
|
videoRef.current.src = streamUrl; |
||||||
|
return; |
||||||
|
} else if (!Hls.isSupported()) { |
||||||
|
return; // TODO show error
|
||||||
|
} |
||||||
|
|
||||||
|
hls.attachMedia(videoRef.current); |
||||||
|
hls.loadSource(streamUrl); |
||||||
|
}, [videoRef, streamUrl]) |
||||||
|
|
||||||
|
return ( |
||||||
|
<video className="videoElement" ref={videoRef} controls autoPlay /> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,29 @@ |
|||||||
|
import React from 'react' |
||||||
|
const MovieContext = React.createContext(null) |
||||||
|
|
||||||
|
export function MovieProvider(props) { |
||||||
|
const [page, setPage] = React.useState("search"); |
||||||
|
const [stream, setStream] = React.useState(""); |
||||||
|
const [streamData, setStreamData] = React.useState({ title: "", type: "", episode: "", season: "" }) |
||||||
|
|
||||||
|
return ( |
||||||
|
<MovieContext.Provider value={{ |
||||||
|
navigate(str) { |
||||||
|
setPage(str) |
||||||
|
}, |
||||||
|
page, |
||||||
|
setStreamUrl: setStream, |
||||||
|
streamUrl: stream, |
||||||
|
streamData, |
||||||
|
setStreamData(d) { |
||||||
|
setStreamData(p => ({...p,...d})) |
||||||
|
}, |
||||||
|
}}> |
||||||
|
{props.children} |
||||||
|
</MovieContext.Provider> |
||||||
|
) |
||||||
|
} |
||||||
|
|
||||||
|
export function useMovie(props) { |
||||||
|
return React.useContext(MovieContext); |
||||||
|
} |
||||||
@ -0,0 +1,14 @@ |
|||||||
|
body, html { |
||||||
|
margin: 0; |
||||||
|
background-color: #16171D; |
||||||
|
min-height: 100vh; |
||||||
|
} |
||||||
|
|
||||||
|
body, html, input, button { |
||||||
|
font-family: 'Segoe UI', 'Roboto', 'Oxygen', |
||||||
|
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', |
||||||
|
sans-serif; |
||||||
|
-webkit-font-smoothing: antialiased; |
||||||
|
-moz-osx-font-smoothing: grayscale; |
||||||
|
font-size: 1rem; |
||||||
|
} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import ReactDOM from 'react-dom'; |
||||||
|
import './index.css'; |
||||||
|
import App from './App'; |
||||||
|
|
||||||
|
ReactDOM.render( |
||||||
|
<React.StrictMode> |
||||||
|
<App /> |
||||||
|
</React.StrictMode>, |
||||||
|
document.getElementById('root') |
||||||
|
); |
||||||
@ -0,0 +1,20 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { Title } from '../components/Title' |
||||||
|
import { Card } from '../components/Card' |
||||||
|
import { useMovie } from '../hooks/useMovie' |
||||||
|
import { VideoElement } from '../components/VideoElement' |
||||||
|
|
||||||
|
export function MovieView(props) { |
||||||
|
const { streamUrl, streamData } = useMovie(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="cardView"> |
||||||
|
<Card fullWidth> |
||||||
|
<Title accent="Return to home" accentLink="search"> |
||||||
|
{ streamData.title } |
||||||
|
</Title> |
||||||
|
<VideoElement streamUrl={streamUrl}/> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
import React from 'react' |
||||||
|
import { Title } from '../components/Title' |
||||||
|
import { Card } from '../components/Card' |
||||||
|
|
||||||
|
export function NotFound(props) { |
||||||
|
return ( |
||||||
|
<div className="cardView"> |
||||||
|
<Card> |
||||||
|
<Title accent="How did you end up here?"> |
||||||
|
Oopsie doopsie |
||||||
|
</Title> |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,17 @@ |
|||||||
|
.cardView { |
||||||
|
display: flex; |
||||||
|
min-height: 100vh; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
flex-direction: column; |
||||||
|
padding: 1rem; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
.cardView > div { |
||||||
|
margin-top: 2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.cardView > div:first-child { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
@ -0,0 +1,96 @@ |
|||||||
|
import React from 'react'; |
||||||
|
import { InputBox } from '../components/InputBox' |
||||||
|
import { Title } from '../components/Title' |
||||||
|
import { Card } from '../components/Card' |
||||||
|
import { MovieRow } from '../components/MovieRow' |
||||||
|
import { Progress } from '../components/Progress' |
||||||
|
import { findMovie, getStreamUrl } from '../lib/lookMovie' |
||||||
|
import { useMovie } from '../hooks/useMovie'; |
||||||
|
|
||||||
|
import './Search.css' |
||||||
|
|
||||||
|
export function SearchView() { |
||||||
|
const { navigate, setStreamUrl, setStreamData } = useMovie(); |
||||||
|
|
||||||
|
const maxSteps = 3; |
||||||
|
const [options, setOptions] = React.useState([]); |
||||||
|
const [progress, setProgress] = React.useState(0); |
||||||
|
const [text, setText] = React.useState(""); |
||||||
|
const [failed, setFailed] = React.useState(false); |
||||||
|
const [showingOptions, setShowingOptions] = React.useState(false); |
||||||
|
|
||||||
|
const fail = (str) => { |
||||||
|
setProgress(maxSteps); |
||||||
|
setText(str) |
||||||
|
setFailed(true) |
||||||
|
} |
||||||
|
|
||||||
|
async function getStream(title, slug, type) { |
||||||
|
setStreamUrl(""); |
||||||
|
try { |
||||||
|
setProgress(2); |
||||||
|
setText(`Getting stream for "${title}"`) |
||||||
|
const { url } = await getStreamUrl(slug, type); |
||||||
|
setProgress(maxSteps); |
||||||
|
setStreamUrl(url); |
||||||
|
setStreamData({ |
||||||
|
title, |
||||||
|
type, |
||||||
|
}) |
||||||
|
setText(`Streaming...`) |
||||||
|
navigate("movie") |
||||||
|
} catch (err) { |
||||||
|
fail("Failed to get stream") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
async function searchMovie(query) { |
||||||
|
setFailed(false); |
||||||
|
setText(`Searching for "${query}"`); |
||||||
|
setProgress(1) |
||||||
|
setShowingOptions(false) |
||||||
|
|
||||||
|
try { |
||||||
|
const { options } = await findMovie(query) |
||||||
|
|
||||||
|
if (options.length === 0) { |
||||||
|
return fail("Could not find that movie") |
||||||
|
} else if (options.length > 1) { |
||||||
|
setProgress(2); |
||||||
|
setText("Choose your movie") |
||||||
|
setOptions(options.map(v=>({ title: v.title, slug: v.slug, type: v.type }))); |
||||||
|
setShowingOptions(true) |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
const { title, slug, type } = options[0]; |
||||||
|
getStream(title, slug, type); |
||||||
|
} catch (err) { |
||||||
|
fail("Failed to watch movie") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className="cardView"> |
||||||
|
<Card> |
||||||
|
<Title accent="Because watching movies legally is boring"> |
||||||
|
What movie do you wanna watch? |
||||||
|
</Title> |
||||||
|
<InputBox placeholder="Hamilton" onSubmit={(str) => searchMovie(str)} /> |
||||||
|
<Progress show={progress > 0} failed={failed} progress={progress} steps={maxSteps} text={text} /> |
||||||
|
</Card> |
||||||
|
|
||||||
|
<Card show={showingOptions} doTransition> |
||||||
|
<Title size="medium"> |
||||||
|
Whoops, there are a few movies like that |
||||||
|
</Title> |
||||||
|
{options?.map((v, i) => ( |
||||||
|
<MovieRow key={i} title={v.title} onClick={() => { |
||||||
|
setShowingOptions(false) |
||||||
|
getStream(v.title, v.slug, v.type) |
||||||
|
}}/> |
||||||
|
))} |
||||||
|
</Card> |
||||||
|
</div> |
||||||
|
) |
||||||
|
} |
||||||
Loading…
Reference in new issue