├── .vscode ├── settings.json └── launch.json ├── _redirects ├── .eslintignore ├── static ├── logo.png ├── logo-16.png ├── logo-32.png ├── logo-192.png ├── logo-512.png ├── fallback_background.jpg ├── manifest.json ├── icons │ ├── google-icon.svg │ ├── github-icon.svg │ ├── discord-icon.svg │ ├── twitter-icon.svg │ └── kitsu-icon.svg └── logo-animated.svg ├── src ├── mse │ ├── TODO │ └── MegaMediaSource.js ├── graphql │ └── index.js ├── store │ ├── index.js │ └── app.js ├── utils │ ├── cookies.js │ └── auth.js ├── components │ ├── ProgressiveImg.vue │ ├── Upload.vue │ ├── layout │ │ ├── Loader.vue │ │ ├── navbar │ │ │ ├── AuthMenu.vue │ │ │ ├── LanguageSelect.vue │ │ │ ├── AuthMenuLinks.vue │ │ │ ├── Notifications.vue │ │ │ ├── Navbar.vue │ │ │ └── MobileNavbar.vue │ │ ├── Search.vue │ │ ├── Layout.vue │ │ ├── AuthLayout.vue │ │ ├── SearchResults.vue │ │ └── LayoutFooter.vue │ ├── Rating.vue │ ├── player │ │ ├── PlayerSlider.vue │ │ └── GlobalPlayer.js │ ├── user │ │ └── User.vue │ ├── media │ │ ├── MediaList.vue │ │ └── Comment.vue │ ├── cover │ │ ├── Cover.vue │ │ └── CoverList.vue │ ├── Floating.vue │ └── room │ │ └── Chatbox.vue ├── sw.js ├── index.js ├── credentials.js ├── stylus │ ├── colors.styl │ ├── main.styl │ ├── transitions.styl │ └── theme.styl ├── main.js ├── views │ ├── Translations.vue │ ├── user │ │ ├── Followers.vue │ │ ├── settings │ │ │ ├── Account.vue │ │ │ └── Connections.vue │ │ ├── Follows.vue │ │ ├── library │ │ │ ├── AnimeStatus.vue │ │ │ ├── Search.vue │ │ │ └── Follows.vue │ │ ├── Settings.vue │ │ └── Library.vue │ ├── news │ │ ├── NewsList.vue │ │ └── News.vue │ ├── 404.vue │ ├── Author.vue │ ├── auth │ │ ├── Login.vue │ │ └── SignUp.vue │ ├── Index.vue │ └── Search.vue ├── App.vue ├── i18n │ └── index.js ├── router │ └── index.js └── assets │ └── loader.svg ├── tests └── e2e │ ├── mock │ ├── static │ │ ├── cover.png │ │ └── background.gif │ ├── mocks │ │ ├── Url.js │ │ ├── DateTime.js │ │ ├── index.js │ │ ├── Anime.js │ │ ├── User.js │ │ └── Query.js │ └── index.js │ ├── utils │ └── istanbul.js │ └── Navbar.test.js ├── .editorconfig ├── config ├── prod.env.js ├── test.env.js ├── dev.env.js └── index.js ├── .postcssrc.js ├── .gitignore ├── .eslintrc.js ├── mockSchema.js ├── .babelrc ├── README.md ├── index.html └── package.json /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/*.js 2 | config/*.js 3 | -------------------------------------------------------------------------------- /static/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/static/logo.png -------------------------------------------------------------------------------- /static/logo-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/static/logo-16.png -------------------------------------------------------------------------------- /static/logo-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/static/logo-32.png -------------------------------------------------------------------------------- /static/logo-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/static/logo-192.png -------------------------------------------------------------------------------- /static/logo-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/static/logo-512.png -------------------------------------------------------------------------------- /src/mse/TODO: -------------------------------------------------------------------------------- 1 | Don't seek if it's in the same TimeRange 2 | Don't redownload downloaded regions after seeking -------------------------------------------------------------------------------- /static/fallback_background.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/static/fallback_background.jpg -------------------------------------------------------------------------------- /tests/e2e/mock/static/cover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/tests/e2e/mock/static/cover.png -------------------------------------------------------------------------------- /tests/e2e/mock/mocks/Url.js: -------------------------------------------------------------------------------- 1 | const faker = require("faker"); 2 | 3 | module.exports = () => faker.internet.url(); 4 | -------------------------------------------------------------------------------- /tests/e2e/mock/mocks/DateTime.js: -------------------------------------------------------------------------------- 1 | const faker = require("faker"); 2 | 3 | module.exports = () => faker.date.past(); 4 | -------------------------------------------------------------------------------- /tests/e2e/mock/static/background.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Popcorn-moe/Web/HEAD/tests/e2e/mock/static/background.gif -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /tests/e2e/mock/mocks/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Anime: require("./Anime"), 3 | DateTime: require("./DateTime"), 4 | Query: require("./Query"), 5 | Url: require("./Url"), 6 | User: require("./User") 7 | }; 8 | -------------------------------------------------------------------------------- /config/prod.env.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | NODE_ENV: '"production"', 3 | AUTH_URL: `"${process.env.AUTH_URL || "https://auth.popcorn.moe"}"`, 4 | API_URL: `"${process.env.API_URL || "https://api.popcorn.moe"}"` 5 | }; 6 | -------------------------------------------------------------------------------- /.postcssrc.js: -------------------------------------------------------------------------------- 1 | // https://github.com/michael-ciniawsky/postcss-load-config 2 | 3 | module.exports = { 4 | "plugins": { 5 | // to edit target browsers: use "browserslist" field in package.json 6 | "autoprefixer": {} 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /tests/e2e/mock/mocks/Anime.js: -------------------------------------------------------------------------------- 1 | const faker = require("faker"); 2 | 3 | module.exports = () => ({ 4 | names: [faker.commerce.productName()], 5 | cover: "http://localhost:3029/cover.png", 6 | background: "http://localhost:3029/background.gif" 7 | }); 8 | -------------------------------------------------------------------------------- /tests/e2e/mock/mocks/User.js: -------------------------------------------------------------------------------- 1 | const faker = require("faker"); 2 | 3 | module.exports = () => ({ 4 | login: faker.name.findName(), 5 | group: "VIEWER", 6 | newsletter: true, 7 | avatar: faker.image.avatar(), 8 | email: faker.internet.email() 9 | }); 10 | -------------------------------------------------------------------------------- /config/test.env.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const devEnv = require("./dev.env"); 3 | 4 | module.exports = merge(devEnv, { 5 | API_URL: process.env.API_URL 6 | ? `'${process.env.API_URL}'` 7 | : "'http://localhost:3029'", 8 | NODE_ENV: '"test"' 9 | }); 10 | -------------------------------------------------------------------------------- /config/dev.env.js: -------------------------------------------------------------------------------- 1 | const merge = require("webpack-merge"); 2 | const prodEnv = require("./prod.env"); 3 | 4 | module.exports = merge(prodEnv, { 5 | NODE_ENV: '"development"', 6 | AUTH_URL: `"${process.env.AUTH_URL || "http://localhost:3031"}"`, 7 | API_URL: `"${process.env.API_URL || "http://localhost:3030"}"` 8 | }); 9 | -------------------------------------------------------------------------------- /src/graphql/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueApollo from "vue-apollo"; 3 | import createClient from "@popcorn.moe/apollo"; 4 | 5 | Vue.use(VueApollo); 6 | 7 | export const client = createClient(); 8 | 9 | window.graphql = client; 10 | 11 | export default new VueApollo({ 12 | defaultClient: client 13 | }); 14 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Vuex from "vuex"; 3 | 4 | import app from "./app"; 5 | export { onLoad } from "./app"; 6 | 7 | Vue.use(Vuex); 8 | 9 | const store = new Vuex.Store({ 10 | strict: true, // process.env.NODE_ENV !== 'production', 11 | ...app 12 | }); 13 | 14 | export default store; 15 | -------------------------------------------------------------------------------- /src/utils/cookies.js: -------------------------------------------------------------------------------- 1 | export default getCookiesMap(document.cookie); 2 | 3 | export function getCookiesMap(cookiesString) { 4 | return cookiesString 5 | .split(";") 6 | .map(cookieString => cookieString.trim().split("=")) 7 | .reduce((acc, [name, ...values]) => { 8 | acc[name] = values.join("="); 9 | return acc; 10 | }, {}); 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules/ 3 | dist/ 4 | dist2/ 5 | npm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | yarn.lock 9 | 10 | # Editor directories and files 11 | .idea 12 | *.suo 13 | *.ntvs* 14 | *.njsproj 15 | .sln 16 | *.iml 17 | 18 | # custom env run file 19 | run.sh 20 | 21 | # testcafe screenshots 22 | screenshots 23 | 24 | .bin 25 | html-report -------------------------------------------------------------------------------- /src/components/ProgressiveImg.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 14 | 15 | 21 | -------------------------------------------------------------------------------- /src/sw.js: -------------------------------------------------------------------------------- 1 | workbox.clientsClaim(); 2 | workbox.skipWaiting(); 3 | 4 | workbox.routing.registerRoute( 5 | new RegExp("^https://fonts.(?:googleapis|gstatic).com/(.*)"), 6 | workbox.strategies.cacheFirst() 7 | ); 8 | 9 | workbox.routing.registerNavigationRoute("/index.html", { 10 | blacklist: [/static/, /report\.html/, /VERSION|COMMITHASH/] 11 | }); 12 | 13 | workbox.precaching.precacheAndRoute(self.__precacheManifest); 14 | -------------------------------------------------------------------------------- /static/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Popcorn.moe", 3 | "short_name": "Popcorn.moe", 4 | "icons": [ 5 | { 6 | "src": "/static/logo-192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/static/logo-512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "/", 17 | "display": "standalone", 18 | "background_color": "#ffffff", 19 | "theme_color": "#f6416c" 20 | } -------------------------------------------------------------------------------- /tests/e2e/mock/mocks/Query.js: -------------------------------------------------------------------------------- 1 | const { MockList } = require("graphql-tools"); 2 | 3 | module.exports = () => ({ 4 | animes(root, { limit }, context) { 5 | return new MockList(limit); 6 | }, 7 | searchUser(root, { name, limit }, context) { 8 | if (name) return new MockList(limit); 9 | else return []; 10 | }, 11 | searchAnime(root, { name, limit }, context) { 12 | if (name) return new MockList(limit); 13 | else return []; 14 | } 15 | }); 16 | -------------------------------------------------------------------------------- /tests/e2e/utils/istanbul.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction } from "testcafe"; 2 | import { Collector, Report } from "istanbul"; 3 | import exitHook from "exit-hook"; 4 | 5 | const getCoverage = ClientFunction(() => window.__coverage__); 6 | const collector = new Collector(); 7 | 8 | exitHook(() => { 9 | Report.create("html").writeReport(collector, true); 10 | Report.create("text").writeReport(collector, true); 11 | }); 12 | 13 | export default async function(t) { 14 | collector.add(await getCoverage()); 15 | } 16 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // http://eslint.org/docs/user-guide/configuring 2 | 3 | module.exports = { 4 | root: true, 5 | parser: 'babel-eslint', 6 | parserOptions: { 7 | sourceType: 'module' 8 | }, 9 | env: { 10 | browser: true, 11 | }, 12 | // required to lint *.vue files 13 | plugins: [ 14 | 'html' 15 | ], 16 | // add your custom rules here 17 | 'rules': { 18 | // allow debugger during development 19 | 'no-debugger': process.env.NODE_ENV === 'production' ? 2 : 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import("./main.js").catch(console.error); // Async load 2 | 3 | if ("serviceWorker" in navigator && navigator.serviceWorker.controller) { 4 | Promise.all([ 5 | navigator.serviceWorker.ready, 6 | fetch("/VERSION").then(res => res.text()) 7 | ]).then(([reg, version]) => { 8 | if (version !== VERSION) { 9 | console.log( 10 | "Updating service worker old:", 11 | version, 12 | "new:", 13 | VERSION, 14 | "..." 15 | ); 16 | return reg.update().then(() => console.log("Service worker updated")); 17 | } 18 | }); 19 | } 20 | -------------------------------------------------------------------------------- /mockSchema.js: -------------------------------------------------------------------------------- 1 | const fetch = require("node-fetch"); 2 | const fs = require("fs"); 3 | const { introspectionQuery } = require("graphql"); 4 | 5 | fetch(`http://localhost:3030/graphql`, { 6 | method: "POST", 7 | headers: { "Content-Type": "application/json" }, 8 | body: JSON.stringify({ 9 | query: introspectionQuery 10 | }) 11 | }) 12 | .then(result => result.json()) 13 | .then(result => { 14 | fs.writeFile( 15 | "./tests/e2e/mock/schema.json", 16 | JSON.stringify(result.data, null, 2), 17 | err => { 18 | if (err) console.error("Error writing schema file", err); 19 | console.log("Schema successfully extracted!"); 20 | } 21 | ); 22 | }) 23 | .catch(console.error); 24 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["env", { 4 | "modules": false, 5 | "targets": { 6 | "browsers": ["> 1%", "last 2 versions", "not ie <= 8"] 7 | } 8 | }], 9 | "stage-2" 10 | ], 11 | "plugins": [ 12 | "transform-runtime", 13 | ["transform-imports", { 14 | "vuetify/es5/components": { 15 | "transform": "vuetify/es5/components/${member}", 16 | "preventFullImport": true 17 | }, 18 | "vuetify": { 19 | "transform": "vuetify/es5/components/${member}", 20 | "preventFullImport": true 21 | } 22 | }] 23 | ], 24 | "env": { 25 | "test": { 26 | "plugins": ["istanbul"] 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Upload.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 18 | 19 | 39 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "chrome", 9 | "request": "attach", 10 | "port": 9222, 11 | "name": "Launch", 12 | "url": "http://localhost:8080/", 13 | "urlFilter": "http://localhost:8080/*", 14 | "webRoot": "${workspaceFolder}", 15 | "sourceMapPathOverrides": { 16 | "webpack:///*": "${workspaceFolder}/*" 17 | } 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /src/components/layout/Loader.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 40 | -------------------------------------------------------------------------------- /src/credentials.js: -------------------------------------------------------------------------------- 1 | import { login, ssoLogin, PROVIDERS } from "./utils/auth"; 2 | import store from "./store"; 3 | 4 | const INVERTED_PROVIDERS = Object.entries(PROVIDERS).reduce( 5 | (c, [key, value]) => ((c[value] = key), c), 6 | {} 7 | ); 8 | 9 | export default function autoSignIn() { 10 | if (window.PasswordCredential || window.FederatedCredential) { 11 | navigator.credentials 12 | .get({ 13 | password: true, 14 | federated: { 15 | providers: Object.values(PROVIDERS) 16 | } 17 | }) 18 | .then(cred => { 19 | if (cred) { 20 | switch (cred.type) { 21 | case "password": 22 | login(cred.id, cred.password); 23 | break; 24 | case "federated": 25 | ssoLogin(INVERTED_PROVIDERS[cred.provider]); 26 | break; 27 | } 28 | } 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/stylus/colors.styl: -------------------------------------------------------------------------------- 1 | color-class(name, $color) { 2 | .{name} { 3 | background-color: $color !important; 4 | border-color: $color !important; 5 | color: #fff !important; 6 | } 7 | 8 | .{name}--text { 9 | color: $color !important; 10 | } 11 | } 12 | 13 | $discord-color = #8c9eff 14 | $google-color = #b8b8b8 15 | $kitsu-color = #f75239 16 | $twitter-color = #36a2e4 17 | 18 | color-class('main-color', $main-color) 19 | color-class('discord-color', $discord-color) 20 | color-class('google-color', $google-color) 21 | color-class('kitsu-color', $kitsu-color) 22 | color-class('twitter-color', $twitter-color) 23 | color-class('discord-color--darken', darken($discord-color, 10)) 24 | color-class('google-color--darken', darken($google-color, 10)) 25 | color-class('kitsu-color--darken', darken($kitsu-color, 10)) 26 | color-class('twitter-color--darken', darken($twitter-color, 10)) 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /tests/e2e/mock/index.js: -------------------------------------------------------------------------------- 1 | const { buildClientSchema } = require("graphql"); 2 | const introspectionResult = require("./schema.json"); 3 | const { addMockFunctionsToSchema } = require("graphql-tools"); 4 | const express = require("express"); 5 | const cors = require("cors"); 6 | const graphqlHTTP = require("express-graphql"); 7 | const { graphql } = require("graphql"); 8 | const mocks = require("./mocks"); 9 | const faker = require("faker"); 10 | const { join } = require("path"); 11 | 12 | const schema = buildClientSchema(introspectionResult); 13 | 14 | addMockFunctionsToSchema({ schema, mocks }); 15 | 16 | const app = express(); 17 | app.use(express.static(join(__dirname, "static"))); 18 | app.use( 19 | cors({ 20 | origin: ["http://localhost:8042"], 21 | credentials: true 22 | }) 23 | ); 24 | 25 | app.use((req, res, next) => { 26 | faker.seed(123); 27 | next(); 28 | }); 29 | app.use("/graphql", graphqlHTTP({ schema })); 30 | 31 | app.listen(3029); 32 | 33 | process.on("SIGTERM", () => process.exit(0)); 34 | -------------------------------------------------------------------------------- /static/icons/google-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/stylus/main.styl: -------------------------------------------------------------------------------- 1 | @import './theme' 2 | @import '~vuetify/src/stylus/app' 3 | @import './transitions' 4 | @import './colors' 5 | 6 | .uppercase { 7 | text-transform: uppercase; 8 | } 9 | 10 | .vertical-center { 11 | position: relative; 12 | top: 50%; 13 | transform: translateY(-50%); 14 | } 15 | 16 | $anime := { 17 | padding: 15px 18 | width: 150px 19 | img-height: 210px 20 | img-border: 4px 21 | font-size: 16px 22 | rating-size: 25px 23 | description-size: 24px 24 | subtitle-size: 19px 25 | } 26 | 27 | $anime-height = $anime.img-height + $anime.img-border + $anime.padding + $anime.subtitle-size + $anime.font-size * 2.5 + $anime.rating-size + $anime.description-size + $anime.padding 28 | 29 | .application.theme--dark { 30 | h1, h2, h3, h4, h5, h6 { 31 | color: #D3D3D3; 32 | } 33 | } 34 | 35 | a, .list__tile--active { 36 | color: inherit; 37 | } 38 | 39 | .icon.material-icons { 40 | display: inline-flex; 41 | } 42 | 43 | @import url('https://fonts.googleapis.com/css?family=Roboto:300,400,500,700|Material+Icons') -------------------------------------------------------------------------------- /static/icons/github-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /static/icons/discord-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import { Vuetify } from "vuetify"; 3 | import colors from "vuetify/es5/util/colors"; 4 | import VueHead from "vue-head"; 5 | import App from "./App"; 6 | import router from "./router"; 7 | import store, { onLoad } from "./store"; 8 | import apolloProvider from "./graphql"; 9 | import i18n from "./i18n"; 10 | import autoSignIn from "./credentials"; 11 | 12 | const mainColor = "#f6416c"; 13 | const secondaryColor = "#fddd84"; 14 | 15 | Vue.config.productionTip = false; 16 | Vue.use(Vuetify, { 17 | theme: { 18 | primary: mainColor, 19 | accent: mainColor, 20 | secondary: secondaryColor, 21 | info: colors.blue.base, 22 | warning: colors.amber.base, 23 | error: colors.red.accent2, 24 | success: colors.green.base 25 | } 26 | }); 27 | 28 | Vue.use(VueHead, { 29 | separator: "-", 30 | complement: "Popcorn.moe" 31 | }); 32 | 33 | /* eslint-disable no-new */ 34 | window.vue = new Vue({ 35 | el: "#app", 36 | router, 37 | store, 38 | provide: apolloProvider.provide(), 39 | i18n, 40 | render: h => h(App) 41 | }); 42 | 43 | onLoad(store).then(() => { 44 | if (!store.state.isAuth) autoSignIn(); 45 | }); 46 | -------------------------------------------------------------------------------- /src/stylus/transitions.styl: -------------------------------------------------------------------------------- 1 | .tab-transition-enter-active { 2 | animation: slideRightIn 1s 3 | } 4 | 5 | .tab-transition-leave-active { 6 | animation: slideRightOut 1s; 7 | position: absolute; 8 | top: 0; 9 | left: 0; 10 | right: 0; 11 | bottom: 0; 12 | width: 100% 13 | } 14 | 15 | @keyframes slideRightIn { 16 | 0% { 17 | transform: translateX(100%) 18 | } 19 | 20 | to { 21 | transform: translateX(0) 22 | } 23 | } 24 | 25 | @keyframes slideRightOut { 26 | 0% { 27 | transform: translateX(0) 28 | } 29 | 30 | to { 31 | transform: translateX(-100%) 32 | } 33 | } 34 | 35 | .tab-reverse-transition-enter-active { 36 | animation: slideLeftIn 1s 37 | } 38 | 39 | .tab-reverse-transition-leave-active { 40 | animation: slideLeftOut 1s; 41 | position: absolute; 42 | top: 0; 43 | left: 0; 44 | right: 0; 45 | bottom: 0; 46 | width: 100% 47 | } 48 | 49 | @keyframes slideLeftIn { 50 | 0% { 51 | transform: translateX(-100%) 52 | } 53 | 54 | to { 55 | transform: translateX(0) 56 | } 57 | } 58 | 59 | @keyframes slideLeftOut { 60 | 0% { 61 | transform: translateX(0) 62 | } 63 | 64 | to { 65 | transform: translateX(100%) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/components/layout/navbar/AuthMenu.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 61 | 62 | 70 | -------------------------------------------------------------------------------- /config/index.js: -------------------------------------------------------------------------------- 1 | // see http://vuejs-templates.github.io/webpack for documentation. 2 | const { resolve } = require("path"); 3 | const { NODE_ENV } = process.env; 4 | 5 | module.exports = { 6 | build: { 7 | env: require("./prod.env"), 8 | index: resolve(__dirname, "../dist/index.html"), 9 | assetsRoot: resolve(__dirname, "../dist"), 10 | assetsSubDirectory: "static", 11 | assetsPublicPath: "/", 12 | devtool: "source-map", 13 | productionSourceMap: true, 14 | // Gzip off by default as many popular static hosts such as 15 | // Surge or Netlify already gzip all static assets for you. 16 | // Before setting to `true`, make sure to: 17 | // npm install --save-dev compression-webpack-plugin 18 | productionGzip: false, 19 | productionGzipExtensions: ["js", "css"] 20 | }, 21 | dev: { 22 | env: NODE_ENV === "test" ? require("./test.env") : require("./dev.env"), 23 | port: NODE_ENV === "test" ? 8042 : 8080, 24 | autoOpenBrowser: NODE_ENV !== "test", 25 | devtool: NODE_ENV === "test" ? "inline-source-map" : "source-map", 26 | assetsSubDirectory: "static", 27 | assetsPublicPath: "/", 28 | proxyTable: {}, 29 | // CSS Sourcemaps off by default because relative paths are "buggy" 30 | // with this option, according to the CSS-Loader README 31 | // (https://github.com/webpack/css-loader#sourcemaps) 32 | // In our experience, they generally work as expected, 33 | // just be aware of this issue when enabling this option. 34 | cssSourceMap: false 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /src/views/Translations.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 54 | -------------------------------------------------------------------------------- /src/views/user/Followers.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 49 | 50 | 75 | -------------------------------------------------------------------------------- /static/icons/twitter-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/views/user/settings/Account.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 71 | 72 | 74 | -------------------------------------------------------------------------------- /src/views/user/Follows.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 22 | 58 | 59 | 84 | -------------------------------------------------------------------------------- /src/views/user/library/AnimeStatus.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 63 | 64 | 65 | 75 | -------------------------------------------------------------------------------- /src/components/Rating.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 53 | 54 | 82 | -------------------------------------------------------------------------------- /src/components/player/PlayerSlider.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 62 | 83 | -------------------------------------------------------------------------------- /src/components/layout/Search.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 72 | 73 | 86 | -------------------------------------------------------------------------------- /src/views/user/library/Search.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 74 | 75 | 78 | -------------------------------------------------------------------------------- /src/views/news/NewsList.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 78 | 79 | 82 | -------------------------------------------------------------------------------- /static/icons/kitsu-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/components/user/User.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 37 | 38 | 39 | 67 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Popcorn.moe Web 2 | 3 | ![screenshot](https://render-tron.appspot.com/screenshot/https://popcorn.moe?width=1280&height=720) 4 | 5 | ## Getting Started 6 | 7 | These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. 8 | 9 | ### Prerequisites 10 | 11 | - [Api Service](https://github.com/Popcorn-moe/Api) (Optional) 12 | - [Auth Service](https://github.com/Popcorn-moe/Auth) (Optional) 13 | 14 | ### Installing 15 | 16 | ```bash 17 | # install dependencies 18 | npm install 19 | 20 | # serve with hot reload at localhost:8080 21 | npm start 22 | 23 | # serve with hot reload at localhost:8080 by using prod environment (Use this if you don't installed localy Auth and Api Service) 24 | API_URL="https://api.popcorn.moe" AUTH_URL="https://auth.popcorn.moe" npm start 25 | 26 | # build for production 27 | npm run build 28 | ``` 29 | 30 | ## Running the tests 31 | 32 | ```bash 33 | npm run test 34 | ``` 35 | 36 | ## Built With 37 | 38 | * [Vuetify](https://vuetifyjs.com/) - The web framework used 39 | 40 | ## Contributing 41 | 42 | Please read [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct, and the process for submitting pull requests to us. 43 | 44 | ## Authors 45 | 46 | * **David Duarte** - *Initial work* - [DeltaEvo](https://github.com/DeltaEvo) 47 | * **Lucas Lelièvre** - *Initial work* - [loucass003](https://github.com/loucass003) 48 | * **SkyBeast** - *Some commits to show us that he is working* - [SkyBeastMC](https://github.com/SkyBeastMC) 49 | 50 | See also the list of [contributors](https://github.com/Popcorn-moe/Web/contributors) who participated in this project. 51 | 52 | ## License 53 | 54 | This project is licensed under the GPLv3 License - see the [LICENSE.md](LICENSE.md) file for details 55 | 56 | ## Acknowledgments 57 | 58 | * **valmax72** - *For the awesome logo and design* - [valmax72](https://github.com/valmax72) -------------------------------------------------------------------------------- /src/components/layout/Layout.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 63 | 64 | 76 | -------------------------------------------------------------------------------- /src/components/player/GlobalPlayer.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VideoPlayer from "./VideoPlayer.vue"; 3 | 4 | const VIDEO_PROPS = Array.isArray(VideoPlayer.props) 5 | ? VideoPlayer.props 6 | : Object.keys(VideoPlayer.props); 7 | 8 | export const videoPlayer = { 9 | owner: null, 10 | media: null, 11 | destroyed: true, 12 | paused: true, 13 | destroy() { 14 | this.destroyed = true; 15 | } 16 | }; 17 | 18 | export default { 19 | props: ["owner", "media"].concat(VIDEO_PROPS), 20 | render(h) { 21 | return h("div"); 22 | }, 23 | mounted() { 24 | if (videoPlayer.owner != null) 25 | throw new Error(`Already owned by ${videoPlayer.owner}`); 26 | 27 | if ( 28 | this.value && 29 | videoPlayer.instance && 30 | this.value != videoPlayer.instance.value 31 | ) { 32 | if (!videoPlayer.destroyed) videoPlayer.destroy(); 33 | videoPlayer.instance.$destroy(); 34 | videoPlayer.instance = null; 35 | videoPlayer.paused = true; 36 | } 37 | 38 | if (!videoPlayer.instance) { 39 | videoPlayer.instance = new Vue(VideoPlayer); 40 | VIDEO_PROPS.forEach(prop => (videoPlayer.instance[prop] = this[prop])); 41 | videoPlayer.instance.$mount(); 42 | videoPlayer.destroyed = false; 43 | videoPlayer.media = this.media; 44 | } 45 | 46 | this.$el.appendChild(videoPlayer.instance.$el); 47 | videoPlayer.instance.paused = videoPlayer.instance.$refs.video.paused; //Resync chrome bug 48 | if (videoPlayer.paused !== videoPlayer.instance.paused) 49 | videoPlayer.instance.togglePlay(); 50 | videoPlayer.owner = this.owner; 51 | }, 52 | beforeDestroy() { 53 | videoPlayer.paused = videoPlayer.instance.paused; 54 | this.$el.removeChild(videoPlayer.instance.$el); 55 | 56 | if (videoPlayer.destroyed || !videoPlayer.instance.hasPlayed) { 57 | if (!videoPlayer.destroyed) videoPlayer.destroy(); 58 | videoPlayer.instance.$destroy(); 59 | videoPlayer.instance = null; 60 | videoPlayer.paused = true; 61 | } 62 | videoPlayer.owner = null; 63 | } 64 | }; 65 | -------------------------------------------------------------------------------- /src/store/app.js: -------------------------------------------------------------------------------- 1 | import cookies from "../utils/cookies"; 2 | import { exchangeSSOToken, isLoggedIn } from "../utils/auth"; 3 | 4 | export const SET_DARK_THEME = "DARK_THEME"; 5 | export const TOGGLE_DRAWER = "TOGGLE_DRAWER"; 6 | export const IS_AUTH = "IS_AUTH"; 7 | export const IS_LOADING = "IS_LOADING"; 8 | 9 | const state = { 10 | darkTheme: localStorage.getItem("darkTheme") === "true", 11 | drawer: (localStorage.getItem("drawer") || "true") === "true", 12 | isLoading: true, 13 | isAuth: null 14 | }; 15 | 16 | export function onLoad(store) { 17 | window.addEventListener( 18 | "storage", 19 | event => { 20 | switch (event.key) { 21 | case "darkTheme": 22 | store.commit(SET_DARK_THEME, event.newValue === "true"); 23 | break; 24 | case "drawer": 25 | store.commit(TOGGLE_DRAWER, event.newValue === "true"); 26 | break; 27 | } 28 | }, 29 | false 30 | ); 31 | if (cookies.ssoExchange) { 32 | return exchangeSSOToken(cookies.ssoExchange); 33 | } else if (localStorage.getItem("csrf")) { 34 | return isLoggedIn().then(isAuth => store.dispatch("setIsAuth", isAuth)); 35 | } else { 36 | store.dispatch("setIsAuth", false); 37 | return Promise.resolve(); 38 | } 39 | } 40 | 41 | const mutations = { 42 | [SET_DARK_THEME](state, darkTheme) { 43 | state.darkTheme = darkTheme; 44 | }, 45 | [TOGGLE_DRAWER](state, drawer) { 46 | state.drawer = drawer; 47 | }, 48 | [IS_AUTH](state, isAuth) { 49 | state.isAuth = isAuth; 50 | }, 51 | [IS_LOADING](state, isLoading) { 52 | state.isLoading = isLoading; 53 | } 54 | }; 55 | 56 | const actions = { 57 | setDarkTheme({ commit }, darkTheme) { 58 | commit(SET_DARK_THEME, darkTheme); 59 | localStorage.setItem("darkTheme", darkTheme); 60 | }, 61 | toggleDrawer({ commit }, drawer) { 62 | commit(TOGGLE_DRAWER, drawer); 63 | localStorage.setItem("drawer", drawer); 64 | }, 65 | setIsAuth({ commit }, isAuth) { 66 | commit(IS_AUTH, isAuth); 67 | } 68 | }; 69 | 70 | const getters = { 71 | darkTheme: ({ darkTheme }) => darkTheme, 72 | drawer: ({ drawer }) => drawer, 73 | isAuth: ({ isAuth }) => isAuth, 74 | isLoading: ({ isLoading }) => isLoading 75 | }; 76 | 77 | export default { 78 | state, 79 | mutations, 80 | actions, 81 | getters 82 | }; 83 | -------------------------------------------------------------------------------- /src/components/layout/navbar/LanguageSelect.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 89 | 90 | 96 | -------------------------------------------------------------------------------- /src/views/user/Settings.vue: -------------------------------------------------------------------------------- 1 | 41 | 42 | 77 | 78 | 91 | -------------------------------------------------------------------------------- /src/components/layout/AuthLayout.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 53 | 54 | 102 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 93 | -------------------------------------------------------------------------------- /src/views/news/News.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 85 | 86 | 105 | -------------------------------------------------------------------------------- /src/components/layout/navbar/AuthMenuLinks.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 72 | 73 | 83 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | Popcorn.moe 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 46 |
47 | 57 | ${require('!!html-loader!./src/assets/loader.svg')} 58 | 62 |
63 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/components/layout/SearchResults.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 114 | 115 | 118 | -------------------------------------------------------------------------------- /src/components/layout/LayoutFooter.vue: -------------------------------------------------------------------------------- 1 | 64 | 65 | 103 | 104 | 106 | -------------------------------------------------------------------------------- /src/i18n/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import VueI18n from "vue-i18n"; 3 | 4 | Vue.use(VueI18n); 5 | 6 | const i18n = new VueI18n({ 7 | locale: 8 | localStorage.getItem("locale") || 9 | (navigator.languages ? navigator.languages[0] : navigator.language), 10 | messages: { 11 | en: { 12 | route: { 13 | index: "Index", 14 | search: "Search", 15 | news: "News", 16 | auth: { 17 | profile: "Profile", 18 | library: "Library", 19 | follows: "Follows", 20 | followers: "Followers", 21 | settings: "Settings", 22 | logout: "Logout" 23 | }, 24 | user_settings: { 25 | account: "My Account", 26 | connections: "Connections" 27 | }, 28 | user_library: { 29 | watching: "Currently Watching", 30 | want_to_watch: "Want to watch", 31 | completed: "Completed", 32 | on_hold: "On Hold", 33 | dropped: "Dropped" 34 | } 35 | }, 36 | anime_status: { 37 | PENDING: "Currently airing", 38 | FINISHED: "Finished", 39 | NOT_STARTED: "Not started" 40 | }, 41 | navbar: { 42 | dark: "Dark" 43 | } 44 | }, 45 | fr: { 46 | route: { 47 | index: "Accueil", 48 | search: "Rechercher", 49 | news: "Nouveautés", 50 | auth: { 51 | profile: "Profil", 52 | library: "Librairie", 53 | follows: "Abonnements", 54 | followers: "Abonnés", 55 | settings: "Paramètres", 56 | logout: "Se deconnecter" 57 | }, 58 | user_settings: { 59 | account: "Mon Compte", 60 | connections: "Comptes" 61 | }, 62 | user_library: { 63 | watching: "En cours", 64 | want_to_watch: "Want to watch", 65 | completed: "Completed", 66 | on_hold: "On Hold", 67 | dropped: "Dropped" 68 | } 69 | }, 70 | anime_status: { 71 | PENDING: "En cours", 72 | FINISHED: "Terminé", 73 | NOT_STARTED: "Non démarré" 74 | }, 75 | navbar: { 76 | dark: "Foncé" 77 | } 78 | } 79 | }, 80 | fallbackLocale: "en" 81 | }); 82 | 83 | export default i18n; 84 | 85 | export function isNull(val) { 86 | return val === null || val === undefined; 87 | } 88 | 89 | function _translate( 90 | messages, 91 | locale, 92 | fallback, 93 | key, 94 | host, 95 | interpolateMode, 96 | args 97 | ) { 98 | let res = this._interpolate( 99 | locale, 100 | messages[locale], 101 | key, 102 | host, 103 | interpolateMode, 104 | args 105 | ); 106 | if (!isNull(res)) return res; 107 | 108 | if (locale.indexOf("-") !== -1) 109 | return this._translate( 110 | messages, 111 | locale.split("-", 1)[0], 112 | fallback, 113 | key, 114 | host, 115 | interpolateMode, 116 | args 117 | ); 118 | 119 | res = this._interpolate( 120 | fallback, 121 | messages[fallback], 122 | key, 123 | host, 124 | interpolateMode, 125 | args 126 | ); 127 | if (!isNull(res)) { 128 | if (process.env.NODE_ENV !== "production" && !this._silentTranslationWarn) { 129 | console.warn( 130 | `[vue-i18n] Fall back to translate the keypath '${key}' with '${fallback}' locale.` 131 | ); 132 | } 133 | return res; 134 | } else { 135 | return null; 136 | } 137 | } 138 | 139 | Object.getPrototypeOf(i18n)._translate = _translate; 140 | -------------------------------------------------------------------------------- /src/views/404.vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 105 | 106 | 133 | -------------------------------------------------------------------------------- /src/utils/auth.js: -------------------------------------------------------------------------------- 1 | import { client } from "../graphql"; 2 | import gql from "graphql-tag"; 3 | import store from "../store"; 4 | 5 | export function isLoggedIn() { 6 | return client 7 | .query({ 8 | query: gql` 9 | { 10 | me { 11 | id 12 | } 13 | } 14 | `, 15 | fetchPolicy: "network-only" 16 | }) 17 | .then(({ data: { me } }) => me !== null); 18 | } 19 | 20 | export const PROVIDERS = { 21 | google: "https://accounts.google.com", 22 | facebook: "https://www.facebook.com", 23 | twitter: "https://api.twitter.com", 24 | discord: "https://discordapp.com", 25 | kitsu: "https://kitsu.io" 26 | }; 27 | 28 | export function exchangeSSOToken(token) { 29 | return fetch(`${process.env.AUTH_URL}/ssoExchange`, { 30 | method: "POST", 31 | headers: { 32 | "Content-Type": "application/json" 33 | }, 34 | body: JSON.stringify({ 35 | token 36 | }), 37 | credentials: "include" 38 | }) 39 | .then(res => res.json()) 40 | .then(({ csrf, ...fields }) => { 41 | localStorage.setItem("csrf", csrf); 42 | 43 | if (window.FederatedCredential) { 44 | const cred = new FederatedCredential({ 45 | id: fields.email, 46 | name: fields.login, 47 | iconURL: fields.avatar.startsWith("https://") 48 | ? fields.avatar 49 | : undefined, 50 | provider: PROVIDERS[fields.provider] 51 | }); 52 | 53 | navigator.credentials.store(cred); 54 | } 55 | 56 | store.dispatch("setIsAuth", true); 57 | return fields; 58 | }); 59 | } 60 | 61 | export function login(username, password) { 62 | return fetch(`${process.env.AUTH_URL}/login`, { 63 | method: "POST", 64 | headers: { 65 | "Content-Type": "application/json" 66 | }, 67 | body: JSON.stringify({ 68 | username, 69 | password 70 | }), 71 | credentials: "include" 72 | }) 73 | .then(res => { 74 | if (res.status === 200) return res.json(); 75 | else return res.json().then(alert => Promise.reject({ alert })); 76 | }) 77 | .then(({ csrf, ...fields }) => { 78 | localStorage.setItem("csrf", csrf); 79 | 80 | if (window.PasswordCredential) { 81 | const cred = new PasswordCredential({ 82 | id: fields.email, 83 | password, 84 | name: fields.login, 85 | iconURL: fields.avatar.startsWith("https://") 86 | ? fields.avatar 87 | : undefined 88 | }); 89 | 90 | navigator.credentials.store(cred); 91 | } 92 | 93 | store.dispatch("setIsAuth", true); 94 | return fields; 95 | }); 96 | } 97 | 98 | export function ssoLogin(provider, callback = window.location.href) { 99 | window.location.assign( 100 | `${process.env.AUTH_URL}/login/${provider}?callback=${encodeURIComponent( 101 | callback 102 | )}` 103 | ); 104 | } 105 | 106 | export function logout() { 107 | localStorage.removeItem("csrf"); 108 | if (navigator.credentials && navigator.credentials.preventSilentAccess) { 109 | // Turn on the mediation mode so auto sign-in won't happen 110 | // until next time user intended to do so. 111 | navigator.credentials.preventSilentAccess(); 112 | } 113 | return Promise.resolve(); 114 | } 115 | 116 | export function signup(login, email, password, newsletter) { 117 | return fetch(`${process.env.AUTH_URL}/signup`, { 118 | method: "POST", 119 | headers: { 120 | "Content-Type": "application/json" 121 | }, 122 | body: JSON.stringify({ 123 | login, 124 | email, 125 | password, 126 | newsletter 127 | }), 128 | credentials: "include" 129 | }) 130 | .then(res => res.json()) 131 | .then(({ csrf }) => localStorage.setItem("csrf", csrf)); 132 | } 133 | -------------------------------------------------------------------------------- /src/components/media/MediaList.vue: -------------------------------------------------------------------------------- 1 | 53 | 54 | 93 | 94 | 115 | -------------------------------------------------------------------------------- /src/components/cover/Cover.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 43 | 44 | 160 | -------------------------------------------------------------------------------- /tests/e2e/Navbar.test.js: -------------------------------------------------------------------------------- 1 | import { ClientFunction } from "testcafe"; 2 | import VueSelector from "testcafe-vue-selectors"; 3 | import collectCoverage from "./utils/istanbul"; 4 | 5 | fixture("Navbar") 6 | .page("http://localhost:8042/") 7 | .afterEach(collectCoverage); 8 | 9 | const navbar = VueSelector("navbar"); 10 | const mobileNavbar = VueSelector("mobile-navbar"); 11 | const app = VueSelector("v-app"); 12 | const getWindowLocation = ClientFunction(() => window.location); 13 | 14 | test("Navbar > Large screen have classic navbar", async t => { 15 | await t.resizeWindow(1280, 720); 16 | await t.expect(navbar.exists).eql(true); 17 | }); 18 | 19 | test("Navbar > Dark Theme input toogle Theme", async t => { 20 | await t.resizeWindow(1280, 720); 21 | const themeSwitch = navbar.find(".switch label").withText("Dark"); 22 | await t 23 | .expect(app.hasClass("theme--light")) 24 | .eql(true) 25 | .expect(app.hasClass("theme--dark")) 26 | .eql(false); 27 | await t.click(themeSwitch); 28 | await t 29 | .expect(app.hasClass("theme--light")) 30 | .eql(false) 31 | .expect(app.hasClass("theme--dark")) 32 | .eql(true); 33 | await t.click(themeSwitch); 34 | await t 35 | .expect(app.hasClass("theme--light")) 36 | .eql(true) 37 | .expect(app.hasClass("theme--dark")) 38 | .eql(false); 39 | }); 40 | 41 | test("Navbar > Language Switch change lang", async t => { 42 | await t.resizeWindow(1280, 720); 43 | const languageSelect = VueSelector("language-select"); 44 | await t.expect(languageSelect.find("span").textContent).eql("en"); 45 | await t 46 | .expect(navbar.find(".list__tile__content").withText("Index").exists) 47 | .eql(true); 48 | await t.click(languageSelect); 49 | await t.click(app.find(".list__tile__title").withText("fr")); 50 | await t.expect(languageSelect.find("span").textContent).eql("fr"); 51 | await t 52 | .expect(navbar.find(".list__tile__content").withText("Accueil").exists) 53 | .eql(true); 54 | }); 55 | 56 | test("Navbar > Button toogle navbar", async t => { 57 | await t.resizeWindow(1280, 720); 58 | const navigationDrawer = VueSelector("v-navigation-drawer"); 59 | await t.expect(navigationDrawer.getVue(({ props }) => props.value)).eql(true); 60 | await t.click(navbar.find(".nav-content .btn")); 61 | await t 62 | .expect(navigationDrawer.getVue(({ props }) => props.value)) 63 | .eql(false); 64 | await t.click(app.find(".el-float.btn")); 65 | await t.expect(navigationDrawer.getVue(({ props }) => props.value)).eql(true); 66 | }); 67 | 68 | test("Navbar > Navigation links", async t => { 69 | await t.resizeWindow(1280, 720); 70 | await t.click(navbar.find(".list__tile__content").withText("Search")); 71 | await t 72 | .expect((await getWindowLocation()).href) 73 | .eql("http://localhost:8042/search"); 74 | }); 75 | 76 | test("MobileNavbar > Little screen have mobile navbar", async t => { 77 | await t.resizeWindow(480, 800); 78 | await t.expect(mobileNavbar.exists).eql(true); 79 | }); 80 | 81 | test("MobileNavbar > Search", async t => { 82 | await t.resizeWindow(480, 800); 83 | await t 84 | .click(mobileNavbar.find(".toolbar__content > .btn")) 85 | .typeText(mobileNavbar.find(".toolbar__content input"), "Unicorn !") 86 | .expect(VueSelector("search-results").exists) 87 | .eql(true); 88 | }); 89 | 90 | test("MobileNavbar > Navigation links", async t => { 91 | await t.resizeWindow(480, 800); 92 | await t.click(mobileNavbar.find(".btn span").withText("Search")); 93 | await t 94 | .expect((await getWindowLocation()).href) 95 | .eql("http://localhost:8042/search"); 96 | }); 97 | 98 | test("MobileNavbar > SignIn", async t => { 99 | await t.resizeWindow(480, 800); 100 | await t.click(mobileNavbar.find(".toolbar .icon").withText("account_circle")); 101 | await t 102 | .expect((await getWindowLocation()).href) 103 | .eql("http://localhost:8042/auth/login"); 104 | }); 105 | -------------------------------------------------------------------------------- /src/components/Floating.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 107 | 108 | 109 | 140 | -------------------------------------------------------------------------------- /src/views/user/library/Follows.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 126 | 127 | 165 | -------------------------------------------------------------------------------- /src/views/user/settings/Connections.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 148 | 149 | 158 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@popcorn.moe/web", 3 | "version": "1.0.0", 4 | "description": "", 5 | "repository": { 6 | "type": "git", 7 | "url": "git+https://github.com/Popcorn-moe/Web.git" 8 | }, 9 | "author": "Popcorn-moe", 10 | "license": "GPL-3.0", 11 | "bugs": { 12 | "url": "https://github.com/Popcorn-moe/Web/issues" 13 | }, 14 | "homepage": "https://github.com/Popcorn-moe/Web#readme", 15 | "scripts": { 16 | "dev": "webpack-dev-server --inline --progress --config build/webpack.dev.conf.js", 17 | "start": "npm run dev", 18 | "lint": "eslint --ext .js,.vue src", 19 | "build": "node build/build.js", 20 | "format": "prettier --write --use-tabs \"src/**/*.js\" \"src/**/*.vue\" \"src/**/*.json\" \"tests/**/*.js\" \"tests/**/*.json\"", 21 | "precommit": "lint-staged", 22 | "test": "npm run test:e2e", 23 | "test:e2e": "concurrently --kill-others \"npm run test:e2e:mock\" \"npm run test:e2e:run\"", 24 | "test:e2e:run": "cross-env NODE_ENV=test testcafe \"chromium\" \"tests/e2e/*.test.js\" --app \"npm start >/dev/null 2>&1\" --app-init-delay 25000 -S -s screenshots", 25 | "test:e2e:mock": "node tests/e2e/mock" 26 | }, 27 | "lint-staged": { 28 | "*.{js,vue}": [ 29 | "prettier --use-tabs --write", 30 | "git add" 31 | ] 32 | }, 33 | "dependencies": { 34 | "@popcorn.moe/apollo": "^1.0.0", 35 | "dateformat": "^3.0.3", 36 | "ebml": "^2.2.4", 37 | "fullscreen-api-polyfill": "^1.1.2", 38 | "graphql-tag": "^2.9.2", 39 | "ismobilejs": "^0.4.1", 40 | "marked": "^0.3.19", 41 | "megajs": "^0.13.3", 42 | "vue": "^2.5.17", 43 | "vue-apollo": "^3.0.0-beta.19", 44 | "vue-head": "^2.0.12", 45 | "vue-i18n": "^7.8.1", 46 | "vue-router": "^3.0.1", 47 | "vue-simplemde": "^0.4.8", 48 | "vuetify": "^1.1.14", 49 | "vuex": "^3.0.1" 50 | }, 51 | "devDependencies": { 52 | "autoprefixer": "^8.6.5", 53 | "babel-eslint": "^8.2.6", 54 | "babel-helper-vue-jsx-merge-props": "^2.0.3", 55 | "babel-loader": "^7.1.5", 56 | "babel-plugin-istanbul": "^4.1.6", 57 | "babel-plugin-syntax-jsx": "^6.18.0", 58 | "babel-plugin-transform-imports": "^1.5.0", 59 | "babel-plugin-transform-runtime": "^6.23.0", 60 | "babel-plugin-transform-vue-jsx": "^3.7.0", 61 | "babel-preset-env": "^1.7.0", 62 | "babel-preset-stage-2": "^6.24.1", 63 | "chalk": "^2.4.1", 64 | "concurrently": "^3.6.1", 65 | "copy-webpack-plugin": "^4.5.2", 66 | "cors": "^2.8.4", 67 | "cross-env": "^5.2.0", 68 | "css-loader": "^0.28.11", 69 | "eslint": "^4.19.1", 70 | "eslint-friendly-formatter": "^4.0.1", 71 | "eslint-loader": "^2.1.0", 72 | "eslint-plugin-html": "^4.0.5", 73 | "exit-hook": "^2.0.0", 74 | "express": "^4.16.3", 75 | "express-graphql": "^0.6.12", 76 | "faker": "^4.1.0", 77 | "file-loader": "^1.1.11", 78 | "friendly-errors-webpack-plugin": "^1.7.0", 79 | "git-revision-webpack-plugin": "^3.0.3", 80 | "graphql": "^0.13.2", 81 | "graphql-tools": "^3.1.1", 82 | "html-loader": "^0.5.5", 83 | "html-webpack-plugin": "^3.2.0", 84 | "husky": "^0.14.3", 85 | "istanbul": "^0.4.5", 86 | "lint-staged": "^7.2.2", 87 | "mini-css-extract-plugin": "^0.4.2", 88 | "node-notifier": "^5.2.1", 89 | "optimize-css-assets-webpack-plugin": "^4.0.3", 90 | "ora": "^2.1.0", 91 | "portfinder": "^1.0.17", 92 | "postcss-import": "^11.1.0", 93 | "postcss-loader": "^2.1.6", 94 | "postcss-url": "^7.3.2", 95 | "prettier": "1.12.1", 96 | "rimraf": "^2.6.2", 97 | "script-ext-html-webpack-plugin": "^2.0.1", 98 | "semver": "^5.5.1", 99 | "shelljs": "^0.8.2", 100 | "style-loader": "^0.21.0", 101 | "stylus": "^0.54.5", 102 | "stylus-loader": "^3.0.2", 103 | "testcafe": "^0.20.5", 104 | "testcafe-vue-selectors": "^3.0.0", 105 | "uglifyjs-webpack-plugin": "^1.3.0", 106 | "url-loader": "^1.1.1", 107 | "vue-loader": "^15.4.1", 108 | "vue-style-loader": "^4.1.2", 109 | "vue-svg-loader": "^0.5.0", 110 | "vue-template-compiler": "^2.5.17", 111 | "webpack": "^4.17.1", 112 | "webpack-bundle-analyzer": "^2.13.1", 113 | "webpack-cli": "^3.1.2", 114 | "webpack-dev-server": "^3.1.6", 115 | "webpack-merge": "^4.1.4", 116 | "workbox-webpack-plugin": "^3.4.1" 117 | }, 118 | "engines": { 119 | "node": ">= 4.0.0", 120 | "npm": ">= 3.0.0" 121 | }, 122 | "browserslist": [ 123 | "> 1%", 124 | "last 2 versions", 125 | "not ie <= 8" 126 | ] 127 | } 128 | -------------------------------------------------------------------------------- /src/components/room/Chatbox.vue: -------------------------------------------------------------------------------- 1 | 86 | 87 | 130 | 131 | 141 | -------------------------------------------------------------------------------- /src/views/Author.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 114 | 115 | 186 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Router from "vue-router"; 3 | import store from "../store"; 4 | import { IS_LOADING } from "../store/app"; 5 | Vue.use(Router); 6 | 7 | export const routes = [ 8 | { 9 | icon: "home", 10 | path: "/", 11 | name: "Index", 12 | t: "route.index", 13 | component: () => import("../views/Index") 14 | }, 15 | { 16 | icon: "search", 17 | path: "/search", 18 | name: "Search", 19 | t: "route.search", 20 | component: () => import("../views/Search") 21 | }, 22 | { 23 | icon: "equalizer", 24 | path: "/news", 25 | name: "News", 26 | t: "route.news", 27 | component: () => import("../views/news/NewsList.vue") 28 | }, 29 | { 30 | hide: true, 31 | path: "/news/:id", 32 | name: "NewsPage", 33 | component: () => import("../views/news/News.vue"), 34 | props: true 35 | }, 36 | { 37 | hide: true, 38 | path: "/anime/:id", 39 | name: "Anime", 40 | component: () => import("../views/Anime"), 41 | props: true 42 | }, 43 | { 44 | hide: true, 45 | path: "/anime/:id/:mediaId", 46 | name: "Media", 47 | component: () => import("../views/Media"), 48 | meta: { hasPlayer: true, removeMobileNavbar: true }, 49 | props: true 50 | }, 51 | { 52 | hide: true, 53 | path: "/anime/:id/:season/:episode", 54 | name: "Episode", 55 | component: () => import("../views/Media"), 56 | meta: { hasPlayer: true, removeMobileNavbar: true }, 57 | props: true 58 | }, 59 | { 60 | hide: true, 61 | path: "/author/:id", 62 | name: "Author", 63 | component: () => import("../views/Author"), 64 | meta: { hasPlayer: true }, 65 | props: true 66 | }, 67 | { 68 | hide: true, 69 | name: "Login", 70 | path: "/auth/login", 71 | component: () => import("../views/auth/Login") 72 | }, 73 | { 74 | hide: true, 75 | name: "SignUp", 76 | path: "/auth/signup", 77 | component: () => import("../views/auth/SignUp") 78 | }, 79 | { 80 | hide: true, 81 | name: "User", 82 | path: "/user/:userLogin/:page(profile|follows|followers)", 83 | component: () => import("../views/user/User"), 84 | props: true 85 | }, 86 | { 87 | hide: true, 88 | name: "UserLibrary", 89 | path: "/user/:userLogin/:page(library)", 90 | component: () => import("../views/user/User"), 91 | props: true, 92 | children: [ 93 | { 94 | path: ":status(watching|want-to-watch|completed|on-hold|dropped)", 95 | name: "LibraryAnimeStatus", 96 | props: true, 97 | component: () => import("../views/user/library/AnimeStatus") 98 | }, 99 | { 100 | path: "search/:search", 101 | name: "LibrarySearch", 102 | props: true, 103 | component: () => import("../views/user/library/Search") 104 | } 105 | ] 106 | }, 107 | { 108 | hide: true, 109 | name: "UserSettings", 110 | path: "/user/:userLogin/:page(settings)", 111 | component: () => import("../views/user/User"), 112 | props: true, 113 | children: [ 114 | { 115 | path: "account", 116 | name: "UserSettingsAccount", 117 | t: "route.user_settings.account", 118 | icon: "face", 119 | component: () => import("../views/user/settings/Account") 120 | }, 121 | { 122 | path: "connections", 123 | name: "UserSettingsConnections", 124 | t: "route.user_settings.connections", 125 | icon: "supervisor_account", 126 | component: () => import("../views/user/settings/Connections"), 127 | divider: true 128 | } 129 | ] 130 | }, 131 | { 132 | hide: true, 133 | name: "Translations", 134 | path: "/translations", 135 | component: () => import("../views/Translations") 136 | }, 137 | { 138 | hide: true, 139 | name: "404", 140 | path: "/404", 141 | component: () => import("../views/404") 142 | }, 143 | { 144 | hide: true, 145 | path: "*", 146 | redirect: "/404" 147 | } 148 | ]; 149 | 150 | const router = new Router({ 151 | routes, 152 | mode: "history", 153 | scrollBehavior(to, from, savedPosition) { 154 | if (savedPosition) { 155 | return savedPosition; 156 | } else if (to.hash) { 157 | return { selector: to.hash }; 158 | } else if (to.name.startsWith("User")) { 159 | return null; 160 | } else if (from.name != to.name) { 161 | return new Promise(r => setTimeout(() => r({ x: 0, y: 0 }), 300)); // 300 ms = slide transition time 162 | } 163 | } 164 | }); 165 | 166 | let timeout; 167 | 168 | router.afterEach((to, from) => { 169 | if (!from.path.startsWith("/auth/")) { 170 | router.last = from.path; 171 | } 172 | if (timeout) clearTimeout(timeout); 173 | store.commit(IS_LOADING, false); 174 | }); 175 | 176 | router.beforeEach((to, from, next) => { 177 | timeout = setTimeout(() => { 178 | store.commit(IS_LOADING, true); 179 | timeout = null; 180 | }, 500); //Debouce 181 | next(); 182 | }); 183 | 184 | export default router; 185 | -------------------------------------------------------------------------------- /src/views/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 66 | 67 | 131 | 132 | 168 | -------------------------------------------------------------------------------- /src/views/auth/SignUp.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 126 | 127 | 163 | -------------------------------------------------------------------------------- /src/views/Index.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 197 | 198 | 231 | -------------------------------------------------------------------------------- /src/components/cover/CoverList.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 136 | 137 | 138 | 198 | -------------------------------------------------------------------------------- /src/components/layout/navbar/Notifications.vue: -------------------------------------------------------------------------------- 1 | 57 | 58 | 175 | 176 | 199 | -------------------------------------------------------------------------------- /src/components/layout/navbar/Navbar.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 172 | 173 | 211 | -------------------------------------------------------------------------------- /src/components/layout/navbar/MobileNavbar.vue: -------------------------------------------------------------------------------- 1 | 85 | 86 | 198 | 199 | 215 | -------------------------------------------------------------------------------- /src/views/user/Library.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 82 | 228 | 229 | 233 | -------------------------------------------------------------------------------- /src/stylus/theme.styl: -------------------------------------------------------------------------------- 1 | /** Stylus Styles */ 2 | @require '~vuetify/src/stylus/settings/_colors' 3 | 4 | // Popcorn 5 | // ============================================================ 6 | $main-color = #f6416c 7 | $secondary-color = #fddd84 8 | 9 | // Theme 10 | // ============================================================ 11 | $body-font-family := 'Roboto', sans-serif 12 | $font-size-root := 16px 13 | $line-height-root := 1.5 14 | $navigation-drawer-width = 280px; 15 | 16 | $theme := { 17 | primary: $main-color 18 | accent: $main-color 19 | secondary: $secondary-color 20 | info: $blue.base 21 | warning: $amber.base 22 | error: $red.accent-2 23 | success: $green.base 24 | } 25 | 26 | // Material Design - https://material.io/guidelines/style/color.html#color-usability 27 | // ============================================================ 28 | $material-light := { 29 | status-bar: { 30 | regular: #E0E0E0, 31 | lights-out: rgba(#fff, .7) 32 | }, 33 | app-bar: #F0F0F0, 34 | background: #FAFAFA, 35 | cards: #F0F0F0, 36 | chips: { 37 | background: $grey.lighten-2, 38 | color: rgba(#000, .87) 39 | }, 40 | dividers: rgba(#000, .12), 41 | text: { 42 | theme: #fff, 43 | primary: rgba(#000, .87), 44 | secondary: rgba(#000, .54), 45 | disabled: rgba(#000, .38), 46 | link: $blue.darken-2, 47 | link-hover: $grey.darken-3 48 | }, 49 | icons: { 50 | active: rgba(#000, .54), 51 | inactive: rgba(#000, .38) 52 | }, 53 | inputs: { 54 | box: rgba(#000, .04), 55 | solo-inverted: rgba(#000, .16), 56 | solo-inverted-focused: #424242, 57 | solo-inverted-focused-text: #fff 58 | }, 59 | buttons: { 60 | disabled: rgba(#000, .26), 61 | focused: rgba(#000, .12), 62 | focused-alt: rgba(#fff, .6), 63 | pressed: rgba(#999, .4) 64 | }, 65 | expansion-panels: { 66 | focus: #EEEEEE 67 | }, 68 | list-tile: { 69 | hover: rgba(#000, .04) 70 | }, 71 | selection-controls: { 72 | active: $main-color 73 | thumb: { 74 | inactive: $grey.lighten-5, 75 | disabled: $grey.lighten-1 76 | }, 77 | track: { 78 | inactive: rgba(#000, .38), 79 | disabled: rgba(#000, .12) 80 | } 81 | }, 82 | slider: { 83 | active: rgba(#000, .38), 84 | inactive: rgba(#000, .26), 85 | disabled: rgba(#000, .26), 86 | discrete: #000 87 | }, 88 | tabs: { 89 | active: rgba(#000, .87), 90 | inactive: rgba(#000, .7) 91 | }, 92 | text-fields: { 93 | box: rgba(#000, .06), 94 | box-hover: rgba(#000, .12) 95 | }, 96 | input-bottom-line: rgba(#000, .42), 97 | stepper: { 98 | active: rgba(#fff, 1), 99 | completed: rgba(#000, 0.87), 100 | hover: rgba(#000, 0.54) 101 | }, 102 | table: { 103 | active: $grey.lighten-4 104 | hover: $grey.lighten-3 105 | }, 106 | picker: { 107 | body: $theme.primary, 108 | clock: $grey.lighten-2, 109 | indeterminateTime: $grey.lighten-1, 110 | title: $grey.lighten-2 111 | }, 112 | bg-color: #fff 113 | fg-color: #000 114 | text-color: #000 115 | primary-text-percent: .87 116 | secondary-text-percent: .54 117 | disabledORhints-text-percent: .38 118 | divider-percent: .12 119 | active-icon-percent: .54 120 | inactive-icon-percent: .38 121 | } 122 | 123 | $material-dark := { 124 | status-bar: { 125 | regular: #000000, 126 | lights-out: rgba(#000, .2) 127 | }, 128 | app-bar: #1C1C1C, 129 | background: #111111, 130 | cards: #1C1C1C, 131 | chips: { 132 | background: #FFF, 133 | color: $material-light.text.primary 134 | }, 135 | dividers: rgba(#fff, .12), 136 | text: { 137 | theme: #fff, 138 | primary: #fff, 139 | secondary: rgba(#fff, .70), 140 | disabled: rgba(#fff, .50), 141 | link: $blue.accent-1, 142 | link-hover: $grey.lighten-3 143 | }, 144 | icons: { 145 | active: #fff, 146 | inactive: rgba(#fff, .5) 147 | }, 148 | inputs: { 149 | box: #fff, 150 | solo-inverted: rgba(#fff, .16), 151 | solo-inverted-focused: #fff, 152 | solo-inverted-focused-text: $material-light.text.primary 153 | }, 154 | list-tile: { 155 | hover: rgba(#fff, .08) 156 | }, 157 | buttons: { 158 | disabled: rgba(#fff, .3), 159 | focused: rgba(#fff, .12), 160 | focused-alt: rgba(#fff, .1), 161 | pressed: rgba(#ccc, .25) 162 | }, 163 | expansion-panels: { 164 | focus: rgba(#000, .7) 165 | }, 166 | selection-controls: { 167 | active: $main-color, 168 | thumb: { 169 | inactive: $grey.lighten-1, 170 | disabled: $grey.darken-3 171 | }, 172 | track: { 173 | inactive: rgba(#fff, .3), 174 | disabled: rgba(#fff, .1) 175 | } 176 | }, 177 | slider: { 178 | active: rgba(#fff, .3), 179 | inactive: rgba(#fff, .2), 180 | disabled: rgba(#fff, .2), 181 | discrete: #fff 182 | }, 183 | tabs: { 184 | active: #fff 185 | inactive: rgba(#fff, .7) 186 | }, 187 | text-fields: { 188 | box: rgba(#000, .1), 189 | box-hover: rgba(#000, .2), 190 | box-focus: rgba(#000, .3) 191 | }, 192 | input-bottom-line: rgba(#fff, .7), 193 | 194 | stepper: { 195 | active: rgba(#fff, 1), 196 | completed: rgba(#fff, 0.87), 197 | hover: rgba(#fff, 0.75) 198 | }, 199 | table: { 200 | active: $grey.darken-1 201 | hover: $grey.darken-2 202 | }, 203 | picker: { 204 | body: , 205 | clock: $grey.darken-2, 206 | indeterminateTime: $grey.darken-1, 207 | title: $grey.darken-2 208 | }, 209 | bg-color: #303030 210 | fg-color: #fff 211 | text-color: #fff 212 | primary-text-percent: 1 213 | secondary-text-percent: .70 214 | disabledORhints-text-percent: .50 215 | divider-percent: .12 216 | active-icon-percent: 1 217 | inactive-icon-percent: .50 218 | } 219 | 220 | // Default Material Theme 221 | // ============================================================ 222 | $material-theme := $material-light 223 | $material-twelve-percent-light := rgba($material-dark.fg-color, $material-dark.divider-percent) 224 | $material-twelve-percent-dark := rgba($material-light.fg-color, $material-light.divider-percent) 225 | 226 | 227 | // Theme 228 | // ============================================================ 229 | $body-bg-light := $material-light.background 230 | $body-bg-dark := $material-dark.background 231 | $body-color-light := rgba($material-dark.text-color, $material-dark.primary-text-percent) 232 | $body-color-dark := rgba($material-light.text-color, $material-light.primary-text-percent) 233 | $color-theme := rgba($material-theme.text-color, $material-theme.primary-text-percent) -------------------------------------------------------------------------------- /static/logo-animated.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /src/assets/loader.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /src/components/media/Comment.vue: -------------------------------------------------------------------------------- 1 | 69 | 70 | 189 | 190 | 249 | -------------------------------------------------------------------------------- /src/mse/MegaMediaSource.js: -------------------------------------------------------------------------------- 1 | import { File } from "megajs/dist/main.browser-es.js"; 2 | import ebml from "ebml"; 3 | 4 | // From https://www.webmproject.org/docs/container/ 5 | const INIT_EBML_ARRAYS = ["SeekHead", "Tracks", "Cues"]; 6 | const ROOT = Symbol("root"); 7 | 8 | function appendBuffer(buffer1, buffer2) { 9 | const tmp = new Uint8Array(buffer1.byteLength + buffer2.byteLength); 10 | tmp.set(new Uint8Array(buffer1), 0); 11 | tmp.set(new Uint8Array(buffer2), buffer1.byteLength); 12 | return tmp; 13 | } 14 | 15 | function byteArrayToLong(byteArray) { 16 | let value = 0; 17 | for (let i = 0; i < byteArray.length; i++) { 18 | value = value * 256 + byteArray[i]; 19 | } 20 | return value; 21 | } 22 | 23 | export default class MegaMediaSource { 24 | constructor(url, video) { 25 | this.mediaSource = new MediaSource(); 26 | this.video = video; 27 | this.video.addEventListener("error", () => console.error(this.video.error)); 28 | this.video.src = URL.createObjectURL(this.mediaSource); 29 | this.mediaSource.addEventListener("sourceopen", () => this.sourceOpen()); 30 | this.video.addEventListener("loadedmetadata", () => this.loadedMetadata()); 31 | this.video.addEventListener("seeking", e => this.seeking(e)); 32 | this.buffers = []; 33 | this.seekQueue = Promise.resolve(); 34 | this.lastSeek = 0; 35 | this.file = File.fromURL(url); 36 | } 37 | 38 | destroy() { 39 | this.stopDownload(); 40 | } 41 | 42 | sourceOpen() { 43 | this.readInitSegment().then(([data, segment]) => { 44 | /*for (const seek of data.Segment.SeekHead) { 45 | if (seek.SeekID && byteArrayToLong(seek.SeekID.data) == 0x1c53bb6b) 46 | console.log("Cue Offset", seek); 47 | }*/ 48 | this.cues = MegaMediaSource.getCues(data); 49 | this.sourceBuffer = this.mediaSource.addSourceBuffer( 50 | MegaMediaSource.getCodec(data) 51 | ); 52 | this.sourceBuffer.addEventListener("updateend", _ => this.updateEnd()); 53 | this.sourceBuffer.addEventListener("error", e => console.error(e)); 54 | this.appendBuffer(segment); 55 | this.lastByte = segment.byteLength; 56 | }); 57 | } 58 | 59 | loadedMetadata() { 60 | this.startDownload(this.lastByte); 61 | } 62 | 63 | seeking(e) { 64 | this.seekQueue = this.seekQueue.then( 65 | _ => 66 | new Promise(resolve => { 67 | let time = this.video.currentTime; 68 | if (this.lastSeek == time) return resolve(); 69 | this.lastSeek = time; 70 | 71 | const buffered = this.sourceBuffer.buffered; 72 | for (let i = 0; i < buffered.length; i++) 73 | if (time >= buffered.start(i) && time <= buffered.end(i)) { 74 | time = buffered.end(i); 75 | break; 76 | } 77 | 78 | this.stopDownload(); 79 | const next = this.cues.find(e => e.offset >= this.lastByte); 80 | 81 | const seek = () => { 82 | const next = this.cues 83 | .slice(0) 84 | .reverse() 85 | .find(e => e.time / 1000 <= time); 86 | this.startDownload(next.offset + 1); 87 | resolve(); 88 | }; 89 | if (next && next.offset == this.lastByte) seek(); 90 | else { 91 | const stream = this.file.download({ 92 | start: this.lastByte, 93 | end: next ? next.offset : undefined 94 | }); 95 | stream.on("data", data => this.appendBuffer(data)); 96 | stream.once("end", seek); 97 | } 98 | }) 99 | ); 100 | } 101 | 102 | startDownload(start) { 103 | if (this.currentStream) throw new Error("Download alrealy running"); 104 | this.lastByte = start; 105 | this.currentStream = this.file.download({ start, maxConnections: 2 }); 106 | 107 | this.currentStream.on("data", buffer => { 108 | if (this.currentStream) { 109 | this.appendBuffer(buffer); 110 | this.lastByte += buffer.byteLength; 111 | } 112 | }); 113 | this.currentStream.on("end", _ => this.appendBuffer(null)); 114 | } 115 | 116 | stopDownload() { 117 | this.currentStream.emit("close"); 118 | this.currentStream = null; 119 | } 120 | 121 | appendBuffer(buffer) { 122 | if (this.buffers.length > 0 || this.sourceBuffer.updating) 123 | this.buffers.push(buffer); 124 | else { 125 | if (buffer === null) this.mediaSource.endOfStream(); 126 | else this.sourceBuffer.appendBuffer(buffer); 127 | } 128 | } 129 | 130 | updateEnd() { 131 | //console.log("Update End"); 132 | const buffered = this.sourceBuffer.buffered; 133 | //for (let i = 0; i < buffered.length; i++) 134 | // console.log("\t => Buffered", i, buffered.start(i), buffered.end(i)); 135 | if (this.buffers.length && !this.sourceBuffer.updating) { 136 | const buffer = this.buffers.shift(); 137 | if (buffer === null) this.mediaSource.endOfStream(); 138 | else this.sourceBuffer.appendBuffer(buffer); 139 | } 140 | } 141 | 142 | readInitSegment() { 143 | return new Promise((resolve, reject) => { 144 | const stream = this.file.download({ 145 | maxChunkSize: 128 * 1024, 146 | maxConnections: 2 147 | }); 148 | const decoder = new ebml.Decoder(); 149 | 150 | let node = {}; 151 | let buffer = new Uint8Array(); 152 | 153 | stream.on("data", data => (buffer = appendBuffer(buffer, data))); 154 | 155 | decoder.on("data", ([type, data]) => { 156 | switch (type) { 157 | case "start": 158 | if (data.name == "Cluster") { 159 | resolve([node[ROOT], buffer.slice(0, data.start)]); 160 | stream.emit("close"); 161 | break; 162 | } 163 | 164 | if (Array.isArray(node)) { 165 | const tmp = INIT_EBML_ARRAYS.includes(data.name) ? [] : {}; 166 | tmp[ROOT] = node; 167 | node.push(tmp); 168 | node = tmp; 169 | } else { 170 | if (!node[data.name]) { 171 | node[data.name] = INIT_EBML_ARRAYS.includes(data.name) 172 | ? [] 173 | : {}; 174 | node[data.name][ROOT] = node; 175 | } 176 | node = node[data.name]; 177 | } 178 | node.dataOffset = data.end - data.dataSize; 179 | break; 180 | case "tag": 181 | if (Array.isArray(node)) node.push(data); 182 | else node[data.name] = data; 183 | break; 184 | case "end": 185 | node = node[ROOT]; 186 | break; 187 | } 188 | }); 189 | decoder.on("error", reject); 190 | 191 | stream.pipe(decoder); 192 | }); 193 | } 194 | 195 | static getCodec({ Segment: { Tracks } }) { 196 | let vCodec; 197 | let aCodec; 198 | for (const track of Tracks) { 199 | if (track.CodecID) { 200 | const codec = track.CodecID.data.toString().toLowerCase(); 201 | if (codec.startsWith("v_")) vCodec = codec.slice(2); 202 | else if (codec.startsWith("a_")) aCodec = codec.slice(2); 203 | } 204 | } 205 | if (vCodec && aCodec) return `video/webm;codecs="${vCodec},${aCodec}"`; 206 | else if (vCodec) return `video/webm;codecs="${vCodec}"`; 207 | else if (aCodec) return `audio/webm;codecs="${aCodec}"`; 208 | } 209 | 210 | static getCues({ 211 | Segment: { 212 | dataOffset, 213 | Cues, 214 | Info: { TimecodeScale } 215 | } 216 | }) { 217 | if (Cues === undefined) return; 218 | const timeBase = TimecodeScale 219 | ? byteArrayToLong(TimecodeScale.data) / 1000000 220 | : 1; 221 | const cues = []; 222 | for (const cue of Cues) { 223 | if (cue.CueTime) 224 | cues.push({ 225 | time: byteArrayToLong(cue["CueTime"].data) * timeBase, 226 | offset: 227 | byteArrayToLong( 228 | cue["CueTrackPositions"]["CueClusterPosition"].data 229 | ) + dataOffset 230 | }); 231 | } 232 | return cues; 233 | } 234 | } 235 | -------------------------------------------------------------------------------- /src/views/Search.vue: -------------------------------------------------------------------------------- 1 | 79 | 80 | 274 | 275 | 318 | --------------------------------------------------------------------------------