mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
Port to react
This commit is contained in:
parent
964b412053
commit
0b99f1c35e
35 changed files with 12255 additions and 143 deletions
54
.github/workflows/build-deploy.yml
vendored
Normal file
54
.github/workflows/build-deploy.yml
vendored
Normal file
|
@ -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
|
23
.gitignore
vendored
Normal file
23
.gitignore
vendored
Normal file
|
@ -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
|
||||
|
||||
Available at: [movie.squeezebox.dev](https://movie.squeezebox.dev)
|
||||
|
||||
Credits to [@JipFr](https://github.com/JipFr) for initial work on [movie-cli](https://github.com/JipFr/movie-cli)
|
||||
Small web app for watching movies easily. Check it out 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)
|
||||
- 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.
34
index.html
34
index.html
|
@ -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>
|
42
package.json
Normal file
42
package.json
Normal file
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
20
public/index.html
Normal file
20
public/index.html
Normal file
|
@ -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>
|
25
public/manifest.json
Normal file
25
public/manifest.json
Normal file
|
@ -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"
|
||||
}
|
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
|
@ -0,0 +1,3 @@
|
|||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
31
src/App.js
Normal file
31
src/App.js
Normal file
|
@ -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;
|
7
src/components/Arrow.css
Normal file
7
src/components/Arrow.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.feather.left {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.arrow {
|
||||
display: inline-block;
|
||||
}
|
14
src/components/Arrow.js
Normal file
14
src/components/Arrow.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
28
src/components/Card.css
Normal file
28
src/components/Card.css
Normal file
|
@ -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);
|
||||
}
|
28
src/components/Card.js
Normal file
28
src/components/Card.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
72
src/components/InputBox.css
Normal file
72
src/components/InputBox.css
Normal file
|
@ -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;
|
||||
}
|
26
src/components/InputBox.js
Normal file
26
src/components/InputBox.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
45
src/components/MovieRow.css
Normal file
45
src/components/MovieRow.css
Normal file
|
@ -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);
|
||||
}
|
19
src/components/MovieRow.js
Normal file
19
src/components/MovieRow.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
43
src/components/Progress.css
Normal file
43
src/components/Progress.css
Normal file
|
@ -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;
|
||||
}
|
21
src/components/Progress.js
Normal file
21
src/components/Progress.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
36
src/components/Title.css
Normal file
36
src/components/Title.css
Normal file
|
@ -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);
|
||||
}
|
25
src/components/Title.js
Normal file
25
src/components/Title.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
3
src/components/VideoElement.css
Normal file
3
src/components/VideoElement.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.videoElement {
|
||||
width: 100%;
|
||||
}
|
28
src/components/VideoElement.js
Normal file
28
src/components/VideoElement.js
Normal file
|
@ -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 />
|
||||
)
|
||||
}
|
29
src/hooks/useMovie.js
Normal file
29
src/hooks/useMovie.js
Normal file
|
@ -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);
|
||||
}
|
14
src/index.css
Normal file
14
src/index.css
Normal file
|
@ -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;
|
||||
}
|
11
src/index.js
Normal file
11
src/index.js
Normal file
|
@ -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')
|
||||
);
|
|
@ -1,3 +1,6 @@
|
|||
import Fuse from 'fuse.js'
|
||||
import JSON5 from 'json5'
|
||||
|
||||
function getCorsUrl(url) {
|
||||
return `https://hidden-inlet-27205.herokuapp.com/${url}`;
|
||||
}
|
||||
|
@ -38,41 +41,13 @@ async function getAccessToken(config) {
|
|||
return "Invalid type provided in config";
|
||||
}
|
||||
|
||||
async function findMovie() {
|
||||
const searchTerm = document.getElementById('search').value;
|
||||
|
||||
sendMessage('info', `Searching for "${searchTerm}"`)
|
||||
|
||||
const searchUrl = getCorsUrl(`https://lookmovie.io/api/v1/movies/search/?q=${encodeURIComponent(searchTerm)}`);
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.json());
|
||||
let results = [ ...searchRes.result.map((v) => ({ ...v, type: "movie" })) ];
|
||||
|
||||
const fuse = new Fuse(results, { threshold: 0.3, distance: 200, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm.toString())
|
||||
.map((result) => result.item);
|
||||
|
||||
let toShow;
|
||||
if (matchedResults.length > 1) {
|
||||
const response = window.prompt(`Pick a movie from:\n${matchedResults.map((i, v) => `${v}) ${i.title}`).join('\n')}`, 'Enter number');
|
||||
toShow = matchedResults[response];
|
||||
} else {
|
||||
toShow = matchedResults[0];
|
||||
}
|
||||
|
||||
if (!toShow) {
|
||||
sendMessage('error', 'Unable to find that, sorry!')
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage('info', `Scraping the ${toShow.type} "${toShow.title}"`)
|
||||
|
||||
const url = getCorsUrl(`https://lookmovie.io/${toShow.type}s/view/${toShow.slug}`);
|
||||
async function getStreamUrl(slug, type) {
|
||||
const url = getCorsUrl(`https://lookmovie.io/${type}s/view/${slug}`);
|
||||
const pageReq = await fetch(url).then((d) => d.text());
|
||||
|
||||
const data = JSON5.parse("{" +
|
||||
pageReq
|
||||
.slice(pageReq.indexOf(`${toShow.type}_storage`))
|
||||
.slice(pageReq.indexOf(`${type}_storage`))
|
||||
.split("};")[0]
|
||||
.split("= {")[1]
|
||||
.trim() +
|
||||
|
@ -80,27 +55,45 @@ async function findMovie() {
|
|||
);
|
||||
|
||||
const videoUrl = await getVideoUrl({
|
||||
slug: toShow.slug,
|
||||
slug: slug,
|
||||
movieId: data.id_movie,
|
||||
type: "movie",
|
||||
});
|
||||
|
||||
sendMessage('info', `Streaming "${toShow.title}"`)
|
||||
streamVideo(videoUrl)
|
||||
return { url: videoUrl }
|
||||
}
|
||||
|
||||
function sendMessage(type, message) {
|
||||
if (!['info', 'error'].includes(type)) return;
|
||||
document.getElementById(type).innerHTML += `${message}<br>`;
|
||||
}
|
||||
async function findMovie(searchTerm) {
|
||||
const searchUrl = getCorsUrl(`https://lookmovie.io/api/v1/movies/search/?q=${encodeURIComponent(searchTerm)}`);
|
||||
const searchRes = await fetch(searchUrl).then((d) => d.json());
|
||||
let results = [...searchRes.result.map((v) => ({ ...v, type: "movie" }))];
|
||||
|
||||
function streamVideo(url) {
|
||||
var video = document.getElementById('video');
|
||||
const fuse = new Fuse(results, { threshold: 0.3, distance: 200, keys: ["title"] });
|
||||
const matchedResults = fuse
|
||||
.search(searchTerm.toString())
|
||||
.map((result) => result.item);
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
var video = document.getElementById('video');
|
||||
var hls = new Hls();
|
||||
hls.attachMedia(video);
|
||||
hls.loadSource(url);
|
||||
if (matchedResults.length === 0) {
|
||||
return { options: [] }
|
||||
}
|
||||
|
||||
if (matchedResults.length > 1) {
|
||||
const res = { options: [] };
|
||||
|
||||
matchedResults.forEach((r) => res.options.push({
|
||||
title: r.title,
|
||||
slug: r.slug,
|
||||
type: r.type
|
||||
}));
|
||||
|
||||
return res;
|
||||
} else {
|
||||
const { title, slug, type } = matchedResults[0];
|
||||
|
||||
return {
|
||||
options: [{ title, slug, type }]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { findMovie, getStreamUrl };
|
0
src/views/Movie.css
Normal file
0
src/views/Movie.css
Normal file
20
src/views/Movie.js
Normal file
20
src/views/Movie.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
15
src/views/NotFound.js
Normal file
15
src/views/NotFound.js
Normal file
|
@ -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>
|
||||
)
|
||||
}
|
17
src/views/Search.css
Normal file
17
src/views/Search.css
Normal file
|
@ -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;
|
||||
}
|
96
src/views/Search.js
Normal file
96
src/views/Search.js
Normal file
|
@ -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 a new issue