Initial commit: LACA parking management system
5
frontend/.eslintrc.js
Normal file
@@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
extends: [
|
||||
'@quasar/app-vite/eslint-config/index.js'
|
||||
]
|
||||
}
|
||||
1
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
27
frontend/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# Foxai (foxai-quasar)
|
||||
|
||||
## Node 16.14
|
||||
|
||||
## Install the dependencies
|
||||
|
||||
```bash
|
||||
yarn
|
||||
# or
|
||||
npm install
|
||||
```
|
||||
|
||||
### Start the app in development mode (hot-code reloading, error reporting, etc.)
|
||||
|
||||
```bash
|
||||
yarn run dev
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
```
|
||||
yarn run build
|
||||
```
|
||||
|
||||
### Customize the configuration
|
||||
|
||||
See [Configuring quasar.config.js](https://v2.quasar.dev/quasar-cli-vite/quasar-config-js).
|
||||
42
frontend/index.html
Normal file
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<title>
|
||||
<%= productName %>
|
||||
</title>
|
||||
|
||||
<meta charset="utf-8" />
|
||||
<meta name="keywords" content="LACA, Parking">
|
||||
<meta name="description" content="<%= productDescription %>" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
<meta name="viewport"
|
||||
content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width<% if (ctx.mode.cordova || ctx.mode.capacitor) { %>, viewport-fit=cover<% } %>" />
|
||||
|
||||
<!-- <link rel="icon" type="image/png" sizes="128x128" href="icons/favicon_128x128.png" />
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="icons/favicon_96x96.png" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="icons/favicon_32x32.png" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="icons/favicon_16x16.png" /> -->
|
||||
<link rel="icon" type="image/ico" href="logo.ico" />
|
||||
</head>
|
||||
<!-- Google tag (gtag.js) -->
|
||||
<!-- <script
|
||||
async
|
||||
src="https://www.googletagmanager.com/gtag/js?id=G-J47C40D2KB"
|
||||
></script> -->
|
||||
<script>
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag() {
|
||||
dataLayer.push(arguments);
|
||||
}
|
||||
gtag('js', new Date());
|
||||
|
||||
gtag('config', 'G-J47C40D2KB');
|
||||
</script>
|
||||
|
||||
<body>
|
||||
<!-- quasar:entry-point -->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
8718
frontend/package-lock.json
generated
Normal file
55
frontend/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "foxai-quasar",
|
||||
"version": "0.0.1",
|
||||
"description": "LACA",
|
||||
"productName": "LACA",
|
||||
"author": "duongdd",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint --ext .js,.ts,.vue ./",
|
||||
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
|
||||
"test": "echo \"No test specified\" && exit 0",
|
||||
"dev": "quasar dev",
|
||||
"build": "quasar build",
|
||||
"electron": "quasar build -m electron"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codekraft-studio/vue-record": "^0.0.3",
|
||||
"@geoapify/geocoder-autocomplete": "^2.1.0",
|
||||
"@indoorequal/vue-maplibre-gl": "^7.6.0",
|
||||
"@quasar/extras": "^1.16.4",
|
||||
"@vueuse/core": "^10.7.0",
|
||||
"axios": "^1.2.1",
|
||||
"dayjs": "^1.11.10",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"maplibre-gl": "^4.7.1",
|
||||
"quasar": "^2.6.0",
|
||||
"vue": "^3.0.0",
|
||||
"vue-google-maps": "^0.1.21",
|
||||
"vue-i18n": "^9.2.2",
|
||||
"vue-router": "^4.0.0",
|
||||
"vue3-google-oauth2": "^1.0.7",
|
||||
"vuex": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@intlify/vite-plugin-vue-i18n": "^3.3.1",
|
||||
"@quasar/app-vite": "^1.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^5.10.0",
|
||||
"@typescript-eslint/parser": "^5.10.0",
|
||||
"autoprefixer": "^10.4.2",
|
||||
"dotenv": "^16.3.1",
|
||||
"electron": "^27.0.3",
|
||||
"electron-builder": "^24.3.0",
|
||||
"electron-packager": "^17.1.1",
|
||||
"eslint": "^8.10.0",
|
||||
"eslint-config-prettier": "^8.1.0",
|
||||
"eslint-plugin-vue": "^9.0.0",
|
||||
"prettier": "^2.5.1",
|
||||
"typescript": "^4.5.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || ^16 || ^14.19",
|
||||
"npm": ">= 6.13.4",
|
||||
"yarn": ">= 1.21.1"
|
||||
}
|
||||
}
|
||||
27
frontend/postcss.config.cjs
Normal file
@@ -0,0 +1,27 @@
|
||||
/* eslint-disable */
|
||||
// https://github.com/michael-ciniawsky/postcss-load-config
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
// https://github.com/postcss/autoprefixer
|
||||
require('autoprefixer')({
|
||||
overrideBrowserslist: [
|
||||
'last 4 Chrome versions',
|
||||
'last 4 Firefox versions',
|
||||
'last 4 Edge versions',
|
||||
'last 4 Safari versions',
|
||||
'last 4 Android versions',
|
||||
'last 4 ChromeAndroid versions',
|
||||
'last 4 FirefoxAndroid versions',
|
||||
'last 4 iOS versions'
|
||||
]
|
||||
})
|
||||
|
||||
// https://github.com/elchininet/postcss-rtlcss
|
||||
// If you want to support RTL css, then
|
||||
// 1. yarn/npm install postcss-rtlcss
|
||||
// 2. optionally set quasar.config.js > framework > lang to an RTL language
|
||||
// 3. uncomment the following line:
|
||||
// require('postcss-rtlcss')
|
||||
]
|
||||
}
|
||||
BIN
frontend/public/about/Ashley_Jones.jpeg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/public/about/Bicycle.jpeg
Normal file
|
After Width: | Height: | Size: 108 KiB |
BIN
frontend/public/about/Ford_Philanthropy_Logo.jpeg
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
frontend/public/about/GIST_logo_445_1.jpeg
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/about/Mai_Nguyen.jpeg
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/public/about/laca.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/public/about/watson.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
31
frontend/public/en.svg
Normal file
@@ -0,0 +1,31 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<rect y="85.333" style="fill:#F0F0F0;" width="512" height="341.337"/>
|
||||
<polygon style="fill:#D80027;" points="288,85.33 224,85.33 224,223.996 0,223.996 0,287.996 224,287.996 224,426.662 288,426.662 288,287.996 512,287.996 512,223.996 288,223.996 "/>
|
||||
<g>
|
||||
<polygon style="fill:#0052B4;" points="393.785,315.358 512,381.034 512,315.358 "/>
|
||||
<polygon style="fill:#0052B4;" points="311.652,315.358 512,426.662 512,395.188 368.307,315.358 "/>
|
||||
<polygon style="fill:#0052B4;" points="458.634,426.662 311.652,344.998 311.652,426.662 "/>
|
||||
</g>
|
||||
<polygon style="fill:#F0F0F0;" points="311.652,315.358 512,426.662 512,395.188 368.307,315.358 "/>
|
||||
<polygon style="fill:#D80027;" points="311.652,315.358 512,426.662 512,395.188 368.307,315.358 "/>
|
||||
<g>
|
||||
<polygon style="fill:#0052B4;" points="90.341,315.356 0,365.546 0,315.356 "/>
|
||||
<polygon style="fill:#0052B4;" points="200.348,329.51 200.348,426.661 25.491,426.661 "/>
|
||||
</g>
|
||||
<polygon style="fill:#D80027;" points="143.693,315.358 0,395.188 0,426.662 0,426.662 200.348,315.358 "/>
|
||||
<g>
|
||||
<polygon style="fill:#0052B4;" points="118.215,196.634 0,130.958 0,196.634 "/>
|
||||
<polygon style="fill:#0052B4;" points="200.348,196.634 0,85.33 0,116.804 143.693,196.634 "/>
|
||||
<polygon style="fill:#0052B4;" points="53.366,85.33 200.348,166.994 200.348,85.33 "/>
|
||||
</g>
|
||||
<polygon style="fill:#F0F0F0;" points="200.348,196.634 0,85.33 0,116.804 143.693,196.634 "/>
|
||||
<polygon style="fill:#D80027;" points="200.348,196.634 0,85.33 0,116.804 143.693,196.634 "/>
|
||||
<g>
|
||||
<polygon style="fill:#0052B4;" points="421.659,196.636 512,146.446 512,196.636 "/>
|
||||
<polygon style="fill:#0052B4;" points="311.652,182.482 311.652,85.331 486.509,85.331 "/>
|
||||
</g>
|
||||
<polygon style="fill:#D80027;" points="368.307,196.634 512,116.804 512,85.33 512,85.33 311.652,196.634 "/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/public/favicon.ico
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
frontend/public/icons/desk.png
Normal file
|
After Width: | Height: | Size: 790 B |
BIN
frontend/public/icons/favicon-128x128.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
frontend/public/icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 859 B |
BIN
frontend/public/icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
frontend/public/icons/favicon-96x96.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/icons/favicon_128x128.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
BIN
frontend/public/icons/favicon_16x16.png
Normal file
|
After Width: | Height: | Size: 717 B |
BIN
frontend/public/icons/favicon_32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/icons/favicon_96x96.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
frontend/public/icons/home-bg-d.jpg
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/public/icons/home-bg-m.jpg
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
frontend/public/icons/logos/LACA_transparent (2).png
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
frontend/public/icons/logos/LACA_transparent.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
BIN
frontend/public/icons/logos/LACA_transparent2.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
frontend/public/icons/pexels-brett-sayles-1756957.jpg
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
frontend/public/icons/pexels-yasin-kayag-333085446-14730671.jpg
Normal file
|
After Width: | Height: | Size: 595 KiB |
BIN
frontend/public/icons/sound_max.png
Normal file
|
After Width: | Height: | Size: 1009 B |
BIN
frontend/public/images/default_user_icon_4.jpg
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
frontend/public/landing/banner_2-1.png
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
frontend/public/landing/banner_2.jfif
Normal file
|
After Width: | Height: | Size: 89 KiB |
BIN
frontend/public/landing/banner_2.pdf
Normal file
BIN
frontend/public/landing/smart_city_2.png
Normal file
|
After Width: | Height: | Size: 1.6 MiB |
BIN
frontend/public/landing/smart_city_6.jpg
Normal file
|
After Width: | Height: | Size: 25 MiB |
|
After Width: | Height: | Size: 494 KiB |
BIN
frontend/public/logo.ico
Normal file
|
After Width: | Height: | Size: 263 KiB |
BIN
frontend/public/logo/Asset 2.png
Normal file
|
After Width: | Height: | Size: 9.0 KiB |
BIN
frontend/public/logo/Asset 3.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
frontend/public/logo/LACA.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
7
frontend/public/vi.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" id="Capa_1" x="0px" y="0px" viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
|
||||
<polygon style="fill:#D80027;" points="196.641,85.337 0,85.337 0,426.663 196.641,426.663 512,426.663 512,85.337 "/>
|
||||
<polygon style="fill:#FFDA44;" points="256,157.279 278.663,227.026 352,227.026 292.668,270.132 315.332,339.881 256,296.774 196.668,339.881 219.332,270.132 160,227.026 233.337,227.026 "/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 576 B |
229
frontend/quasar.config.js
Normal file
@@ -0,0 +1,229 @@
|
||||
/* eslint-env node */
|
||||
|
||||
/*
|
||||
* This file runs in a Node context (it's NOT transpiled by Babel), so use only
|
||||
* the ES6 features that are supported by your Node version. https://node.green/
|
||||
*/
|
||||
|
||||
// Configuration for your app
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js
|
||||
|
||||
const { configure } = require('quasar/wrappers');
|
||||
const path = require('path');
|
||||
require('dotenv').config();
|
||||
|
||||
module.exports = configure(function (/* ctx */) {
|
||||
return {
|
||||
eslint: {
|
||||
// fix: true,
|
||||
// include: [],
|
||||
// exclude: [],
|
||||
// rawOptions: {},
|
||||
warnings: false,
|
||||
errors: false,
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/prefetch-feature
|
||||
// preFetch: true,
|
||||
|
||||
// app boot file (/src/boot)
|
||||
// --> boot files are part of "main.js"
|
||||
// https://v2.quasar.dev/quasar-cli-vite/boot-files
|
||||
boot: ['i18n', 'axios', 'google'],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#css
|
||||
css: ['app.scss'],
|
||||
|
||||
// https://github.com/quasarframework/quasar/tree/dev/extras
|
||||
extras: [
|
||||
// 'ionicons-v4',
|
||||
// 'mdi-v5',
|
||||
// 'fontawesome-v6',
|
||||
// 'eva-icons',
|
||||
// 'themify',
|
||||
// 'line-awesome',
|
||||
// 'roboto-font-latin-ext', // this or either 'roboto-font', NEVER both!
|
||||
|
||||
'roboto-font', // optional, you are not bound to it
|
||||
'material-icons', // optional, you are not bound to it
|
||||
'fontawesome-v6',
|
||||
],
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#build
|
||||
build: {
|
||||
target: {
|
||||
browser: ['es2019', 'edge88', 'firefox78', 'chrome87', 'safari13.1'],
|
||||
node: 'node16',
|
||||
},
|
||||
|
||||
vueRouterMode: 'hash', // available values: 'hash', 'history'
|
||||
// vueRouterBase,
|
||||
// vueDevtools,
|
||||
// vueOptionsAPI: false,
|
||||
|
||||
// rebuildCache: true, // rebuilds Vite/linter/etc cache on startup
|
||||
|
||||
publicPath: '/',
|
||||
// analyze: true,
|
||||
env: {
|
||||
VUE_APP_CLIENT_ID: process.env.VUE_APP_CLIENT_ID,
|
||||
VUE_APP_API: process.env.VUE_APP_API,
|
||||
VUE_APP_API_SERVER: process.env.VUE_APP_API_SERVER,
|
||||
VITE_APP_GEO_MAP_API_KEY: process.env.VITE_APP_GEO_MAP_API_KEY,
|
||||
VUE_APP_API_KEY_STYLE_MAP: process.env.VUE_APP_API_KEY_STYLE_MAP,
|
||||
},
|
||||
// rawDefine: {}
|
||||
// ignorePublicFolder: true,
|
||||
// minify: false,
|
||||
// polyfillModulePreload: true,
|
||||
// distDir
|
||||
|
||||
// extendViteConf (viteConf) {},
|
||||
// viteVuePluginOptions: {},
|
||||
|
||||
vitePlugins: [
|
||||
[
|
||||
'@intlify/vite-plugin-vue-i18n',
|
||||
{
|
||||
// if you want to use Vue I18n Legacy API, you need to set `compositionOnly: false`
|
||||
// compositionOnly: false,
|
||||
|
||||
// if you want to use named tokens in your Vue I18n messages, such as 'Hello {name}',
|
||||
// you need to set `runtimeOnly: false`
|
||||
// runtimeOnly: false,
|
||||
|
||||
// you need to set i18n resource including paths !
|
||||
include: path.resolve(__dirname, './src/i18n/**'),
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#devServer
|
||||
devServer: {
|
||||
// https: true
|
||||
open: true, // opens browser window automatically
|
||||
port: 8080,
|
||||
env: {
|
||||
VUE_APP_CLIENT_ID: process.env.VUE_APP_CLIENT_ID,
|
||||
VUE_APP_API: process.env.VUE_APP_API,
|
||||
VUE_APP_API_SERVER: process.env.VUE_APP_API_SERVER,
|
||||
VITE_APP_GEO_MAP_API_KEY: process.env.VITE_APP_GEO_MAP_API_KEY,
|
||||
},
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#framework
|
||||
framework: {
|
||||
config: {},
|
||||
iconSet: 'fontawesome-v6',
|
||||
// iconSet: 'material-icons', // Quasar icon set
|
||||
// lang: 'en-US', // Quasar language pack
|
||||
|
||||
// For special cases outside of where the auto-import strategy can have an impact
|
||||
// (like functional components as one of the examples),
|
||||
// you can manually specify Quasar components/directives to be available everywhere:
|
||||
//
|
||||
// components: [],
|
||||
// directives: [],
|
||||
|
||||
// Quasar plugins
|
||||
plugins: ['Notify', 'Dialog'],
|
||||
},
|
||||
|
||||
// animations: 'all', // --- includes all animations
|
||||
// https://v2.quasar.dev/options/animations
|
||||
animations: [],
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/quasar-config-js#sourcefiles
|
||||
// sourceFiles: {
|
||||
// rootComponent: 'src/App.vue',
|
||||
// router: 'src/router/index',
|
||||
// store: 'src/store/index',
|
||||
// registerServiceWorker: 'src-pwa/register-service-worker',
|
||||
// serviceWorker: 'src-pwa/custom-service-worker',
|
||||
// pwaManifestFile: 'src-pwa/manifest.json',
|
||||
// electronMain: 'src-electron/electron-main',
|
||||
// electronPreload: 'src-electron/electron-preload'
|
||||
// },
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-ssr/configuring-ssr
|
||||
ssr: {
|
||||
// ssrPwaHtmlFilename: 'offline.html', // do NOT use index.html as name!
|
||||
// will mess up SSR
|
||||
|
||||
// extendSSRWebserverConf (esbuildConf) {},
|
||||
// extendPackageJson (json) {},
|
||||
|
||||
pwa: false,
|
||||
|
||||
// manualStoreHydration: true,
|
||||
// manualPostHydrationTrigger: true,
|
||||
|
||||
prodPort: 3000, // The default port that the production server should use
|
||||
// (gets superseded if process.env.PORT is specified at runtime)
|
||||
|
||||
middlewares: [
|
||||
'render', // keep this as last one
|
||||
],
|
||||
},
|
||||
|
||||
// https://v2.quasar.dev/quasar-cli-vite/developing-pwa/configuring-pwa
|
||||
pwa: {
|
||||
workboxMode: 'generateSW', // or 'injectManifest'
|
||||
injectPwaMetaTags: true,
|
||||
swFilename: 'sw.js',
|
||||
manifestFilename: 'manifest.json',
|
||||
useCredentialsForManifestTag: false,
|
||||
// useFilenameHashes: true,
|
||||
// extendGenerateSWOptions (cfg) {}
|
||||
// extendInjectManifestOptions (cfg) {},
|
||||
// extendManifestJson (json) {}
|
||||
// extendPWACustomSWConf (esbuildConf) {}
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-cordova-apps/configuring-cordova
|
||||
cordova: {
|
||||
// noIosLegacyBuildFlag: true, // uncomment only if you know what you are doing
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-capacitor-apps/configuring-capacitor
|
||||
capacitor: {
|
||||
hideSplashscreen: true,
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-electron-apps/configuring-electron
|
||||
electron: {
|
||||
// extendElectronMainConf (esbuildConf)
|
||||
// extendElectronPreloadConf (esbuildConf)
|
||||
|
||||
inspectPort: 5858,
|
||||
|
||||
bundler: 'packager', // 'packager' or 'builder'
|
||||
|
||||
packager: {
|
||||
// https://github.com/electron-userland/electron-packager/blob/master/docs/api.md#options
|
||||
// OS X / Mac App Store
|
||||
// appBundleId: '',
|
||||
// appCategoryType: '',
|
||||
// osxSign: '',
|
||||
// protocol: 'myapp://path',
|
||||
// Windows only
|
||||
// win32metadata: { ... }
|
||||
},
|
||||
|
||||
builder: {
|
||||
// https://www.electron.build/configuration/configuration
|
||||
|
||||
appId: 'foxai-quasar',
|
||||
},
|
||||
},
|
||||
|
||||
// Full list of options: https://v2.quasar.dev/quasar-cli-vite/developing-browser-extensions/configuring-bex
|
||||
bex: {
|
||||
contentScripts: ['my-content-script'],
|
||||
|
||||
// extendBexScriptsConf (esbuildConf) {}
|
||||
// extendBexManifestJson (json) {}
|
||||
},
|
||||
};
|
||||
});
|
||||
11
frontend/src/App.vue
Normal file
@@ -0,0 +1,11 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'App',
|
||||
});
|
||||
</script>
|
||||
BIN
frontend/src/assets/fonts/Inter-VariableFont_slnt,wght.ttf
Normal file
93
frontend/src/assets/fonts/OFL.txt
Normal file
@@ -0,0 +1,93 @@
|
||||
Copyright 2020 The Inter Project Authors (https://github.com/rsms/inter)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
72
frontend/src/assets/fonts/README.txt
Normal file
@@ -0,0 +1,72 @@
|
||||
Inter Variable Font
|
||||
===================
|
||||
|
||||
This download contains Inter as both a variable font and static fonts.
|
||||
|
||||
Inter is a variable font with these axes:
|
||||
slnt
|
||||
wght
|
||||
|
||||
This means all the styles are contained in a single file:
|
||||
Inter-VariableFont_slnt,wght.ttf
|
||||
|
||||
If your app fully supports variable fonts, you can now pick intermediate styles
|
||||
that aren’t available as static fonts. Not all apps support variable fonts, and
|
||||
in those cases you can use the static font files for Inter:
|
||||
static/Inter-Thin.ttf
|
||||
static/Inter-ExtraLight.ttf
|
||||
static/Inter-Light.ttf
|
||||
static/Inter-Regular.ttf
|
||||
static/Inter-Medium.ttf
|
||||
static/Inter-SemiBold.ttf
|
||||
static/Inter-Bold.ttf
|
||||
static/Inter-ExtraBold.ttf
|
||||
static/Inter-Black.ttf
|
||||
|
||||
Get started
|
||||
-----------
|
||||
|
||||
1. Install the font files you want to use
|
||||
|
||||
2. Use your app's font picker to view the font family and all the
|
||||
available styles
|
||||
|
||||
Learn more about variable fonts
|
||||
-------------------------------
|
||||
|
||||
https://developers.google.com/web/fundamentals/design-and-ux/typography/variable-fonts
|
||||
https://variablefonts.typenetwork.com
|
||||
https://medium.com/variable-fonts
|
||||
|
||||
In desktop apps
|
||||
|
||||
https://theblog.adobe.com/can-variable-fonts-illustrator-cc
|
||||
https://helpx.adobe.com/nz/photoshop/using/fonts.html#variable_fonts
|
||||
|
||||
Online
|
||||
|
||||
https://developers.google.com/fonts/docs/getting_started
|
||||
https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Fonts/Variable_Fonts_Guide
|
||||
https://developer.microsoft.com/en-us/microsoft-edge/testdrive/demos/variable-fonts
|
||||
|
||||
Installing fonts
|
||||
|
||||
MacOS: https://support.apple.com/en-us/HT201749
|
||||
Linux: https://www.google.com/search?q=how+to+install+a+font+on+gnu%2Blinux
|
||||
Windows: https://support.microsoft.com/en-us/help/314960/how-to-install-or-remove-a-font-in-windows
|
||||
|
||||
Android Apps
|
||||
|
||||
https://developers.google.com/fonts/docs/android
|
||||
https://developer.android.com/guide/topics/ui/look-and-feel/downloadable-fonts
|
||||
|
||||
License
|
||||
-------
|
||||
Please read the full license text (OFL.txt) to understand the permissions,
|
||||
restrictions and requirements for usage, redistribution, and modification.
|
||||
|
||||
You can use them in your products & projects – print or digital,
|
||||
commercial or otherwise.
|
||||
|
||||
This isn't legal advice, please consider consulting a lawyer and see the full
|
||||
license for all details.
|
||||
BIN
frontend/src/assets/fonts/static/Inter-Black.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-Bold.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-ExtraBold.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-ExtraLight.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-Light.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-Medium.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-Regular.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-SemiBold.ttf
Normal file
BIN
frontend/src/assets/fonts/static/Inter-Thin.ttf
Normal file
BIN
frontend/src/assets/images/bg.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
frontend/src/assets/images/logo.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
frontend/src/assets/images/upload.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
15
frontend/src/assets/quasar-logo-vertical.svg
Normal file
@@ -0,0 +1,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 356 360">
|
||||
<path
|
||||
d="M43.4 303.4c0 3.8-2.3 6.3-7.1 6.3h-15v-22h14.4c4.3 0 6.2 2.2 6.2 5.2 0 2.6-1.5 4.4-3.4 5 2.8.4 4.9 2.5 4.9 5.5zm-8-13H24.1v6.9H35c2.1 0 4-1.3 4-3.8 0-2.2-1.3-3.1-3.7-3.1zm5.1 12.6c0-2.3-1.8-3.7-4-3.7H24.2v7.7h11.7c3.4 0 4.6-1.8 4.6-4zm36.3 4v2.7H56v-22h20.6v2.7H58.9v6.8h14.6v2.3H58.9v7.5h17.9zm23-5.8v8.5H97v-8.5l-11-13.4h3.4l8.9 11 8.8-11h3.4l-10.8 13.4zm19.1-1.8V298c0-7.9 5.2-10.7 12.7-10.7 7.5 0 13 2.8 13 10.7v1.4c0 7.9-5.5 10.8-13 10.8s-12.7-3-12.7-10.8zm22.7 0V298c0-5.7-3.9-8-10-8-6 0-9.8 2.3-9.8 8v1.4c0 5.8 3.8 8.1 9.8 8.1 6 0 10-2.3 10-8.1zm37.2-11.6v21.9h-2.9l-15.8-17.9v17.9h-2.8v-22h3l15.6 18v-18h2.9zm37.9 10.2v1.3c0 7.8-5.2 10.4-12.4 10.4H193v-22h11.2c7.2 0 12.4 2.8 12.4 10.3zm-3 0c0-5.3-3.3-7.6-9.4-7.6h-8.4V307h8.4c6 0 9.5-2 9.5-7.7V298zm50.8-7.6h-9.7v19.3h-3v-19.3h-9.7v-2.6h22.4v2.6zm34.4-2.6v21.9h-3v-10.1h-16.8v10h-2.8v-21.8h2.8v9.2H296v-9.2h2.9zm34.9 19.2v2.7h-20.7v-22h20.6v2.7H316v6.8h14.5v2.3H316v7.5h17.8zM24 340.2v7.3h13.9v2.4h-14v9.6H21v-22h20v2.7H24zm41.5 11.4h-9.8v7.9H53v-22h13.3c5.1 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6H66c3.1 0 5.3-1.5 5.3-4.7 0-3.3-2.2-4.1-5.3-4.1H55.7v8.8zm47.9 6.2H89l-2 4.3h-3.2l10.7-22.2H98l10.7 22.2h-3.2l-2-4.3zm-1-2.3l-6.3-13-6 13h12.2zm46.3-15.3v21.9H146v-17.2L135.7 358h-2.1l-10.2-15.6v17h-2.8v-21.8h3l11 16.9 11.3-17h3zm35 19.3v2.6h-20.7v-22h20.6v2.7H166v6.8h14.5v2.3H166v7.6h17.8zm47-19.3l-8.3 22h-3l-7.1-18.6-7 18.6h-3l-8.2-22h3.3L204 356l6.8-18.5h3.4L221 356l6.6-18.5h3.3zm10 11.6v-1.4c0-7.8 5.2-10.7 12.7-10.7 7.6 0 13 2.9 13 10.7v1.4c0 7.9-5.4 10.8-13 10.8-7.5 0-12.7-3-12.7-10.8zm22.8 0v-1.4c0-5.7-4-8-10-8s-9.9 2.3-9.9 8v1.4c0 5.8 3.8 8.2 9.8 8.2 6.1 0 10-2.4 10-8.2zm28.3 2.4h-9.8v7.9h-2.8v-22h13.2c5.2 0 8 1.9 8 6.8 0 3.7-2 6.3-5.6 7l6 8.2h-3.3l-5.8-8zm-9.8-2.6h10.2c3 0 5.2-1.5 5.2-4.7 0-3.3-2.1-4.1-5.2-4.1h-10.2v8.8zm40.3-1.5l-6.8 5.6v6.4h-2.9v-22h2.9v12.3l15.2-12.2h3.7l-9.9 8.1 10.3 13.8h-3.6l-8.9-12z" />
|
||||
<path fill="#050A14"
|
||||
d="M188.4 71.7a10.4 10.4 0 01-20.8 0 10.4 10.4 0 1120.8 0zM224.2 45c-2.2-3.9-5-7.5-8.2-10.7l-12 7c-3.7-3.2-8-5.7-12.6-7.3a49.4 49.4 0 00-9.7 13.9 59 59 0 0140.1 14l7.6-4.4a57 57 0 00-5.2-12.5zM178 125.1c4.5 0 9-.6 13.4-1.7v-14a40 40 0 0012.5-7.2 47.7 47.7 0 00-7.1-15.3 59 59 0 01-32.2 27.7v8.7c4.4 1.2 8.9 1.8 13.4 1.8zM131.8 45c-2.3 4-4 8.1-5.2 12.5l12 7a40 40 0 000 14.4c5.7 1.5 11.3 2 16.9 1.5a59 59 0 01-8-41.7l-7.5-4.3c-3.2 3.2-6 6.7-8.2 10.6z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M224.2 98.4c2.3-3.9 4-8 5.2-12.4l-12-7a40 40 0 000-14.5c-5.7-1.5-11.3-2-16.9-1.5a59 59 0 018 41.7l7.5 4.4c3.2-3.2 6-6.8 8.2-10.7zm-92.4 0c2.2 4 5 7.5 8.2 10.7l12-7a40 40 0 0012.6 7.3c4-4.1 7.3-8.8 9.7-13.8a59 59 0 01-40-14l-7.7 4.4c1.2 4.3 3 8.5 5.2 12.4zm46.2-80c-4.5 0-9 .5-13.4 1.7V34a40 40 0 00-12.5 7.2c1.5 5.7 4 10.8 7.1 15.4a59 59 0 0132.2-27.7V20a53.3 53.3 0 00-13.4-1.8z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M178 9.2a62.6 62.6 0 11-.1 125.2A62.6 62.6 0 01178 9.2m0-9.2a71.7 71.7 0 100 143.5A71.7 71.7 0 00178 0z" />
|
||||
<path fill="#050A14"
|
||||
d="M96.6 212v4.3c-9.2-.8-15.4-5.8-15.4-17.8V180h4.6v18.4c0 8.6 4 12.6 10.8 13.5zm16-31.9v18.4c0 8.9-4.3 12.8-10.9 13.5v4.4c9.2-.7 15.5-5.6 15.5-18v-18.3h-4.7zM62.2 199v-2.2c0-12.7-8.8-17.4-21-17.4-12.1 0-20.7 4.7-20.7 17.4v2.2c0 12.8 8.6 17.6 20.7 17.6 1.5 0 3-.1 4.4-.3l11.8 6.2 2-3.3-8.2-4-6.4-3.1a32 32 0 01-3.6.2c-9.8 0-16-3.9-16-13.3v-2.2c0-9.3 6.2-13.1 16-13.1 9.9 0 16.3 3.8 16.3 13.1v2.2c0 5.3-2.1 8.7-5.6 10.8l4.8 2.4c3.4-2.8 5.5-7 5.5-13.2zM168 215.6h5.1L156 179.7h-4.8l17 36zM143 205l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.8-3.7H143zm133.7 10.7h5.2l-17.3-35.9h-4.8l17 36zm-25-10.7l7.4-15.7-2.4-5-15.1 31.4h5.1l3.3-7h18.3l-1.7-3.7h-14.8zm73.8-2.5c6-1.2 9-5.4 9-11.4 0-8-4.5-10.9-12.9-10.9h-21.4v35.5h4.6v-31.3h16.5c5 0 8.5 1.4 8.5 6.7 0 5.2-3.5 7.7-8.5 7.7h-11.4v4.1h10.7l9.3 12.8h5.5l-9.9-13.2zm-117.4 9.9c-9.7 0-14.7-2.5-18.6-6.3l-2.2 3.8c5.1 5 11 6.7 21 6.7 1.6 0 3.1-.1 4.6-.3l-1.9-4h-3zm18.4-7c0-6.4-4.7-8.6-13.8-9.4l-10.1-1c-6.7-.7-9.3-2.2-9.3-5.6 0-2.5 1.4-4 4.6-5l-1.8-3.8c-4.7 1.4-7.5 4.2-7.5 8.9 0 5.2 3.4 8.7 13 9.6l11.3 1.2c6.4.6 8.9 2 8.9 5.4 0 2.7-2.1 4.7-6 5.8l1.8 3.9c5.3-1.6 8.9-4.7 8.9-10zm-20.3-21.9c7.9 0 13.3 1.8 18.1 5.7l1.8-3.9a30 30 0 00-19.6-5.9c-2 0-4 .1-5.7.3l1.9 4 3.5-.2z" />
|
||||
<path fill="#00B4FF"
|
||||
d="M.5 251.9c29.6-.5 59.2-.8 88.8-1l88.7-.3 88.7.3 44.4.4 44.4.6-44.4.6-44.4.4-88.7.3-88.7-.3a7981 7981 0 01-88.8-1z" />
|
||||
<path fill="none" d="M-565.2 324H-252v15.8h-313.2z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
0
frontend/src/boot/.gitkeep
Normal file
76
frontend/src/boot/axios.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
|
||||
declare module '@vue/runtime-core' {
|
||||
interface ComponentCustomProperties {
|
||||
$axios: AxiosInstance;
|
||||
$api: AxiosInstance;
|
||||
}
|
||||
}
|
||||
|
||||
// Be careful when using SSR for cross-request state pollution
|
||||
// due to creating a Singleton instance here;
|
||||
// If any client changes this (global) instance, it might be a
|
||||
// good idea to move this instance creation inside of the
|
||||
// "export default () => {}" function below (which runs individually
|
||||
// for each client)
|
||||
const apiServer = axios.create({ baseURL: process.env.VUE_APP_API_SERVER });
|
||||
apiServer.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('accesstoken');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
||||
(error) => {
|
||||
console.log(error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
apiServer.interceptors.response.use(
|
||||
(res) => {
|
||||
return res;
|
||||
},
|
||||
async (err) => {
|
||||
const originalConfig = err.config;
|
||||
const listNotRefreach = ['/auth/login-google', '/auth/login'];
|
||||
if (!listNotRefreach.includes(originalConfig.url) && err.response) {
|
||||
// Access Token was expired
|
||||
if (err.response.status === 401 && !originalConfig._retry) {
|
||||
originalConfig._retry = true;
|
||||
const tryTime = Number(localStorage.getItem('try'));
|
||||
localStorage.setItem('try', (tryTime + 1).toString());
|
||||
try {
|
||||
if (tryTime < 2) {
|
||||
const rs = await apiServer.post('auth/refresh-token', {
|
||||
refresh_token: localStorage.getItem('refresh'),
|
||||
});
|
||||
|
||||
localStorage.setItem('accesstoken', rs.data.data.access_token);
|
||||
localStorage.setItem('refresh', rs.data.data.refresh_token);
|
||||
if (rs.data.data) localStorage.setItem('try', '0');
|
||||
|
||||
return apiServer(originalConfig);
|
||||
} else {
|
||||
localStorage.removeItem('accesstoken');
|
||||
localStorage.removeItem('refresh');
|
||||
window.close();
|
||||
}
|
||||
} catch (_error) {
|
||||
return Promise.reject(_error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(err);
|
||||
}
|
||||
);
|
||||
|
||||
export default boot(({ app }) => {
|
||||
app.config.globalProperties.$axios = axios;
|
||||
app.config.globalProperties.$apiServer = apiServer;
|
||||
});
|
||||
|
||||
export { apiServer };
|
||||
15
frontend/src/boot/google.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { boot } from 'quasar/wrappers';
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
import gAuthPlugin from 'vue3-google-oauth2';
|
||||
|
||||
export default boot(({ app }) => {
|
||||
const gauthOption = {
|
||||
clientId: process.env.VUE_APP_CLIENT_ID,
|
||||
scope: 'profile email',
|
||||
prompt: 'consent',
|
||||
fetch_basic_profile: true,
|
||||
plugin_name: 'chat'
|
||||
}
|
||||
app.use(gAuthPlugin, gauthOption)
|
||||
});
|
||||
32
frontend/src/boot/i18n.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { boot } from 'quasar/wrappers';
|
||||
import { createI18n } from 'vue-i18n';
|
||||
|
||||
import messages from 'src/i18n';
|
||||
|
||||
export type MessageLanguages = keyof typeof messages;
|
||||
// Type-define 'en-US' as the master schema for the resource
|
||||
export type MessageSchema = (typeof messages)['vn-VN'];
|
||||
|
||||
// See https://vue-i18n.intlify.dev/guide/advanced/typescript.html#global-resource-schema-type-definition
|
||||
/* eslint-disable @typescript-eslint/no-empty-interface */
|
||||
declare module 'vue-i18n' {
|
||||
// define the locale messages schema
|
||||
export interface DefineLocaleMessage extends MessageSchema {}
|
||||
|
||||
// define the datetime format schema
|
||||
export interface DefineDateTimeFormat {}
|
||||
|
||||
// define the number format schema
|
||||
export interface DefineNumberFormat {}
|
||||
}
|
||||
/* eslint-enable @typescript-eslint/no-empty-interface */
|
||||
|
||||
export default boot(({ app }) => {
|
||||
const i18n = createI18n({
|
||||
locale: 'vn-VN',
|
||||
messages,
|
||||
});
|
||||
|
||||
// Set i18n instance on app
|
||||
app.use(i18n);
|
||||
});
|
||||
20
frontend/src/components/AudioVisual/components/AVBars.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { makeBarsProps } from '../composables/useProps'
|
||||
import { useAVBars } from '../composables/useAVBars'
|
||||
|
||||
const props = defineProps(makeBarsProps())
|
||||
const player = ref(null)
|
||||
const canvas = ref(null)
|
||||
|
||||
useAVBars(player, canvas, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="full-width">
|
||||
<audio ref="player"
|
||||
:controls="props.audioControls"
|
||||
:src="props.src" />
|
||||
<canvas ref="canvas" />
|
||||
</div>
|
||||
</template>
|
||||
18
frontend/src/components/AudioVisual/components/AVCircle.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { makeCircleProps } from '../composables/useProps'
|
||||
import { useAVCircle } from '../composables/useAVCircle'
|
||||
|
||||
const props = defineProps(makeCircleProps())
|
||||
const player = ref(null)
|
||||
const canvas = ref(null)
|
||||
|
||||
useAVCircle(player, canvas, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio ref="player"
|
||||
:controls="props.audioControls"
|
||||
:src="props.src" />
|
||||
<canvas ref="canvas" />
|
||||
</template>
|
||||
18
frontend/src/components/AudioVisual/components/AVLine.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { makeLineProps } from '../composables/useProps'
|
||||
import { useAVLine } from '../composables/useAVLine'
|
||||
|
||||
const props = defineProps(makeLineProps())
|
||||
const player = ref(null)
|
||||
const canvas = ref(null)
|
||||
|
||||
useAVLine(player, canvas, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<audio ref="player"
|
||||
:controls="props.audioControls"
|
||||
:src="props.src" />
|
||||
<canvas ref="canvas" />
|
||||
</template>
|
||||
15
frontend/src/components/AudioVisual/components/AVMedia.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAVMedia } from '../composables/useAVMedia'
|
||||
import { makeMediaProps } from '../composables/useProps'
|
||||
|
||||
const canvas = ref(null)
|
||||
|
||||
const props = defineProps(makeMediaProps())
|
||||
|
||||
useAVMedia(canvas, props)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<canvas ref="canvas"/>
|
||||
</template>
|
||||
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { makeWavefromProps } from '../composables/useProps'
|
||||
import { useAVWaveform } from '../composables/useAVWaveform'
|
||||
|
||||
const props = defineProps(makeWavefromProps())
|
||||
const emit = defineEmits(['showRecord'])
|
||||
const player = ref(null)
|
||||
const canvas = ref(null)
|
||||
|
||||
useAVWaveform(player, canvas, props, {
|
||||
options: { refetch: true },
|
||||
fetchOptions: { mode: 'no-cors', headers: {'Access-Control-Allow-Origin' : '*'} }
|
||||
})
|
||||
|
||||
function showRecordBtn() {
|
||||
emit('showRecord')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<canvas ref="canvas" />
|
||||
<div class="row items-center justify-center">
|
||||
<q-btn
|
||||
@click="showRecordBtn"
|
||||
color="secondary"
|
||||
size="md"
|
||||
label="Ghi âm lại"
|
||||
no-caps
|
||||
class="q-mr-md"/>
|
||||
<audio ref="player"
|
||||
:controls="props.audioControls"
|
||||
:src="props.src" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.record-status {
|
||||
border-radius: 12px;
|
||||
background-color: #4DB6AC;
|
||||
}
|
||||
</style>
|
||||
97
frontend/src/components/AudioVisual/composables/useAVBars.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { resolveUnref, useEventListener } from '@vueuse/core'
|
||||
import { useAudioContext } from '../composables/useAudioContext'
|
||||
|
||||
import {
|
||||
useCanvasContext,
|
||||
fillCanvasBackground,
|
||||
fillGradient
|
||||
} from '../composables/useCanvasContext'
|
||||
|
||||
import { Bars, type PropsBarsType } from '../composables/useProps'
|
||||
|
||||
// init with min value
|
||||
const caps: number[] = Array(16).fill(0)
|
||||
|
||||
export function useAVBars<T extends object>(
|
||||
player: Ref<HTMLAudioElement | null>,
|
||||
canvas: Ref<HTMLCanvasElement | null>,
|
||||
props: T
|
||||
){
|
||||
const p = new Bars(props as PropsBarsType)
|
||||
|
||||
caps.length = p.fftSize / 2
|
||||
caps.fill(0)
|
||||
|
||||
const ctx = useCanvasContext(canvas, p)
|
||||
useEventListener(player, 'loadedmetadata', () => {
|
||||
if (!p.placeholder || !ctx) return
|
||||
draw(new Uint8Array(p.fftSize / 2), ctx, props as PropsBarsType)
|
||||
})
|
||||
|
||||
useAudioContext(player, p.fftSize, (data: Uint8Array) => {
|
||||
draw(data, ctx, props as PropsBarsType)
|
||||
})
|
||||
}
|
||||
|
||||
export function draw(
|
||||
data: Uint8Array,
|
||||
canvas: Ref<CanvasRenderingContext2D | null>,
|
||||
props: PropsBarsType
|
||||
) {
|
||||
const ctx = resolveUnref( canvas )
|
||||
if ( !ctx ) return
|
||||
|
||||
const p = new Bars(props)
|
||||
const step = Math.round((p.barWidth + p.barSpace) / p.frqBits * p.canvWidth)
|
||||
const dataLen = data.length
|
||||
|
||||
let x = 0
|
||||
|
||||
fillCanvasBackground(ctx, p.canvWidth, p.canvHeight, p.canvFillColor)
|
||||
|
||||
for (let i = 0;i < dataLen;i++){
|
||||
if (i % step) continue
|
||||
const bits = Math.round(data.slice(i, i + step)
|
||||
.reduce((v, t) => t + v, 0) / step)
|
||||
const barHeight = bits / 255 * p.canvHeight
|
||||
|
||||
drawCaps(ctx, p, bits, i, x)
|
||||
|
||||
ctx.fillStyle = fillGradient(ctx, p.canvWidth, p.canvHeight, p.barColor)
|
||||
if (p.brickHeight > 0) {
|
||||
drawBarBricks(ctx, p, barHeight, x)
|
||||
} else {
|
||||
ctx.fillRect(x, p.canvHeight - barHeight - p.alignSym(barHeight), p.barWidth, barHeight)
|
||||
}
|
||||
x += p.barWidth + p.barSpace
|
||||
}
|
||||
}
|
||||
|
||||
function drawCaps(ctx: CanvasRenderingContext2D, p: Bars, bits: number, i: number, x: number){
|
||||
if (p.capsHeight === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const cap = caps[i] <= bits
|
||||
? bits
|
||||
: caps[i] - p.capsDropSpeed
|
||||
caps[i] = cap
|
||||
const y = (cap / 255.0 * p.canvHeight)
|
||||
const capY = p.canvHeight - y - p.capsHeight - p.alignSym(y)
|
||||
ctx.fillStyle = p.capsColor
|
||||
ctx.fillRect(x, capY, p.barWidth, p.capsHeight)
|
||||
|
||||
if (p.symmetric) {
|
||||
ctx.fillRect(x, p.canvHeight - capY - p.capsHeight, p.barWidth, p.capsHeight)
|
||||
}
|
||||
}
|
||||
|
||||
function drawBarBricks(ctx:CanvasRenderingContext2D, p: Bars, barHeight: number, x:number){
|
||||
for (let b = 0; b < barHeight; b += p.brickHeight + p.brickSpace) {
|
||||
ctx.fillRect(
|
||||
x, p.canvHeight - barHeight + b - p.alignSym(barHeight),
|
||||
p.barWidth, p.brickHeight
|
||||
)
|
||||
}
|
||||
}
|
||||
136
frontend/src/components/AudioVisual/composables/useAVCircle.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { resolveUnref, useEventListener } from '@vueuse/core'
|
||||
import { useAudioContext } from '../composables/useAudioContext'
|
||||
|
||||
import { useCanvasContext, fillCanvasBackground } from '../composables/useCanvasContext'
|
||||
import { Circle, type PropsCircleType } from '../composables/useProps'
|
||||
|
||||
export function useAVCircle<T extends object>(
|
||||
player: Ref<HTMLAudioElement | null>,
|
||||
canvas: Ref<HTMLCanvasElement | null>,
|
||||
props: T
|
||||
){
|
||||
const p = new Circle(props as PropsCircleType)
|
||||
|
||||
const ctx = useCanvasContext(canvas, p)
|
||||
useEventListener(player, 'loadedmetadata', () => {
|
||||
drawPlaceholder(ctx, p)
|
||||
})
|
||||
|
||||
useAudioContext(player, p.fftSize, (data: Uint8Array) => {
|
||||
draw(data, ctx, player, new Circle(props as PropsCircleType))
|
||||
})
|
||||
}
|
||||
|
||||
export function draw(
|
||||
data: Uint8Array,
|
||||
canvas: Ref<CanvasRenderingContext2D | null>,
|
||||
player: Ref<HTMLAudioElement | null>,
|
||||
p: Circle
|
||||
) {
|
||||
const ctx = resolveUnref( canvas )
|
||||
if ( !ctx ) return
|
||||
const audio = resolveUnref( player )
|
||||
if ( !audio ) return
|
||||
|
||||
const dataLen = data.length
|
||||
const step = ((p.lineWidth + p.lineSpace) / dataLen) * (2 * Math.PI)
|
||||
|
||||
fillCanvasBackground(ctx, p.canvWidth, p.canvHeight, p.canvFillColor)
|
||||
|
||||
drawOutline(ctx, p)
|
||||
|
||||
drawProgress(ctx, audio, p)
|
||||
|
||||
drawPlaytime(ctx, audio, p)
|
||||
|
||||
ctx.lineWidth = p.barWidth
|
||||
ctx.strokeStyle = setBarColor(ctx, p)
|
||||
|
||||
let angle = p.angle
|
||||
for (let i = 0; i < dataLen; i++) {
|
||||
angle += step
|
||||
if (i % p.arcStep) {
|
||||
continue
|
||||
}
|
||||
|
||||
const bits = Math.round(data.slice(i, i + p.arcStep)
|
||||
.reduce((v, t) => t + v, 0) / p.arcStep)
|
||||
|
||||
const blen = p.r + (bits / 255.0 * p.barLen)
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(p.r * Math.cos(angle) + p.cx, p.r * Math.sin(angle) + p.cy)
|
||||
ctx.lineTo(blen * Math.cos(angle) + p.cx, blen * Math.sin(angle) + p.cy)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlaceholder( canvas: Ref<CanvasRenderingContext2D | null>, p: Circle) {
|
||||
const ctx = resolveUnref( canvas )
|
||||
if ( !ctx ) return
|
||||
drawOutline(ctx, p)
|
||||
drawText(ctx, '0:00', p)
|
||||
}
|
||||
|
||||
function drawOutline(ctx: CanvasRenderingContext2D, p: Circle) {
|
||||
if (p.outlineWidth === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = p.outlineColor
|
||||
ctx.lineWidth = p.outlineWidth
|
||||
ctx.arc(p.cx, p.cy, p.r, 0, 2 * Math.PI)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawProgress(ctx: CanvasRenderingContext2D, audio: HTMLAudioElement, p: Circle){
|
||||
if (!p.progress) {
|
||||
return
|
||||
}
|
||||
const { currentTime, duration } = audio
|
||||
const elapsed = currentTime / duration * 2 * Math.PI
|
||||
const angleEnd = Math.PI * 1.5 + elapsed
|
||||
|
||||
if (!elapsed) return
|
||||
|
||||
ctx.lineWidth = p.progressWidth
|
||||
ctx.strokeStyle = p.progressColor
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.cx, p.cy, p.r - p.outlineWidth - p.outlineMeterSpace,
|
||||
1.5 * Math.PI, angleEnd, p.progressClockwise)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawPlaytime(ctx: CanvasRenderingContext2D, audio: HTMLAudioElement, p: Circle){
|
||||
const { currentTime } = audio
|
||||
|
||||
const m = Math.floor(currentTime / 60)
|
||||
const sec = Math.floor(currentTime) % 60
|
||||
const s = sec < 10 ? `0${sec}` : `${sec}`
|
||||
const text = `${m}:${s}`
|
||||
drawText(ctx, text, p)
|
||||
}
|
||||
|
||||
function drawText(ctx: CanvasRenderingContext2D, text: string, p: Circle) {
|
||||
ctx.font = p.playtimeFont
|
||||
ctx.fillStyle = p.playtimeColor
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(text, p.cx, p.cy + parseInt(p.playtimeFont) * 0.25)
|
||||
}
|
||||
|
||||
function setBarColor(ctx: CanvasRenderingContext2D, p: Circle){
|
||||
if (!Array.isArray(p.barColor)) {
|
||||
return p.barColor
|
||||
}
|
||||
const gradient = ctx.createRadialGradient(p.cx, p.cy, p.canvWidth / 2, p.cx, p.cy, 0)
|
||||
let offset = 0
|
||||
|
||||
p.barColor.forEach(color => {
|
||||
gradient.addColorStop(offset, color)
|
||||
offset += (1 / p.barColor.length)
|
||||
})
|
||||
return gradient
|
||||
}
|
||||
76
frontend/src/components/AudioVisual/composables/useAVLine.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { watch, type Ref } from 'vue'
|
||||
import { resolveUnref } from '@vueuse/core'
|
||||
import { useAudioContext } from '../composables/useAudioContext'
|
||||
|
||||
import {
|
||||
useCanvasContext,
|
||||
fillCanvasBackground,
|
||||
fillGradient
|
||||
} from '../composables/useCanvasContext'
|
||||
|
||||
import { Line, type PropsLineType } from '../composables/useProps'
|
||||
|
||||
export function useAVLine<T extends object>(
|
||||
player: Ref<HTMLAudioElement | null>,
|
||||
canvas: Ref<HTMLCanvasElement | null>,
|
||||
props: T
|
||||
) {
|
||||
const p = new Line(props as PropsLineType)
|
||||
const ctx = useCanvasContext(canvas, p)
|
||||
|
||||
watch(ctx, () => {
|
||||
if (!p.placeholder) return
|
||||
const canv = resolveUnref( ctx )
|
||||
if (!canv) return
|
||||
draw(new Uint8Array(p.fftSize), ctx, p)
|
||||
})
|
||||
|
||||
useAudioContext(player, p.fftSize, (data: Uint8Array) => {
|
||||
draw(data, ctx, new Line(props as PropsLineType))
|
||||
})
|
||||
}
|
||||
|
||||
export function draw(
|
||||
data: Uint8Array,
|
||||
canvas: Ref<CanvasRenderingContext2D | null>,
|
||||
props: Line
|
||||
) {
|
||||
const ctx = resolveUnref( canvas )
|
||||
if ( !ctx ) return
|
||||
|
||||
const w = props.canvWidth
|
||||
const h = props.canvHeight
|
||||
const lw = props.lineWidth
|
||||
|
||||
const dataLen = data.length
|
||||
const step = ~~w / 2.0 / dataLen
|
||||
let x = 0
|
||||
|
||||
const drawLine = (): number => {
|
||||
let y = 0
|
||||
for ( let i = 0; i < dataLen; i++ ) {
|
||||
// (h / 2) - v / 255 * (h / 2)
|
||||
const v = data[i]
|
||||
y = ( h * ( 255 - v )) / 510
|
||||
if ( i % 2 ) y = h - y
|
||||
ctx.lineTo( x, y )
|
||||
x += step
|
||||
}
|
||||
return x
|
||||
}
|
||||
|
||||
fillCanvasBackground(ctx, w, h, props.canvFillColor)
|
||||
|
||||
ctx.lineWidth = lw
|
||||
ctx.strokeStyle = fillGradient(ctx, w, h, props.lineColor)
|
||||
ctx.beginPath()
|
||||
|
||||
data.reverse()
|
||||
ctx.moveTo( x, h / 2 )
|
||||
x = drawLine()
|
||||
|
||||
data.reverse()
|
||||
drawLine()
|
||||
ctx.lineTo( w, h / 2 )
|
||||
ctx.stroke()
|
||||
}
|
||||
153
frontend/src/components/AudioVisual/composables/useAVMedia.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { resolveUnref, useRafFn } from '@vueuse/core'
|
||||
import { watchEffect, type Ref } from 'vue'
|
||||
import { useCanvasContext } from './useCanvasContext'
|
||||
import { type PropsMediaType, Media } from './useProps'
|
||||
|
||||
export function useAVMedia<T extends object>(
|
||||
canvas: Ref<HTMLCanvasElement | null>,
|
||||
props: T
|
||||
) {
|
||||
const p = props as PropsMediaType
|
||||
let analyser: AnalyserNode
|
||||
|
||||
const ctx = useCanvasContext(canvas, new Media(p))
|
||||
|
||||
const { pause, resume } = useRafFn(() => {
|
||||
if (!analyser) return
|
||||
draw(analyser, ctx, new Media(p))
|
||||
}, { immediate: false })
|
||||
|
||||
watchEffect(() => {
|
||||
const stream = resolveUnref(p.media)
|
||||
if (stream) {
|
||||
analyser = setAnalyser(stream as unknown as MediaStream, new Media(p))
|
||||
resume()
|
||||
} else {
|
||||
pause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setAnalyser(stream: MediaStream, p: Media): AnalyserNode {
|
||||
const ctx = new AudioContext()
|
||||
const analyser = ctx.createAnalyser()
|
||||
const src = ctx.createMediaStreamSource(stream)
|
||||
|
||||
src.connect(analyser)
|
||||
analyser.fftSize = p.fftSize
|
||||
|
||||
if (p.connectDestination) {
|
||||
analyser.connect(ctx.destination)
|
||||
}
|
||||
|
||||
return analyser
|
||||
}
|
||||
|
||||
export function draw(analyser: AnalyserNode, canv: Ref<CanvasRenderingContext2D | null>, p: Media) {
|
||||
const ctx = resolveUnref(canv)
|
||||
if (!ctx) return
|
||||
const data = new Uint8Array(analyser.fftSize)
|
||||
if (p.canvFillColor) ctx.fillStyle = p.canvFillColor
|
||||
ctx.clearRect(0, 0, p.canvWidth, p.canvHeight)
|
||||
ctx.beginPath()
|
||||
ctx.strokeStyle = p.lineColor
|
||||
|
||||
switch (p.type) {
|
||||
case 'frequ':
|
||||
analyser.getByteFrequencyData(data)
|
||||
drawFrequ(data, ctx, p)
|
||||
break
|
||||
case 'circle':
|
||||
analyser.getByteFrequencyData(data)
|
||||
drawCircle(data, ctx, p)
|
||||
break
|
||||
case 'vbar':
|
||||
analyser.getByteFrequencyData(data)
|
||||
drawVBar(data, ctx, p)
|
||||
break
|
||||
default: // wform
|
||||
analyser.getByteTimeDomainData(data)
|
||||
drawWForm(data, ctx, p)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
function drawFrequ(data: Uint8Array, ctx: CanvasRenderingContext2D, p: Media) {
|
||||
const middleOut = p.frequDirection === 'mo'
|
||||
const start = middleOut ? p.canvWidth / 2 : 0
|
||||
const c = middleOut ? p.frequLnum / 2 : p.frequLnum
|
||||
const step = middleOut ? p.canvWidth / c / 2 : p.canvWidth / c
|
||||
const h = p.canvHeight
|
||||
const lw = p.lineWidth || 2
|
||||
for (let i = 0; i < c; i++) {
|
||||
const x = middleOut ? i * step : i * step + lw
|
||||
const v = data.slice(x, x + step).reduce((sum, v) => sum + (v / 255.0 * h), 0) / step
|
||||
const space = (h - v) / 2 + 2 // + 2 is space for caps
|
||||
ctx.lineWidth = lw
|
||||
ctx.lineCap = p.frequLineCap ? 'round' : 'butt'
|
||||
ctx.moveTo(start + x, space)
|
||||
ctx.lineTo(start + x, h - space)
|
||||
ctx.stroke()
|
||||
|
||||
if (middleOut && i > 0) {
|
||||
ctx.moveTo(start - x, space)
|
||||
ctx.lineTo(start - x, h - space)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawVBar(data: Uint8Array, ctx: CanvasRenderingContext2D, p: Media) {
|
||||
const barWidth = p.vbarWidth
|
||||
const barSpace = p.vbarSpace
|
||||
const capSpace = barWidth < 5 ? 5 : barWidth / 2
|
||||
let max = 0
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
max = max < data[i] ? data[i] : max
|
||||
}
|
||||
const vbarLen = max / 255 * p.canvWidth
|
||||
ctx.lineWidth = p.vbarWidth
|
||||
ctx.lineCap = p.vbarCaps ? 'round' : 'butt'
|
||||
ctx.fillStyle = p.vbarBgColor
|
||||
ctx.fillRect(0, 0, p.canvWidth, p.canvHeight)
|
||||
for (let x = barWidth / 2; x + barWidth + barSpace <= p.canvWidth; x = x + barWidth + barSpace) {
|
||||
ctx.strokeStyle = x > vbarLen ? p.vbarRightColor : p.vbarFillColor
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(x, capSpace)
|
||||
ctx.lineTo(x, p.canvHeight - capSpace)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
function drawCircle(data: Uint8Array, ctx: CanvasRenderingContext2D, p: Media) {
|
||||
const cx = p.canvWidth / 2 // center X
|
||||
const cy = p.canvHeight / 2 // center Y
|
||||
const outr = cx < cy ? cx : cy
|
||||
|
||||
const max = Math.max(...data)
|
||||
const r = max / 255 * outr
|
||||
|
||||
const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, r)
|
||||
|
||||
for (const [offset, color] of p.circleGradient) {
|
||||
gradient.addColorStop(offset, color)
|
||||
}
|
||||
|
||||
ctx.fillStyle = gradient
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
||||
ctx.fill('evenodd')
|
||||
}
|
||||
|
||||
function drawWForm(data: Uint8Array, ctx: CanvasRenderingContext2D, p: Media) {
|
||||
const h = p.canvHeight
|
||||
const step = p.canvWidth / p.fftSize
|
||||
let x = 0
|
||||
ctx.lineWidth = p.lineWidth || 0.5
|
||||
for (let i = 0; i < data.length; i++){
|
||||
const v = data[i]
|
||||
const y = (v / 255.0) * h
|
||||
ctx.lineTo(x, y)
|
||||
x += step
|
||||
}
|
||||
ctx.stroke()
|
||||
}
|
||||
145
frontend/src/components/AudioVisual/composables/useAVWaveform.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
import { watchEffect, type Ref } from 'vue'
|
||||
import { useCanvasContext } from '../composables/useCanvasContext'
|
||||
import { Waveform, type PropsWaveformType } from '../composables/useProps'
|
||||
import { createFetch, resolveUnref, useEventListener, useRafFn, type CreateFetchOptions } from '@vueuse/core'
|
||||
|
||||
export function useAVWaveform<T extends object>(
|
||||
player: Ref<HTMLAudioElement | null>,
|
||||
canvas: Ref<HTMLCanvasElement | null>,
|
||||
props: T,
|
||||
fetchOpts: CreateFetchOptions = {}
|
||||
) {
|
||||
const p = new Waveform(props as PropsWaveformType)
|
||||
|
||||
const ctx = useCanvasContext(canvas, p)
|
||||
|
||||
fetchData(ctx, p, fetchOpts)
|
||||
|
||||
const { pause, resume } = useRafFn(() => {
|
||||
p.currentTime = player?.value?.currentTime ?? 0
|
||||
draw(ctx, p)
|
||||
}, { immediate: false })
|
||||
|
||||
useEventListener(player, 'play', resume)
|
||||
useEventListener(player, 'pause', pause)
|
||||
useEventListener(player, 'ended', () => {
|
||||
// this is a patch for weba file formats.
|
||||
// weba files when buffered with fetchData function return
|
||||
// wrong data duration which is longer then real duration.
|
||||
// So, when file is finished to play waveform still have empty
|
||||
// space in the end. This will try to fix it.
|
||||
const audio = resolveUnref(player)
|
||||
if (!audio || audio.duration === p.duration) return
|
||||
p.duration = audio.duration
|
||||
draw(ctx, p)
|
||||
})
|
||||
useEventListener(player, 'timeupdate', () => {
|
||||
const audio = resolveUnref(player)
|
||||
if (!audio) return
|
||||
p.currentTime = audio.currentTime
|
||||
draw(ctx, p)
|
||||
})
|
||||
useEventListener(canvas, 'click', (e: Event) => {
|
||||
if (!p.playtimeClickable) return
|
||||
const audio = resolveUnref(player)
|
||||
if (!audio) return
|
||||
audio.currentTime = (e as PointerEvent).offsetX / p.canvWidth * p.duration
|
||||
p.currentTime = audio.currentTime
|
||||
draw(ctx, p)
|
||||
})
|
||||
}
|
||||
|
||||
export function draw(canvas: Ref<CanvasRenderingContext2D | null>, p: Waveform) {
|
||||
const ctx = resolveUnref(canvas)
|
||||
if (!ctx) return
|
||||
let x = 0
|
||||
|
||||
ctx.clearRect(0, 0, p.canvWidth, p.canvHeight)
|
||||
|
||||
const waveform = (x: number, to: number, lw: number, color: string): number => {
|
||||
ctx.lineWidth = lw
|
||||
ctx.strokeStyle = color
|
||||
to = to > p.peaks.length ? p.peaks.length : to
|
||||
ctx.beginPath()
|
||||
for (; x < to; x++) {
|
||||
ctx.moveTo(x, p.peaks[x][0])
|
||||
ctx.lineTo(x, p.peaks[x][1])
|
||||
}
|
||||
ctx.stroke()
|
||||
return x
|
||||
}
|
||||
|
||||
x = waveform(x, p.playX, p.playedLineWidth, p.playedLineColor)
|
||||
waveform(x, p.peaks.length, p.noplayedLineWidth, p.noplayedLineColor)
|
||||
|
||||
drawSlider(ctx, p)
|
||||
|
||||
if (p.playtime) {
|
||||
drawTime(ctx, p)
|
||||
}
|
||||
}
|
||||
|
||||
function drawSlider(ctx: CanvasRenderingContext2D, p: Waveform) {
|
||||
ctx.lineWidth = p.playtimeSliderWidth
|
||||
ctx.strokeStyle = p.playtimeSliderColor
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(p.playX, 0)
|
||||
ctx.lineTo(p.playX, p.canvHeight)
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
function drawTime(ctx: CanvasRenderingContext2D, p: Waveform) {
|
||||
const time = p.timePlayed
|
||||
const offset = 3 // pixels
|
||||
const textWidth = ~~ctx.measureText(time).width
|
||||
const textX = p.playX > (p.canvWidth - textWidth - offset)
|
||||
? p.playX - textWidth - offset
|
||||
: p.playX + offset
|
||||
const textY = p.playtimeTextBottom
|
||||
? p.canvHeight - p.playtimeFontSize + offset
|
||||
: p.playtimeFontSize + offset
|
||||
ctx.fillStyle = p.playtimeFontColor
|
||||
ctx.font = `${p.playtimeFontSize}px ${p.playtimeFontFamily}`
|
||||
ctx.fillText(time, textX, textY)
|
||||
}
|
||||
|
||||
function fetchData(canv: Ref<CanvasRenderingContext2D | null>, p: Waveform, fetchOpts: CreateFetchOptions) {
|
||||
if (!p.src) return
|
||||
const localFetch = createFetch(fetchOpts)
|
||||
localFetch(p.src).arrayBuffer().then((res) => {
|
||||
const {error, data} = res
|
||||
const err = resolveUnref(error)
|
||||
if (err !== null) {
|
||||
console.error(`Failed get url '${p.src}': ${err}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (data.value === null) {
|
||||
console.error('invalid arrayBuffer data received')
|
||||
return
|
||||
}
|
||||
const ctx = new AudioContext()
|
||||
ctx.decodeAudioData(data.value).then(buff => {
|
||||
p.duration = buff.duration
|
||||
p.setPeaks(buff)
|
||||
draw(canv, p)
|
||||
}).catch(err => {
|
||||
console.error('Failed to decode audio array buffer:', err)
|
||||
})
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const ctx = resolveUnref(canv)
|
||||
if (!ctx) return
|
||||
ctx.lineWidth = p.noplayedLineWidth
|
||||
ctx.strokeStyle = p.noplayedLineColor
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(0, p.canvHeight / 2)
|
||||
ctx.lineTo(p.canvWidth, p.canvHeight / 2)
|
||||
ctx.stroke()
|
||||
drawSlider(ctx, p)
|
||||
if (p.playtime) {
|
||||
drawTime(ctx, p)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { resolveUnref, useEventListener, useRafFn } from '@vueuse/core'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
export function useAudioContext(
|
||||
player: Ref<HTMLAudioElement | null>,
|
||||
fft: number,
|
||||
cbFun: (data: Uint8Array) => void
|
||||
) {
|
||||
let ctx: AudioContext | null = null
|
||||
let analyser: AnalyserNode | null = null
|
||||
let src: MediaElementAudioSourceNode | null = null
|
||||
const fftSize = fft || 1024
|
||||
const data = new Uint8Array(fftSize / 2)
|
||||
|
||||
const { pause, resume } = useRafFn(() => {
|
||||
if (!analyser) return
|
||||
analyser.getByteFrequencyData(data)
|
||||
cbFun(data)
|
||||
}, { immediate: false })
|
||||
|
||||
useEventListener(player, 'play', async () => {
|
||||
const audio = resolveUnref(player)
|
||||
if (!audio) return
|
||||
audio.crossOrigin = 'anonymous';
|
||||
resume()
|
||||
if (!ctx) {
|
||||
ctx = new AudioContext()
|
||||
src = await ctx.createMediaElementSource(audio)
|
||||
}
|
||||
analyser = ctx.createAnalyser()
|
||||
analyser.fftSize = fftSize
|
||||
src?.connect(analyser)
|
||||
analyser.connect(ctx.destination)
|
||||
ctx.resume()
|
||||
})
|
||||
|
||||
useEventListener(player, 'pause', () => {
|
||||
ctx?.suspend()
|
||||
src?.disconnect()
|
||||
analyser?.disconnect()
|
||||
pause()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { resolveUnref } from '@vueuse/core'
|
||||
import { watchEffect, ref, type Ref } from 'vue'
|
||||
import type { Props } from '../composables/useProps'
|
||||
|
||||
export function useCanvasContext(
|
||||
canvas: Ref<HTMLCanvasElement | null>,
|
||||
props: Props
|
||||
): Ref<CanvasRenderingContext2D | null> {
|
||||
const ctx = ref<CanvasRenderingContext2D | null>(null)
|
||||
watchEffect(() => {
|
||||
const canv = resolveUnref(canvas)
|
||||
if (!canv) return
|
||||
|
||||
ctx.value = canv.getContext('2d')
|
||||
canv.width = props.canvWidth
|
||||
canv.height = props.canvHeight
|
||||
})
|
||||
return ctx
|
||||
}
|
||||
|
||||
type CanvColor = string[] | string | null | undefined
|
||||
|
||||
export function fillCanvasBackground(ctx: CanvasRenderingContext2D, w: number, h: number, colors: CanvColor): void {
|
||||
ctx.clearRect(0, 0, w, h)
|
||||
if (!colors) {
|
||||
return
|
||||
}
|
||||
ctx.fillStyle = fillGradient(ctx, w, h, colors)
|
||||
ctx.fillRect(0, 0, w, h)
|
||||
}
|
||||
|
||||
export function fillGradient(ctx: CanvasRenderingContext2D, w: number, h: number, colors: CanvColor): CanvasGradient | string {
|
||||
if (!Array.isArray(colors)) {
|
||||
return colors || ''
|
||||
}
|
||||
const gradient = ctx.createLinearGradient(w / 2, 0, w / 2, h)
|
||||
let offset = 0
|
||||
colors.forEach((color: string) => {
|
||||
gradient.addColorStop(offset, color)
|
||||
offset += 1 / colors.length
|
||||
})
|
||||
return gradient
|
||||
}
|
||||
139
frontend/src/components/AudioVisual/composables/useProps/Bars.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { resolvePropNum, resolvePropColor, resolvePropString, resolvePropBool } from './utils'
|
||||
import { commonProps } from './common'
|
||||
/**
|
||||
* props for AVBars component
|
||||
*/
|
||||
const barsProps = {
|
||||
/**
|
||||
* prop: 'bar-width'
|
||||
* Width of the bar in pixels.
|
||||
* Default: 5
|
||||
*/
|
||||
barWidth: {
|
||||
type: Number,
|
||||
default: 5
|
||||
},
|
||||
/**
|
||||
* prop: 'bar-space'
|
||||
* Space between bars.
|
||||
* Default: 1
|
||||
*/
|
||||
barSpace: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* prop: 'bar-color'
|
||||
* Bar fill color. Can be string RGB color or canvas gradients array.
|
||||
*/
|
||||
barColor: {
|
||||
type: [String, Array],
|
||||
default: '#0A0AFF'
|
||||
},
|
||||
/**
|
||||
* prop: 'caps-height'
|
||||
* Create caps on bars with given height in pixels.
|
||||
* If zero caps then skip creating bars.
|
||||
* Default: 0
|
||||
*/
|
||||
capsHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/**
|
||||
* prop: 'caps-drop-speed'
|
||||
* Caps drop down animation speed.
|
||||
* Default: 0.9
|
||||
*/
|
||||
capsDropSpeed: {
|
||||
type: Number,
|
||||
default: 0.9
|
||||
},
|
||||
/**
|
||||
* prop: 'caps-color'
|
||||
* Caps rectangles RGB color.
|
||||
*/
|
||||
capsColor: {
|
||||
type: String,
|
||||
default: '#A0A0FF'
|
||||
},
|
||||
/**
|
||||
* prop: 'brick-height'
|
||||
* Draw bar as bricks with set height.
|
||||
*/
|
||||
brickHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/**
|
||||
* prop: 'brick-space'
|
||||
* Space between bricks.
|
||||
*/
|
||||
brickSpace: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* prop: 'symmetric'
|
||||
* Draw bars symmetric to canvas vertical center
|
||||
* Default: false
|
||||
*/
|
||||
symmetric: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* prop: 'fft-size'
|
||||
* Represents the window size in samples that is used when performing
|
||||
* a Fast Fourier Transform (FFT) to get frequency domain data.
|
||||
* Must be power of 2 between 2^5 and 2^15
|
||||
* Default: 1024
|
||||
*/
|
||||
fftSize: {
|
||||
type: Number,
|
||||
default: 1024
|
||||
}
|
||||
}
|
||||
|
||||
export const PropsBars = { ...commonProps, ...barsProps }
|
||||
export type PropsBarsType = typeof PropsBars
|
||||
export function makeBarsProps(): PropsBarsType { return PropsBars }
|
||||
|
||||
export class Bars {
|
||||
barColor: string | string[]
|
||||
barSpace: number
|
||||
barWidth: number
|
||||
brickHeight: number
|
||||
brickSpace: number
|
||||
canvFillColor: string | string[]
|
||||
canvHeight: number
|
||||
canvWidth: number
|
||||
capsColor: string
|
||||
capsDropSpeed: number
|
||||
capsHeight: number
|
||||
fftSize: number
|
||||
frqBits: number
|
||||
placeholder: boolean
|
||||
symmetric: boolean
|
||||
constructor(p: PropsBarsType) {
|
||||
this.barColor = resolvePropColor(p.barColor, PropsBars.barColor.default)
|
||||
this.barSpace = resolvePropNum(p.barSpace, PropsBars.barSpace.default)
|
||||
this.brickHeight = resolvePropNum(p.brickHeight, PropsBars.brickHeight.default)
|
||||
this.brickSpace = resolvePropNum(p.brickSpace, PropsBars.brickSpace.default)
|
||||
this.canvFillColor = resolvePropColor(p.canvFillColor, PropsBars.canvFillColor.default)
|
||||
this.canvHeight = resolvePropNum(p.canvHeight, PropsBars.canvHeight.default)
|
||||
this.canvWidth = resolvePropNum(p.canvWidth, PropsBars.canvWidth.default)
|
||||
this.capsColor = resolvePropString(p.capsColor, PropsBars.capsColor.default)
|
||||
this.capsDropSpeed = resolvePropNum(p.capsDropSpeed, PropsBars.capsDropSpeed.default)
|
||||
this.capsHeight = resolvePropNum(p.capsHeight, PropsBars.capsHeight.default)
|
||||
this.fftSize = resolvePropNum(p.fftSize, PropsBars.fftSize.default)
|
||||
this.frqBits = this.fftSize >> 1 // same as div 2 in this case
|
||||
this.placeholder = resolvePropBool(p.placeholder, PropsBars.placeholder.default)
|
||||
this.symmetric = resolvePropBool(p.symmetric, PropsBars.symmetric.default)
|
||||
const bw = resolvePropNum(p.barWidth, PropsBars.barWidth.default)
|
||||
this.barWidth = bw > this.canvWidth ? this.canvWidth : bw
|
||||
}
|
||||
alignSym(barHeight: number):number {
|
||||
return this.symmetric ? ((this.canvHeight - barHeight) / 2) : 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
import { commonProps } from './common'
|
||||
import { resolvePropNum, resolvePropColor, resolvePropString, resolvePropBool } from './utils'
|
||||
|
||||
/**
|
||||
* props for AVCircle component
|
||||
*/
|
||||
const circleProps = {
|
||||
/**
|
||||
* prop: 'fft-size'
|
||||
* Represents the window size in samples that is used when performing
|
||||
* a Fast Fourier Transform (FFT) to get frequency domain data.
|
||||
* Must be power of 2 between 2^5 and 2^15
|
||||
* Default: 1024
|
||||
*/
|
||||
fftSize: {
|
||||
type: Number,
|
||||
default: 1024
|
||||
},
|
||||
/**
|
||||
* prop: 'canv-width'
|
||||
* Canvas element width. Default 100
|
||||
*/
|
||||
canvWidth: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
/**
|
||||
* prop: 'canv-height'
|
||||
* Canvas element height. Default 100
|
||||
*/
|
||||
canvHeight: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
/**
|
||||
* prop: 'radius'
|
||||
* Set circle radius. If zero will be calculated from canvas
|
||||
* width: (canv-width / 2) * 0.7
|
||||
* Default: 0
|
||||
*/
|
||||
radius: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/**
|
||||
* prop: 'line-width'
|
||||
* Frequency bit line width to draw.
|
||||
*/
|
||||
lineWidth: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* prop: 'line-space'
|
||||
* Space between lines to draw.
|
||||
*/
|
||||
lineSpace: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* prop: 'outline-color'
|
||||
* Outline (contour) style RGB color.
|
||||
* Default: #00f
|
||||
*/
|
||||
outlineColor: {
|
||||
type: String,
|
||||
default: '#0000FF'
|
||||
},
|
||||
/**
|
||||
* prop: 'outline-width'
|
||||
* Outline (contour) line width. Float value.
|
||||
* Default: 0.3
|
||||
*/
|
||||
outlineWidth: {
|
||||
type: Number,
|
||||
default: 0.3
|
||||
},
|
||||
/**
|
||||
* prop: 'bar-width'
|
||||
* Frequency graph bar width.
|
||||
*/
|
||||
barWidth: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* prop: 'bar-length'
|
||||
* Frequency graph bar length.
|
||||
* Default is a difference between radius and canvas width.
|
||||
*/
|
||||
barLength: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/**
|
||||
* prop: 'bar-color'
|
||||
* Bar style RGB color or radient gradient when array.
|
||||
* Default: [ #FFFFFF, #0000FF ]
|
||||
*/
|
||||
barColor: {
|
||||
type: [String, Array],
|
||||
default: ['#FFFFFF', '#0000FF']
|
||||
},
|
||||
/**
|
||||
* prop: 'progress'
|
||||
* Draw play progress meter.
|
||||
* Default: false
|
||||
*/
|
||||
progress: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'progress-width'
|
||||
* Progress meter width.
|
||||
* Default: 1
|
||||
*/
|
||||
progressWidth: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
/**
|
||||
* prop: 'progress-color'
|
||||
* Progress meter color.
|
||||
* Default: 1
|
||||
*/
|
||||
progressColor: {
|
||||
type: String,
|
||||
default: '#0000FF'
|
||||
},
|
||||
/**
|
||||
* prop: 'progress-clockwise'
|
||||
* Progress meter arc draw direction. Default clockwise
|
||||
* Default: true
|
||||
*/
|
||||
progressClockwise: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'outline-meter-space'
|
||||
* Space between outline and progress meter.
|
||||
* Default: 2
|
||||
*/
|
||||
outlineMeterSpace: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime'
|
||||
* Draw playtime text in the center of the circle.
|
||||
* Default: false
|
||||
*/
|
||||
playtime: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-font'
|
||||
* Played time print font.
|
||||
* Default: '14px Monaco'
|
||||
*/
|
||||
playtimeFont: {
|
||||
type: String,
|
||||
default: '14px Monaco'
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-color'
|
||||
* Played time font color.
|
||||
* Default: '#00f'
|
||||
*/
|
||||
playtimeColor: {
|
||||
type: String,
|
||||
default: '#00f'
|
||||
},
|
||||
/**
|
||||
* prop: 'rotate-graph'
|
||||
* Rotate graph clockwise enable.
|
||||
* Default: false
|
||||
*/
|
||||
rotateGraph: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* prop: 'rotate-speed'
|
||||
* Rotate graph speed.
|
||||
* Default: 0.001
|
||||
*/
|
||||
rotateSpeed: {
|
||||
type: Number,
|
||||
default: 0.001
|
||||
}
|
||||
}
|
||||
|
||||
export const PropsCircle = { ...commonProps, ...circleProps }
|
||||
export type PropsCircleType = typeof PropsCircle
|
||||
export function makeCircleProps(): PropsCircleType { return PropsCircle }
|
||||
|
||||
let rotate = 1.5
|
||||
|
||||
export class Circle {
|
||||
barColor: string | string[]
|
||||
barLength: number
|
||||
barWidth: number
|
||||
canvFillColor: string | string[]
|
||||
canvHeight: number
|
||||
canvWidth: number
|
||||
fftSize: number
|
||||
lineSpace: number
|
||||
lineWidth: number
|
||||
outlineColor: string
|
||||
outlineMeterSpace: number
|
||||
outlineWidth: number
|
||||
placeholder: boolean
|
||||
playtime: boolean
|
||||
playtimeColor: string
|
||||
playtimeFont: string
|
||||
progress: boolean
|
||||
progressClockwise: boolean
|
||||
progressColor: string
|
||||
progressWidth: number
|
||||
radius: number
|
||||
rotateGraph: boolean
|
||||
rotateSpeed: number
|
||||
constructor(p: PropsCircleType){
|
||||
const c = PropsCircle
|
||||
this.barColor = resolvePropColor(p.barColor, c.barColor.default)
|
||||
this.barLength = resolvePropNum(p.barLength, c.barLength.default)
|
||||
this.barWidth = resolvePropNum(p.barWidth, c.barWidth.default)
|
||||
this.canvFillColor = resolvePropColor(p.canvFillColor, c.canvFillColor.default)
|
||||
this.canvHeight = resolvePropNum(p.canvHeight, c.canvHeight.default)
|
||||
this.canvWidth = resolvePropNum(p.canvWidth, c.canvWidth.default)
|
||||
this.fftSize = resolvePropNum(p.fftSize, c.fftSize.default)
|
||||
this.lineSpace = resolvePropNum(p.lineSpace, c.lineSpace.default)
|
||||
this.lineWidth = resolvePropNum(p.lineWidth, c.lineWidth.default)
|
||||
this.outlineColor = resolvePropString(p.outlineColor, c.outlineColor.default)
|
||||
this.outlineMeterSpace = resolvePropNum(p.outlineMeterSpace, c.outlineMeterSpace.default)
|
||||
this.outlineWidth = resolvePropNum(p.outlineWidth, c.outlineWidth.default)
|
||||
this.lineWidth = resolvePropNum(p.lineWidth, c.lineWidth.default)
|
||||
this.placeholder = resolvePropBool(p.placeholder, c.placeholder.default)
|
||||
this.playtime = resolvePropBool(p.playtime, c.playtime.default)
|
||||
this.playtimeColor = resolvePropString(p.playtimeColor, c.playtimeColor.default)
|
||||
this.playtimeFont = resolvePropString(p.playtimeFont, c.playtimeFont.default)
|
||||
this.progress = resolvePropBool(p.progress, c.progress.default)
|
||||
this.progressClockwise = resolvePropBool(p.progressClockwise, c.progressClockwise.default)
|
||||
this.progressColor = resolvePropString(p.progressColor, c.progressColor.default)
|
||||
this.progressWidth = resolvePropNum(p.progressWidth, c.progressWidth.default)
|
||||
this.radius = resolvePropNum(p.radius, c.radius.default)
|
||||
this.rotateGraph = resolvePropBool(p.rotateGraph, c.rotateGraph.default)
|
||||
this.rotateSpeed = resolvePropNum(p.rotateSpeed, c.rotateSpeed.default)
|
||||
}
|
||||
get cx() { return this.canvWidth / 2 }
|
||||
get cy() { return this.canvHeight / 2 }
|
||||
get r() {
|
||||
return this.radius > 0
|
||||
? this.radius
|
||||
: Math.round(this.canvWidth / 2 * 0.7)
|
||||
}
|
||||
get arcStep() { return Math.ceil(this.lineWidth + this.lineSpace) }
|
||||
get barLen() {
|
||||
return this.barLength > 0
|
||||
? this.barLength
|
||||
: (this.canvWidth / 2) - this.r
|
||||
}
|
||||
|
||||
get angle() {
|
||||
const rot = (): number => {
|
||||
return rotate === 3.5
|
||||
? 1.5
|
||||
: rotate + this.rotateSpeed
|
||||
}
|
||||
|
||||
rotate = this.rotateGraph
|
||||
? rot()
|
||||
: 1.5
|
||||
|
||||
return Math.PI * rotate
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { resolvePropNum, resolvePropColor, resolvePropBool } from './utils'
|
||||
import { commonProps } from './common'
|
||||
|
||||
/**
|
||||
* props for AVLine component
|
||||
*/
|
||||
const lineProps = {
|
||||
/**
|
||||
* prop: 'line-width'
|
||||
* Draw line width in px
|
||||
*/
|
||||
lineWidth: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
/**
|
||||
* prop: 'line-color'
|
||||
* Draw line color or gradient array
|
||||
*/
|
||||
lineColor: {
|
||||
type: [String, Array],
|
||||
default: '#9F9'
|
||||
},
|
||||
/**
|
||||
* prop: 'fft-size'
|
||||
* Represents the window size in samples that is used when performing
|
||||
* a Fast Fourier Transform (FFT) to get frequency domain data.
|
||||
* Must be power of 2 between 2^5 and 2^15
|
||||
* Default: 128
|
||||
*/
|
||||
fftSize: {
|
||||
type: Number,
|
||||
default: 128
|
||||
}
|
||||
}
|
||||
|
||||
export const PropsLine = { ...commonProps, ...lineProps }
|
||||
export type PropsLineType = typeof PropsLine
|
||||
export function makeLineProps(): PropsLineType { return PropsLine }
|
||||
|
||||
export class Line{
|
||||
canvWidth: number
|
||||
canvHeight: number
|
||||
canvFillColor: string | string[]
|
||||
lineWidth: number
|
||||
lineColor: string | string[]
|
||||
fftSize: number
|
||||
placeholder: boolean
|
||||
constructor (p: PropsLineType) {
|
||||
const l = PropsLine
|
||||
this.canvWidth = resolvePropNum(p.canvWidth, l.canvWidth.default)
|
||||
this.canvHeight = resolvePropNum(p.canvHeight, l.canvHeight.default)
|
||||
this.canvFillColor = resolvePropColor(p.canvFillColor, l.canvFillColor.default)
|
||||
this.lineWidth = resolvePropNum(p.lineWidth, l.lineWidth.default)
|
||||
this.lineColor = resolvePropColor(p.lineColor, l.lineColor.default)
|
||||
this.fftSize = resolvePropNum(p.fftSize, l.fftSize.default)
|
||||
this.placeholder = resolvePropBool(p.placeholder, l.placeholder.default)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import { resolvePropNum, isUndef, resolvePropString, resolvePropBool } from './utils'
|
||||
|
||||
/**
|
||||
* props for AVMedia component
|
||||
*/
|
||||
const mediaProps = {
|
||||
/**
|
||||
* prop: 'media'
|
||||
* MediaStream object for visualisation. Can be delivered by
|
||||
* Web Audio API functions like getUserMedia or RTCPeerConnection
|
||||
*/
|
||||
media: {
|
||||
type: Object,
|
||||
required: false,
|
||||
default: null
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'canv-width'
|
||||
* Canvas element width. Default depends on type
|
||||
* vbar: 50, frequ: 300, wform: 200, circle: 80
|
||||
*/
|
||||
canvWidth: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'canv-class'
|
||||
* Canvas element css class name.
|
||||
*/
|
||||
canvClass: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'canv-height'
|
||||
* Canvas element height. Default value depends on type.
|
||||
* vbar: 20, frequ: 80, wform: 40, circle: 80
|
||||
*/
|
||||
canvHeight: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'canv-fill-color'
|
||||
* Canvas fill background RGB color.
|
||||
* Default is transparent.
|
||||
*/
|
||||
canvFillColor: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'circle-gradient'
|
||||
* Gradient array for circle type
|
||||
* Default: [[0, 'palegreen'], [0.3, 'lime'], [0.7, 'limegreen'], [1, 'green']]
|
||||
*/
|
||||
circleGradient: {
|
||||
type: Array, // <[number, string]>,
|
||||
default: [[0, 'palegreen'], [0.3, 'lime'], [0.7, 'limegreen'], [1, 'green']]
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'fft-size'
|
||||
* Represents the window size in samples that is used when performing
|
||||
* a Fast Fourier Transform (FFT) to get frequency domain data.
|
||||
* Must be power of 2 between 2^5 and 2^15
|
||||
* Default: 8192 for 'wform' 1024 for 'freq'
|
||||
*/
|
||||
fftSize: {
|
||||
type: Number
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'type'
|
||||
* Type of visualisation.
|
||||
* circle - circle form
|
||||
* frequ - using byte frequency data
|
||||
* wform - using byte time domaine data
|
||||
* vbar - vertical bar
|
||||
* wform when not recognized.
|
||||
* Default: wform
|
||||
*/
|
||||
type: {
|
||||
type: String,
|
||||
default: 'wform'
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'frequ-lnum'
|
||||
* Vertical lines number for frequ type.
|
||||
* Default: 60
|
||||
*/
|
||||
frequLnum: {
|
||||
type: Number,
|
||||
default: 60
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'frequ-line-cap'
|
||||
* Draw line with rounded end caps.
|
||||
* Default: false
|
||||
*/
|
||||
frequLineCap: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'frequ-direction'
|
||||
* Direction for frequency visualization.
|
||||
* lr - from left to right
|
||||
* mo - from middle out
|
||||
* lr when not recognized.
|
||||
* Default: lr
|
||||
*/
|
||||
frequDirection: {
|
||||
type: String,
|
||||
default: 'lr'
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'line-color'
|
||||
* Line color.
|
||||
* Default: lime
|
||||
*/
|
||||
lineColor: {
|
||||
type: String,
|
||||
default: 'lime'
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'line-width'
|
||||
* Line width.
|
||||
* Default: 0.5 for wform and 3 for frequ
|
||||
*/
|
||||
lineWidth: {
|
||||
type: Number
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'radius'
|
||||
* Circle radius.
|
||||
* Default: 4 for circle
|
||||
*/
|
||||
radius: {
|
||||
type: Number,
|
||||
default: 4
|
||||
},
|
||||
|
||||
/**
|
||||
* prop: 'connect-destination'
|
||||
* Analyser to connect to audio context's destination
|
||||
* Default: false
|
||||
*/
|
||||
connectDestination: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
|
||||
/**
|
||||
* 'prop': 'vbar-bg-color'
|
||||
* Background canvas color for 'vbar' type
|
||||
* Default: '#e1e1e1'
|
||||
*/
|
||||
vbarBgColor: {
|
||||
type: String,
|
||||
default: '#e1e1e1'
|
||||
},
|
||||
|
||||
/**
|
||||
* 'prop': 'vbar-caps'
|
||||
* Rounded bars for 'vbar' types
|
||||
* Default: true
|
||||
*/
|
||||
vbarCaps: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
|
||||
/**
|
||||
* 'prop': 'vbar-space'
|
||||
* Space between bars in 'vbar' type
|
||||
* Default: 1
|
||||
*/
|
||||
vbarSpace: {
|
||||
type: Number,
|
||||
default: 1
|
||||
},
|
||||
|
||||
/**
|
||||
* 'prop': 'vbar-width'
|
||||
* Width of bars in 'vbar' type
|
||||
* Default: 4
|
||||
*/
|
||||
vbarWidth: {
|
||||
type: Number,
|
||||
default: 4
|
||||
},
|
||||
|
||||
/**
|
||||
* 'prop': 'vbar-fill-color'
|
||||
* Color of bars in 'vbar' type
|
||||
* Default: 'lime'
|
||||
*/
|
||||
vbarFillColor: {
|
||||
type: String,
|
||||
default: 'lime'
|
||||
},
|
||||
|
||||
/**
|
||||
* 'prop': 'vbar-right-color'
|
||||
* Color of bars on right side in 'vbar' type
|
||||
* Default: '#c0c0c0'
|
||||
*/
|
||||
vbarRightColor: {
|
||||
type: String,
|
||||
default: '#c0c0c0'
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
export const PropsMedia = { ...mediaProps }
|
||||
export type PropsMediaType = typeof PropsMedia
|
||||
export function makeMediaProps(): PropsMediaType { return PropsMedia }
|
||||
|
||||
type GradientType = Array<[number, string]>
|
||||
export class Media {
|
||||
canvWidth: number
|
||||
canvHeight: number
|
||||
canvFillColor: string
|
||||
canvClass: string
|
||||
circleGradient: GradientType
|
||||
fftSize: number
|
||||
type: string
|
||||
frequLnum: number
|
||||
frequLineCap: boolean
|
||||
frequDirection: string
|
||||
lineColor: string
|
||||
lineWidth: number
|
||||
radius: number
|
||||
connectDestination: boolean
|
||||
vbarBgColor: string
|
||||
vbarCaps: boolean
|
||||
vbarFillColor: string
|
||||
vbarRightColor: string
|
||||
vbarSpace: number
|
||||
vbarWidth: number
|
||||
|
||||
constructor(p: PropsMediaType){
|
||||
const m = PropsMedia
|
||||
|
||||
this.canvFillColor = resolvePropString(p.canvFillColor, m.canvFillColor.default)
|
||||
this.canvClass = resolvePropString(p.canvClass, m.canvClass.default)
|
||||
this.circleGradient = isUndef(p.circleGradient)
|
||||
? m.circleGradient.default as GradientType
|
||||
: p.circleGradient as unknown as GradientType
|
||||
this.type = resolvePropString(p.type, m.type.default)
|
||||
this.fftSize = isUndef(p.fftSize)
|
||||
? this.type === 'frequ' ? 1024 : 8192
|
||||
: Number(p.fftSize)
|
||||
this.frequLnum = resolvePropNum(p.frequLnum, m.frequLnum.default)
|
||||
this.frequLineCap = resolvePropBool(p.frequLineCap, m.frequLineCap.default)
|
||||
this.frequDirection = resolvePropString(p.frequDirection, m.frequDirection.default)
|
||||
this.lineColor = resolvePropString(p.lineColor, m.lineColor.default)
|
||||
this.lineWidth = isUndef(p.lineWidth)
|
||||
? this.type === 'frequ' ? 3 : 0.5
|
||||
: Number(p.lineWidth)
|
||||
this.radius = resolvePropNum(p.radius, m.radius.default)
|
||||
this.connectDestination = resolvePropBool(p.connectDestination, m.connectDestination.default)
|
||||
this.vbarBgColor = resolvePropString(p.vbarBgColor, m.vbarBgColor.default)
|
||||
this.vbarCaps = resolvePropBool(p.vbarCaps, m.vbarCaps.default)
|
||||
this.vbarFillColor = resolvePropString(p.vbarFillColor, m.vbarFillColor.default)
|
||||
this.vbarRightColor = resolvePropString(p.vbarRightColor, m.vbarRightColor.default)
|
||||
this.vbarSpace = resolvePropNum(p.vbarSpace, m.vbarSpace.default)
|
||||
this.vbarWidth = resolvePropNum(p.vbarWidth, m.vbarWidth.default)
|
||||
|
||||
this.canvWidth = isUndef(p.canvWidth) || Number(p.canvWidth) === 0
|
||||
? this.defaultWidth
|
||||
: Number(p.canvWidth)
|
||||
this.canvHeight = isUndef(p.canvHeight) || Number(p.canvHeight) === 0
|
||||
? this.defaultHeight
|
||||
: Number(p.canvHeight)
|
||||
}
|
||||
// vbar w: 50, h: 20; frequ w: 300, h: 80; wform w: 200, h: 40; circle w: 80, h: 80
|
||||
get defaultWidth(): number {
|
||||
switch (this.type) {
|
||||
case 'vbar': return 50
|
||||
case 'frequ': return 300
|
||||
case 'circle': return 80
|
||||
default: return 200 // wform is default
|
||||
}
|
||||
}
|
||||
get defaultHeight(): number {
|
||||
switch (this.type) {
|
||||
case 'vbar': return 20
|
||||
case 'frequ': return 80
|
||||
case 'circle': return 80
|
||||
default: return 40 // wform is default
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { commonProps } from './common'
|
||||
import { resolvePropNum, resolvePropColor, isUndef, resolvePropString, resolvePropBool } from './utils'
|
||||
|
||||
/**
|
||||
* props for AVWaveform component
|
||||
*/
|
||||
const waveformProps = {
|
||||
/**
|
||||
* prop: 'canv-width'
|
||||
* Canvas element width. Default 500
|
||||
*/
|
||||
canvWidth: {
|
||||
type: Number,
|
||||
default: 500
|
||||
},
|
||||
/**
|
||||
* prop: 'canv-height'
|
||||
* Canvas element height. Default 80
|
||||
*/
|
||||
canvHeight: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
/**
|
||||
* prop: 'played-line-width'
|
||||
* Waveform line width for played segment of audio
|
||||
* Default: 0.5
|
||||
*/
|
||||
playedLineWidth: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
/**
|
||||
* prop: 'played-line-color'
|
||||
* Waveform line color for played segment of audio
|
||||
* Default: navy
|
||||
*/
|
||||
playedLineColor: {
|
||||
type: String,
|
||||
default: 'navy'
|
||||
},
|
||||
/**
|
||||
* prop: 'noplayed-line-width'
|
||||
* Waveform line width for not yet played segment of audio
|
||||
* Default: 0.5
|
||||
*/
|
||||
noplayedLineWidth: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
/**
|
||||
* prop: 'noplayed-line-color'
|
||||
* Waveform line color for not yet played segment of audio
|
||||
* Default: lime
|
||||
*/
|
||||
noplayedLineColor: {
|
||||
type: String,
|
||||
default: 'lime'
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime'
|
||||
* Display played time next to progress slider.
|
||||
* Default: true
|
||||
*/
|
||||
playtime: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-with-ms'
|
||||
* Display milliseconds in played when true.
|
||||
* For example: 02:55.054
|
||||
* Default: true
|
||||
*/
|
||||
playtimeWithMs: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-font-size'
|
||||
* Played time print font size in pixels.
|
||||
* Default: 12
|
||||
*/
|
||||
playtimeFontSize: {
|
||||
type: Number,
|
||||
default: 12
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-font-family'
|
||||
* Played time print font family.
|
||||
* Default: monospace
|
||||
*/
|
||||
playtimeFontFamily: {
|
||||
type: String,
|
||||
default: 'monospace'
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-font-color'
|
||||
* Played time print font RGB color string.
|
||||
* Default: grey
|
||||
*/
|
||||
playtimeFontColor: {
|
||||
type: String,
|
||||
default: 'grey'
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-text-bottom'
|
||||
* Position playtime text bottom.
|
||||
* Default on top.
|
||||
* Default: false
|
||||
*/
|
||||
playtimeTextBottom: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-slider'
|
||||
* Draw played slider
|
||||
* Default: true
|
||||
*/
|
||||
playtimeSlider: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-slider-color'
|
||||
* Played slider color
|
||||
* Default: red
|
||||
*/
|
||||
playtimeSliderColor: {
|
||||
type: String,
|
||||
default: 'red'
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-slider-width'
|
||||
* Played slider width
|
||||
* Default: 1
|
||||
*/
|
||||
playtimeSliderWidth: {
|
||||
type: Number,
|
||||
default: 3
|
||||
},
|
||||
/**
|
||||
* prop: 'playtime-clickable'
|
||||
* Allow click on waveform to change playtime.
|
||||
* Default: true
|
||||
*/
|
||||
playtimeClickable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'requester'
|
||||
* Allow set a custom requester (axios/fetch) to be used.
|
||||
* Default: new fetch instance
|
||||
*/
|
||||
requester: {
|
||||
type: Function,
|
||||
default: fetch
|
||||
}
|
||||
}
|
||||
|
||||
export const PropsWaveform = { ...commonProps, ...waveformProps }
|
||||
export type PropsWaveformType = typeof PropsWaveform
|
||||
export function makeWavefromProps(): PropsWaveformType { return PropsWaveform }
|
||||
|
||||
export class Waveform {
|
||||
src: string | null
|
||||
canvWidth: number
|
||||
canvHeight: number
|
||||
canvFillColor: string | string[]
|
||||
currentTime: number
|
||||
duration: number
|
||||
playedLineWidth: number
|
||||
playedLineColor: string
|
||||
noplayedLineWidth: number
|
||||
noplayedLineColor: string
|
||||
playtime: boolean
|
||||
playtimeWithMs: boolean
|
||||
playtimeFontSize: number
|
||||
playtimeFontFamily: string
|
||||
playtimeFontColor: string
|
||||
playtimeTextBottom: boolean
|
||||
playtimeSlider: boolean
|
||||
playtimeSliderColor: string
|
||||
playtimeSliderWidth: number
|
||||
playtimeClickable: boolean
|
||||
peaks: [number, number][] = []
|
||||
constructor(p: PropsWaveformType) {
|
||||
const w = PropsWaveform
|
||||
this.canvWidth = resolvePropNum(p.canvWidth, w.canvWidth.default)
|
||||
this.canvHeight = resolvePropNum(p.canvHeight, w.canvHeight.default)
|
||||
this.canvFillColor = resolvePropColor(p.canvFillColor, w.canvFillColor.default)
|
||||
this.playedLineWidth = resolvePropNum(p.playedLineWidth, w.playedLineWidth.default)
|
||||
this.playedLineColor = resolvePropString(p.playedLineColor, w.playedLineColor.default)
|
||||
this.noplayedLineWidth = resolvePropNum(p.noplayedLineWidth, w.noplayedLineWidth.default)
|
||||
this.noplayedLineColor = resolvePropString(p.noplayedLineColor, w.noplayedLineColor.default)
|
||||
this.playtime = resolvePropBool(p.playtime, w.playtime.default)
|
||||
this.playtimeWithMs = resolvePropBool(p.playtimeWithMs, w.playtimeWithMs.default)
|
||||
this.playtimeFontSize = resolvePropNum(p.playtimeFontSize, w.playtimeFontSize.default)
|
||||
this.playtimeFontFamily = resolvePropString(p.playtimeFontFamily, w.playtimeFontFamily.default)
|
||||
this.playtimeFontColor = resolvePropString(p.playtimeFontColor, w.playtimeFontColor.default)
|
||||
this.playtimeTextBottom = resolvePropBool(p.playtimeTextBottom, w.playtimeTextBottom.default)
|
||||
this.playtimeSlider = resolvePropBool(p.playtimeSlider, w.playtimeSlider.default)
|
||||
this.playtimeSliderColor = resolvePropString(p.playtimeSliderColor, w.playtimeSliderColor.default)
|
||||
this.playtimeSliderWidth = resolvePropNum(p.playtimeSliderWidth, w.playtimeSliderWidth.default)
|
||||
this.playtimeClickable = resolvePropBool(p.playtimeClickable, w.playtimeClickable.default)
|
||||
this.src = isUndef(p.src) ? null : String(p.src)
|
||||
this.currentTime = 0
|
||||
this.duration = 0
|
||||
}
|
||||
get playX(): number {
|
||||
if (!this.duration) return 0
|
||||
const x = ~~(this.currentTime / this.duration * this.canvWidth)
|
||||
return x > this.canvWidth ? this.canvWidth : x
|
||||
}
|
||||
get timePlayed(): string {
|
||||
const time = [
|
||||
this.currentTime / 3600,
|
||||
this.currentTime / 60 % 60,
|
||||
this.currentTime % 60
|
||||
].
|
||||
map(v => String(~~v).padStart(2, '0')).
|
||||
join(':')
|
||||
|
||||
if (!this.playtimeWithMs)
|
||||
return time
|
||||
const ms = ~~(this.currentTime % 1 * 1000)
|
||||
return [time, String(ms).padStart(3, '0')].join('.')
|
||||
}
|
||||
|
||||
setPeaks(buffer: AudioBuffer) {
|
||||
this.peaks.slice(0)
|
||||
let min = 0
|
||||
let max = 0
|
||||
let top = 0
|
||||
let bottom = 0
|
||||
const segSize = Math.ceil(buffer.length / this.canvWidth)
|
||||
const width = this.canvWidth
|
||||
const height = this.canvHeight
|
||||
|
||||
for (let c = 0; c < buffer.numberOfChannels; c++) {
|
||||
const data = buffer.getChannelData(c)
|
||||
for (let s = 0; s < width; s++) {
|
||||
const start = ~~(s * segSize)
|
||||
const end = ~~(start + segSize)
|
||||
min = 0
|
||||
max = 0
|
||||
for (let i = start; i < end; i++) {
|
||||
min = data[i] < min ? data[i] : min
|
||||
max = data[i] > max ? data[i] : max
|
||||
}
|
||||
// merge multi channel data
|
||||
if (this.peaks[s]) {
|
||||
this.peaks[s][0] = this.peaks[s][0] < max ? max : this.peaks[s][0]
|
||||
this.peaks[s][1] = this.peaks[s][1] > min ? min : this.peaks[s][1]
|
||||
}
|
||||
this.peaks[s] = [max, min]
|
||||
}
|
||||
}
|
||||
// set peaks relativelly to canvas dimensions
|
||||
for (let i = 0; i < this.peaks.length; i++) {
|
||||
max = this.peaks[i][0]
|
||||
min = this.peaks[i][1]
|
||||
top = ((height / 2) - (max * height / 2))
|
||||
bottom = ((height / 2) - (min * height / 2))
|
||||
this.peaks[i] = [top, bottom === top ? top + 1 : bottom]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Common properties for all components
|
||||
*/
|
||||
export const commonProps = {
|
||||
/**
|
||||
* prop: 'src'
|
||||
* Audio element src attribute. When provided creates audio element
|
||||
*/
|
||||
src: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
/**
|
||||
* prop: 'audio-controls'
|
||||
* Audio element controls attribute. When provided should
|
||||
* display audio element with controls
|
||||
*/
|
||||
audioControls: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* prop: 'cors-anonym'
|
||||
* CORS requests for this element will not have the credentials flag set.
|
||||
* Set crossOrigin property of audio element to 'anonymous'.
|
||||
* Default: null
|
||||
*/
|
||||
corsAnonym: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* prop: 'canv-width'
|
||||
* Canvas element width. Default 300
|
||||
*/
|
||||
canvWidth: {
|
||||
type: Number,
|
||||
default: 300
|
||||
},
|
||||
/**
|
||||
* prop: 'canv-height'
|
||||
* Canvas element height. Default 80
|
||||
*/
|
||||
canvHeight: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
/**
|
||||
* prop: 'canv-fill-color'
|
||||
* Canvas fill background color. Can be string RGB color or canvas gradients array.
|
||||
* Default is transparent.
|
||||
*/
|
||||
canvFillColor: {
|
||||
type: [String, Array],
|
||||
default: ''
|
||||
},
|
||||
/**
|
||||
* prop: 'placeholder'
|
||||
* Draw initial state of visualization. Like line in the middle for line plugin.
|
||||
* Default is true
|
||||
*/
|
||||
placeholder: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export * from './utils'
|
||||
export * from './Bars'
|
||||
export * from './Line'
|
||||
export * from './Circle'
|
||||
export * from './Waveform'
|
||||
export * from './Media'
|
||||
|
||||
export interface Props {
|
||||
canvWidth: number
|
||||
canvHeight: number
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { resolveUnref, type MaybeRef } from '@vueuse/core'
|
||||
|
||||
export const isUndef = (v: string | string[] | number | boolean | object | undefined): boolean => v === undefined
|
||||
|
||||
export function resolvePropNum(
|
||||
val: MaybeRef<{type: NumberConstructor; default: number}>,
|
||||
defVal: number
|
||||
): number {
|
||||
const realVal = resolveUnref(val)
|
||||
return isUndef(realVal) ? defVal : Number(realVal)
|
||||
}
|
||||
|
||||
export function resolvePropColor(
|
||||
val: MaybeRef<{type: (StringConstructor | ArrayConstructor)[]}>,
|
||||
def: string | string[]
|
||||
): string | string[] {
|
||||
const color = resolveUnref(val)
|
||||
if (Array.isArray(color)) {
|
||||
return color
|
||||
}
|
||||
|
||||
return color ? String(color) : def
|
||||
}
|
||||
|
||||
export function resolvePropString(
|
||||
val: MaybeRef<{type: StringConstructor}>,
|
||||
def: string
|
||||
): string {
|
||||
const v = resolveUnref(val)
|
||||
return isUndef(v) ? def : String(v)
|
||||
}
|
||||
|
||||
export function resolvePropBool(
|
||||
val: MaybeRef<{type: BooleanConstructor}>,
|
||||
def: boolean
|
||||
): boolean {
|
||||
const v = resolveUnref(val)
|
||||
return isUndef(v) ? def : Boolean(v)
|
||||
}
|
||||
38
frontend/src/components/ElementMixin.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import RecorderMixin from './RecorderMixin'
|
||||
import SafariRecorderMixin from './SafariRecorderMixin'
|
||||
|
||||
const mixins = [RecorderMixin];
|
||||
|
||||
if (!window.MediaRecorder) {
|
||||
console.warn('Using Safari polyfill');
|
||||
mixins.push(SafariRecorderMixin)
|
||||
}
|
||||
|
||||
/**
|
||||
* The element mixin defines the mode behaviour and creates two
|
||||
* functions to start and stop the recording execution
|
||||
*/
|
||||
export default {
|
||||
mixins: mixins,
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: 'hold',
|
||||
validator: v => ['hold', 'press'].includes(v)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
stopRecording () {
|
||||
if (this.mode === 'press') {
|
||||
return
|
||||
}
|
||||
return this.stop()
|
||||
},
|
||||
startRecording () {
|
||||
if (this.isRecording && this.mode === 'press') {
|
||||
return this.stop()
|
||||
}
|
||||
return this.start()
|
||||
}
|
||||
}
|
||||
}
|
||||
75
frontend/src/components/Lang/LanguageList.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<q-card>
|
||||
<q-card-section>
|
||||
<div class="text-h6">
|
||||
{{ $t('languages') }}
|
||||
</div>
|
||||
</q-card-section>
|
||||
|
||||
<q-card-section>
|
||||
<q-list separator>
|
||||
<q-item
|
||||
v-for="(langOption, i) in langOptions"
|
||||
:key="`lang_${i}`"
|
||||
:active="langOption.value === lang"
|
||||
color="yellow"
|
||||
clickable
|
||||
@click="onLang(langOption.value)"
|
||||
>
|
||||
<q-item-section>
|
||||
<q-item-label>
|
||||
{{ langOption.label }}
|
||||
</q-item-label>
|
||||
</q-item-section>
|
||||
<q-item-section
|
||||
side
|
||||
top
|
||||
>
|
||||
<div class="column q-gutter-sm">
|
||||
<q-badge
|
||||
v-if="langOption.value === savedLang"
|
||||
color="secondary"
|
||||
label="Startup Cookie"
|
||||
/>
|
||||
<q-badge
|
||||
v-if="langOption.value === browserLang.toLowerCase()"
|
||||
color="secondary"
|
||||
label="Browser"
|
||||
/>
|
||||
<q-badge
|
||||
v-if="langOption.value === 'ru'"
|
||||
color="warning"
|
||||
label="Partial"
|
||||
/>
|
||||
</div>
|
||||
</q-item-section>
|
||||
</q-item>
|
||||
</q-list>
|
||||
</q-card-section>
|
||||
</q-card>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LanguageSelectMixin from './LanguageSelectMixin'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
LanguageSelectMixin
|
||||
],
|
||||
data () {
|
||||
return {
|
||||
langOptions: []
|
||||
}
|
||||
},
|
||||
created () {
|
||||
this.langOptions = this.appLanguages && this.appLanguages.map(langObj => ({
|
||||
label: langObj.nativeName, value: langObj.isoName
|
||||
}))
|
||||
},
|
||||
methods: {
|
||||
onLang (lang) {
|
||||
this.lang = lang
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
51
frontend/src/components/Lang/LanguageSelect.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<q-select
|
||||
v-if="true"
|
||||
|
||||
v-model="lang"
|
||||
v-bind="{options, dark, filled, outlined, borderless, standout,
|
||||
hideBottomSpace, rounded, square, dense, itemAligned}"
|
||||
:options-dark="true"
|
||||
aria-label="Language"
|
||||
emit-value
|
||||
map-options
|
||||
bg-color="rgb(36, 41, 46)"
|
||||
>
|
||||
<template v-slot:prepend>
|
||||
<q-icon name="language" />
|
||||
</template>
|
||||
</q-select>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LanguageSelectMixin from './LanguageSelectMixin.vue'
|
||||
|
||||
export default {
|
||||
mixins: [
|
||||
LanguageSelectMixin
|
||||
],
|
||||
props: {
|
||||
dark: Boolean,
|
||||
filled: Boolean,
|
||||
outlined: Boolean,
|
||||
borderless: Boolean,
|
||||
standout: Boolean,
|
||||
hideBottomSpace: Boolean,
|
||||
rounded: Boolean,
|
||||
square: Boolean,
|
||||
dense: Boolean,
|
||||
itemAligned: Boolean
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
options: []
|
||||
}
|
||||
},
|
||||
created () {
|
||||
// populate language selector
|
||||
this.options = this.appLanguages && this.appLanguages.map(langObj => ({
|
||||
label: langObj.nativeName, value: langObj.isoName
|
||||
}))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
105
frontend/src/components/Lang/LanguageSelectMixin.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script>
|
||||
import languages from 'quasar/lang/index.json'
|
||||
|
||||
const LANG_COOKIE = 'lang'
|
||||
const LANG_AVAILABLE = [
|
||||
'en-US', 'vi'
|
||||
]
|
||||
|
||||
|
||||
// Note that the order of languages is set according to index.json
|
||||
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
appLanguages: languages.filter(lang =>
|
||||
LANG_AVAILABLE.includes(lang.isoName)
|
||||
)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
appLanguageNames () {
|
||||
return this.appLanguages && this.appLanguages.map(lang => lang.isoName)
|
||||
},
|
||||
isLangValid () {
|
||||
return this.isLangSupported(this.lang)
|
||||
},
|
||||
savedLang () {
|
||||
// last selected language in the app
|
||||
return this.$q.cookies && this.$q.cookies.get(LANG_COOKIE)
|
||||
},
|
||||
browserLang () {
|
||||
// user's language preferences in the browser
|
||||
return navigator && navigator.language
|
||||
},
|
||||
lang: {
|
||||
get () {
|
||||
// project's language
|
||||
// initially set from framework.lang field in quasar.conf.jslog
|
||||
return this.$q.lang.isoName
|
||||
},
|
||||
set (val) {
|
||||
this.setLang(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
lang (val) {
|
||||
// update when language is modified from outside
|
||||
this.setLang(val)
|
||||
}
|
||||
},
|
||||
created () {
|
||||
if (!this.$q.cookies) {
|
||||
console.warn('Consider enabling the Cookies plugin for language selection persistency')
|
||||
}
|
||||
this.setInitialLang()
|
||||
},
|
||||
methods: {
|
||||
isLangSupported (lang) {
|
||||
console.log(`Checking if language ${lang} is supported`);
|
||||
|
||||
return lang && this.appLanguageNames &&
|
||||
this.appLanguageNames.includes(lang.toLowerCase())
|
||||
},
|
||||
setInitialLang () {
|
||||
// find first supported language by candidate's priority
|
||||
const candidateLangs = [
|
||||
this.savedLang, // (must be first)
|
||||
this.browserLang,
|
||||
this.lang
|
||||
]
|
||||
this.lang = candidateLangs.find(this.isLangSupported)
|
||||
},
|
||||
setLang (lang) {
|
||||
// dynamic import, so loading on demand only
|
||||
lang = lang?.toLowerCase() || this.lang
|
||||
import(
|
||||
// Note: you must update the magic comment here with all supported
|
||||
// languages defined in i18n.config.js, in order for them to load
|
||||
// Reference: https://webpack.js.org/api/module-methods/#magic-comments
|
||||
|
||||
/* webpackInclude: /(he|en-us|ru)\.js$/ */
|
||||
`quasar/lang/${lang}`
|
||||
)
|
||||
.then(langObj => {
|
||||
this.$q.lang.set(langObj.default)
|
||||
this.$i18n.locale = langObj.default.isoName
|
||||
this.$q.cookies &&
|
||||
this.$q.cookies.set(LANG_COOKIE, langObj.default.isoName)
|
||||
})
|
||||
.catch(error => {
|
||||
console.error(`Failed to load language ${lang}`)
|
||||
console.dir(error)
|
||||
if (this.appLanguages) {
|
||||
// set to first available language
|
||||
const preferred = this.appLanguages[0].isoName
|
||||
if (lang !== preferred) {
|
||||
this.setLang(preferred)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
129
frontend/src/components/RecorderMixin.js
Normal file
@@ -0,0 +1,129 @@
|
||||
export default {
|
||||
data () {
|
||||
return {
|
||||
isSupported: false,
|
||||
hasPermission: false,
|
||||
isRecording: false,
|
||||
isPaused: false,
|
||||
chunks: []
|
||||
}
|
||||
},
|
||||
async created(){
|
||||
this.$_stream = await this.getStream()
|
||||
this.$_stream.getTracks().forEach(t => t.stop())
|
||||
},
|
||||
methods: {
|
||||
async start () {
|
||||
if (this.isRecording) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.$_stream = await this.getStream()
|
||||
this.prepareRecorder()
|
||||
this.$_mediaRecorder.start()
|
||||
} catch (e) {
|
||||
this.$emit('error', e)
|
||||
// eslint-disable-next-line
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
stop () {
|
||||
if (!this.isRecording) return
|
||||
this.$_mediaRecorder.stop()
|
||||
this.$_stream.getTracks().forEach(t => t.stop())
|
||||
this.$_stream = null
|
||||
},
|
||||
pause () {
|
||||
if (!this.isRecording) return
|
||||
this.$_mediaRecorder.pause()
|
||||
},
|
||||
resume () {
|
||||
if (!this.isPaused) return
|
||||
this.$_mediaRecorder.resume()
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the input stream based on constraints and emit the stream event
|
||||
* to the parent component so he can use it for processing or show a preview
|
||||
*/
|
||||
async getStream () {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(this.constraints)
|
||||
this.$_stream = stream
|
||||
this.$emit('stream', stream)
|
||||
return stream
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new media recorder with the user media stream
|
||||
* and set some event listeners to update component data
|
||||
* and emit events to the parent component
|
||||
*/
|
||||
prepareRecorder () {
|
||||
if (!this.$_stream) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$_mediaRecorder = new MediaRecorder(this.$_stream, {
|
||||
mimeType: this.mimeType
|
||||
})
|
||||
|
||||
this.$_mediaRecorder.ignoreMutedMedia = true
|
||||
|
||||
this.$_mediaRecorder.addEventListener('start', () => {
|
||||
this.isRecording = true
|
||||
this.isPaused = false
|
||||
this.$emit('start')
|
||||
})
|
||||
|
||||
this.$_mediaRecorder.addEventListener('resume', () => {
|
||||
this.isRecording = true
|
||||
this.isPaused = false
|
||||
this.$emit('resume')
|
||||
})
|
||||
|
||||
this.$_mediaRecorder.addEventListener('pause', () => {
|
||||
this.isPaused = true
|
||||
this.$emit('pause')
|
||||
})
|
||||
|
||||
// Collect the available data into chunks
|
||||
this.$_mediaRecorder.addEventListener('dataavailable', (e) => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
this.chunks.push(e.data)
|
||||
}
|
||||
}, true)
|
||||
|
||||
// On recording stop get the data and emit the result
|
||||
// than clear all the recording chunks
|
||||
this.$_mediaRecorder.addEventListener('stop', () => {
|
||||
this.$emit('stop')
|
||||
|
||||
const blobData = new Blob(this.chunks)
|
||||
if (blobData.size > 0) {
|
||||
this.$emit('result', blobData)
|
||||
}
|
||||
this.chunks = []
|
||||
this.isPaused = false
|
||||
this.isRecording = false
|
||||
}, true)
|
||||
}
|
||||
},
|
||||
mounted () {
|
||||
if (!navigator.mediaDevices && !navigator.mediaDevices.getUserMedia) {
|
||||
// eslint-disable-next-line
|
||||
console.warn('Media Devices are not supported from your browser.')
|
||||
return
|
||||
}
|
||||
|
||||
// video recorder on Safari is not currently supported
|
||||
// TODO: we could use https://github.com/CameraKit/webm-media-recorder
|
||||
if (!window.MediaRecorder && this.constraints.video) {
|
||||
// eslint-disable-next-line
|
||||
console.warn('MediaRecorder for video is not supported from your browser.')
|
||||
return
|
||||
}
|
||||
|
||||
this.isSupported = true
|
||||
}
|
||||
}
|
||||
204
frontend/src/components/SafariRecorderMixin.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { loadScripts } from './loadScripts';
|
||||
|
||||
import StartAudioContext from 'startaudiocontext';
|
||||
import Tone from 'tone';
|
||||
|
||||
// https://github.com/Tonejs/Tone.js/issues/341
|
||||
// https://github.com/tambien/StartAudioContext
|
||||
StartAudioContext(Tone.context);
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
async start() {
|
||||
if (this.isRecording) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.$_stream = await this.getStream();
|
||||
await this.prepareRecorder();
|
||||
|
||||
this.$_mediaRecorder.start();
|
||||
Tone.Transport.start();
|
||||
} catch (e) {
|
||||
this.$emit('error', e);
|
||||
// eslint-disable-next-line
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
if (!this.isRecording) return;
|
||||
|
||||
this.$_mediaRecorder.stop();
|
||||
this.$_stream.getTracks().forEach(t => t.stop());
|
||||
this.$_mic.close();
|
||||
|
||||
Tone.Transport.stop();
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the input stream based on constraints and emit the stream event
|
||||
* to the parent component so he can use it for processing or show a preview
|
||||
*/
|
||||
async getStream() {
|
||||
// const stream = await navigator.mediaDevices.getUserMedia(this.constraints)
|
||||
// this.$_stream = stream
|
||||
|
||||
// you probably DONT want to connect the microphone
|
||||
// directly to the master output because of feedback.
|
||||
this.$_mic = new Tone.UserMedia();
|
||||
this.debug && console.log('tonejs microphone instance', this.$_mic);
|
||||
|
||||
await this.$_mic.open();
|
||||
this.debug && console.log('mic is open', this.$_mic);
|
||||
|
||||
const dest = Tone.context.createMediaStreamDestination();
|
||||
this.debug && console.log('context.createMediaStreamDestination', dest);
|
||||
|
||||
this.$_mic.connect(dest);
|
||||
this.debug && console.log('mic connected');
|
||||
|
||||
this.$_stream = dest.stream;
|
||||
this.$emit('stream', this.$_stream);
|
||||
|
||||
return this.$_stream;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new media recorder with the user media stream
|
||||
* and set some event listeners to update component data
|
||||
* and emit events to the parent component
|
||||
*/
|
||||
async prepareRecorder() {
|
||||
if (!this.$_stream) {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
<!-- load OpusMediaRecorder.umd.js. OpusMediaRecorder will be loaded. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/OpusMediaRecorder.umd.js"></script>
|
||||
<!-- load encoderWorker.umd.js. This should be after OpusMediaRecorder. -->
|
||||
<!-- This script tag will create OpusMediaRecorder.encoderWorker. -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/encoderWorker.umd.js"></script>
|
||||
<script>
|
||||
// Check if MediaRecorder available.
|
||||
if (!window.MediaRecorder) {
|
||||
window.MediaRecorder = OpusMediaRecorder;
|
||||
}
|
||||
// Check if a target format (e.g. audio/ogg) is supported.
|
||||
else if (!window.MediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
||||
window.MediaRecorder = OpusMediaRecorder;
|
||||
}
|
||||
</script>
|
||||
<script type="text/javascript" src="//webrtchacks.github.io/adapter/adapter-latest.js"></script>
|
||||
<!-- <script type="text/javascript" src="/js/audio-service/StartAudioContext.js"></script>
|
||||
<script type="text/javascript" src="/js/audio-service/Tone.js"></script> -->
|
||||
*/
|
||||
|
||||
await loadScripts([
|
||||
// load OpusMediaRecorder.umd.js. OpusMediaRecorder will be loaded.
|
||||
'https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/OpusMediaRecorder.umd.js',
|
||||
// load encoderWorker.umd.js. This should be after OpusMediaRecorder.
|
||||
// This script tag will create OpusMediaRecorder.encoderWorker.
|
||||
'https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/encoderWorker.umd.js',
|
||||
'https://webrtchacks.github.io/adapter/adapter-latest.js'
|
||||
// I've included NPM ones
|
||||
// '/js/audio-service/StartAudioContext.js',
|
||||
// '/js/audio-service/Tone.js',
|
||||
]);
|
||||
|
||||
let CustomMediaRecorder = window.MediaRecorder;
|
||||
|
||||
// Check if MediaRecorder available.
|
||||
if (!CustomMediaRecorder) {
|
||||
if (!window.OpusMediaRecorder) {
|
||||
console.error('OpusMediaRecorder is not defined');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
CustomMediaRecorder = OpusMediaRecorder;
|
||||
}
|
||||
// Check if a target format (e.g. audio/ogg) is supported.
|
||||
else if (!CustomMediaRecorder.isTypeSupported('audio/ogg;codecs=opus')) {
|
||||
if (!window.OpusMediaRecorder) {
|
||||
console.error('OpusMediaRecorder is not defined');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-undef
|
||||
CustomMediaRecorder = OpusMediaRecorder;
|
||||
}
|
||||
|
||||
// If you already load encoderWorker.js using <script> tag,
|
||||
// you don't need to define encoderWorkerFactory.
|
||||
const workerOptions = {
|
||||
OggOpusEncoderWasmPath:
|
||||
'https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/OggOpusEncoder.wasm',
|
||||
WebMOpusEncoderWasmPath:
|
||||
'https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/WebMOpusEncoder.wasm'
|
||||
};
|
||||
|
||||
this.$_mediaRecorder = new CustomMediaRecorder(
|
||||
this.$_stream,
|
||||
{
|
||||
// mimeType: this.mimeType
|
||||
mimeType: '' // browser dependent
|
||||
},
|
||||
workerOptions
|
||||
);
|
||||
this.debug && console.log('⏺', this.$_mediaRecorder);
|
||||
|
||||
this.$_mediaRecorder.ignoreMutedMedia = true;
|
||||
|
||||
this.$_mediaRecorder.addEventListener('start', () => {
|
||||
this.isRecording = true;
|
||||
this.isPaused = false;
|
||||
this.$emit('start');
|
||||
});
|
||||
|
||||
this.$_mediaRecorder.addEventListener('resume', () => {
|
||||
this.isRecording = true;
|
||||
this.isPaused = false;
|
||||
this.$emit('resume');
|
||||
});
|
||||
|
||||
this.$_mediaRecorder.addEventListener('pause', () => {
|
||||
this.isPaused = true;
|
||||
this.$emit('pause');
|
||||
});
|
||||
|
||||
// Collect the available data into chunks
|
||||
this.$_mediaRecorder.addEventListener(
|
||||
'dataavailable',
|
||||
e => {
|
||||
if (e.data && e.data.size > 0) {
|
||||
this.chunks.push(e.data);
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
// On recording stop get the data and emit the result
|
||||
// than clear all the recording chunks
|
||||
this.$_mediaRecorder.addEventListener(
|
||||
'stop',
|
||||
() => {
|
||||
this.$emit('stop');
|
||||
|
||||
const blobData = new Blob(this.chunks, {
|
||||
type: this.$_mediaRecorder.mimeType
|
||||
});
|
||||
if (blobData.size > 0) {
|
||||
this.$emit('result', blobData);
|
||||
}
|
||||
this.chunks = [];
|
||||
this.isPaused = false;
|
||||
this.isRecording = false;
|
||||
|
||||
this.$_mic.dispose();
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
130
frontend/src/components/VueRecordAudio.vue
Normal file
@@ -0,0 +1,130 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="isSupported"
|
||||
class="vue-audio-recorder"
|
||||
:class="{
|
||||
'active': isRecording,
|
||||
'paused': isPaused
|
||||
}"
|
||||
@mousedown="startRecording"
|
||||
@mouseleave="stopRecording"
|
||||
@mouseup="stopRecording"
|
||||
@touchstart="startRecording"
|
||||
@touchend="stopRecording"
|
||||
@touchcancel="stopRecording"
|
||||
>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ElementMixin from './ElementMixin'
|
||||
|
||||
const supportedTypes = [
|
||||
'audio/aac',
|
||||
'audio/ogg',
|
||||
'audio/wav',
|
||||
'audio/webm'
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'VueRecordAudio',
|
||||
mixins: [ElementMixin],
|
||||
props: {
|
||||
mimeType: {
|
||||
type: String,
|
||||
default: 'audio/webm',
|
||||
validator: v => supportedTypes.includes(v)
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
constraints: {
|
||||
audio: true,
|
||||
video: false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-audio-recorder {
|
||||
position: relative;
|
||||
background-color: #4DB6AC;
|
||||
border-radius: 50%;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 0 0 0 rgba(232, 76, 61, 0.7);
|
||||
&:hover {
|
||||
background-color: #26A69A;
|
||||
}
|
||||
&.active {
|
||||
background-color: #ef5350;
|
||||
-webkit-animation: pulse 1.25s infinite cubic-bezier(0.66, 0, 0, 1);
|
||||
-moz-animation: pulse 1.25s infinite cubic-bezier(0.66, 0, 0, 1);
|
||||
animation: pulse 1.25s infinite cubic-bezier(0.66, 0, 0, 1);
|
||||
}
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
}
|
||||
&:after {
|
||||
top: 30%;
|
||||
left: 43%;
|
||||
height: 15%;
|
||||
width: 14%;
|
||||
border-top-left-radius: 50%;
|
||||
border-top-right-radius: 50%;
|
||||
}
|
||||
&:before {
|
||||
top: 40%;
|
||||
left: 43%;
|
||||
height: 15%;
|
||||
width: 14%;
|
||||
border-bottom-left-radius: 50%;
|
||||
border-bottom-right-radius: 50%;
|
||||
}
|
||||
span {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 36%;
|
||||
height: 24%;
|
||||
width: 28%;
|
||||
overflow: hidden;
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: #fff;
|
||||
}
|
||||
&:before {
|
||||
bottom: 50%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
border-radius: 50%;
|
||||
border: 0.125em solid #fff;
|
||||
background: none;
|
||||
left: 0;
|
||||
}
|
||||
&:after {
|
||||
top: 50%;
|
||||
left: 40%;
|
||||
width: 20%;
|
||||
height: 25%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
to {
|
||||
box-shadow: 0 0 0 10px rgba(239, 83, 80, 0.1);
|
||||
background-color: #E53935;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
frontend/src/components/VueRecordVideo.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template lang="html">
|
||||
<div
|
||||
v-if="isSupported"
|
||||
class="vue-video-recorder"
|
||||
:class="{
|
||||
'active': isRecording,
|
||||
'paused': isPaused
|
||||
}"
|
||||
@mousedown="startRecording"
|
||||
@mouseleave="stopRecording"
|
||||
@mouseup="stopRecording"
|
||||
@touchstart="startRecording"
|
||||
@touchend="stopRecording"
|
||||
@touchcancel="stopRecording"
|
||||
>
|
||||
<div class="recorder-icon">
|
||||
<span v-if="isRecording">STOP</span>
|
||||
<span v-else>START</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import ElementMixin from './ElementMixin'
|
||||
|
||||
const supportedTypes = [
|
||||
'video/x-msvideo',
|
||||
'video/ogg',
|
||||
'video/mpeg',
|
||||
'video/webm'
|
||||
]
|
||||
|
||||
export default {
|
||||
name: 'VueRecordVideo',
|
||||
mixins: [ElementMixin],
|
||||
props: {
|
||||
mimeType: {
|
||||
type: String,
|
||||
default: 'video/webm',
|
||||
validator: v => supportedTypes.includes(v)
|
||||
},
|
||||
audio: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
mode: {
|
||||
default: 'press'
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
constraints () {
|
||||
return {
|
||||
video: true,
|
||||
audio: this.audio
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.vue-video-recorder {
|
||||
color: white;
|
||||
border-radius: 18px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
|
||||
.recorder-icon {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
background-color: #4DB6AC;
|
||||
border-radius: 50%;
|
||||
display:inline-block;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
12
frontend/src/components/index.js
Normal file
@@ -0,0 +1,12 @@
|
||||
import VueRecordAudio from './VueRecordAudio'
|
||||
import VueRecordVideo from './VueRecordVideo'
|
||||
|
||||
export {
|
||||
VueRecordAudio,
|
||||
VueRecordVideo
|
||||
}
|
||||
|
||||
export default function install(Vue) {
|
||||
Vue.component('VueRecordAudio', VueRecordAudio)
|
||||
Vue.component('VueRecordVideo', VueRecordVideo)
|
||||
}
|
||||
134
frontend/src/components/loadScripts.js
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Dinamically load script into DOM
|
||||
* @see https://gist.github.com/lidio601/81974ecf4564dbf257f80a969dcbdd5c
|
||||
*
|
||||
* Example usage:
|
||||
* {code}
|
||||
|
||||
require('./loadScript');
|
||||
|
||||
console.log('starting');
|
||||
loadScripts([
|
||||
"https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/OpusMediaRecorder.umd.js",
|
||||
"https://cdn.jsdelivr.net/npm/opus-media-recorder@latest/encoderWorker.umd.js"
|
||||
], { debug: true })
|
||||
.then(() => console.log("finished"))
|
||||
.catch(console.error);
|
||||
{/code}
|
||||
*/
|
||||
|
||||
/**
|
||||
* @see https://stackoverflow.com/questions/16839698/jquery-getscript-alternative-in-native-javascript
|
||||
*/
|
||||
export const loadScript = (
|
||||
source,
|
||||
{
|
||||
beforeEl = false,
|
||||
afterEl = false,
|
||||
async = true,
|
||||
defer = true,
|
||||
debug = false
|
||||
} = {}
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
let script = document.createElement('script');
|
||||
const shouldInjectBefore = !afterEl; // defaults to before
|
||||
const scripts = document.getElementsByTagName('script');
|
||||
|
||||
// check whenever this script is not already included!
|
||||
const existingOne = Array.prototype.slice
|
||||
.call(scripts)
|
||||
.filter(elem => elem.src === source);
|
||||
|
||||
if (existingOne.length) {
|
||||
debug && console.warn("loadScript :: skipped because it's already loaded", {
|
||||
source
|
||||
});
|
||||
|
||||
return resolve(existingOne);
|
||||
}
|
||||
|
||||
const prior = beforeEl || (scripts.length > 0 ? scripts[0] : null);
|
||||
const after = afterEl || (scripts.length > 0 ? [scripts.length - 1] : null);
|
||||
|
||||
script.async = async;
|
||||
script.defer = defer;
|
||||
|
||||
function onloadHander(_, isAbort) {
|
||||
if (
|
||||
isAbort ||
|
||||
!script.readyState ||
|
||||
/loaded|complete/.test(script.readyState)
|
||||
) {
|
||||
script.onload = null;
|
||||
script.onreadystatechange = null;
|
||||
script = undefined;
|
||||
|
||||
if (isAbort) {
|
||||
reject(
|
||||
new Error(`loadScript :: error while loading script from ${script}`)
|
||||
);
|
||||
} else {
|
||||
resolve(script);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
script.onload = onloadHander;
|
||||
script.onreadystatechange = onloadHander;
|
||||
|
||||
script.src = source;
|
||||
|
||||
if (shouldInjectBefore && beforeEl) {
|
||||
prior.parentNode.insertBefore(script, prior);
|
||||
} else if (!shouldInjectBefore && afterEl) {
|
||||
// Note: There is no insertAfter() method.
|
||||
// It can be emulated by combining the insertBefore method with Node.nextSibling.
|
||||
prior.parentNode.insertBefore(script, after.nextSibling);
|
||||
} else {
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const loadScripts = async (
|
||||
sources,
|
||||
{
|
||||
beforeEl = false,
|
||||
afterEl = false,
|
||||
async = true,
|
||||
defer = true,
|
||||
debug = false
|
||||
} = {}
|
||||
) => {
|
||||
// defaults to []
|
||||
sources = sources || [];
|
||||
// ensure that is an array
|
||||
sources = typeof sources.forEach === 'function' ? sources : [sources];
|
||||
// if it's empty
|
||||
if (sources.length === 0) {
|
||||
debug && console.log('loadScripts :: ended');
|
||||
return;
|
||||
}
|
||||
|
||||
// include scripts in order
|
||||
const firstScript = sources.shift();
|
||||
debug && console.log('loadScripts :: loading', firstScript);
|
||||
|
||||
return (
|
||||
loadScript(firstScript, { beforeEl, afterEl, async, defer })
|
||||
// recursion here!
|
||||
.then(elem => {
|
||||
debug && console.log('loadScripts :: loaded', firstScript);
|
||||
|
||||
return loadScripts(sources, {
|
||||
// continue injecting the other scripts
|
||||
// after this one
|
||||
afterEl: elem,
|
||||
async,
|
||||
defer,
|
||||
debug
|
||||
});
|
||||
})
|
||||
);
|
||||
};
|
||||
39
frontend/src/css/app.scss
Normal file
@@ -0,0 +1,39 @@
|
||||
// app global css in SCSS form
|
||||
@font-face {
|
||||
font-family: 'Inter-Regular';
|
||||
src: url(../assets/fonts/static/Inter-Regular.ttf);
|
||||
}
|
||||
|
||||
body {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
background-position: center;
|
||||
font-family: 'Inter-Regular';
|
||||
|
||||
|
||||
.img-width {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
width: calc(100% - 20px);
|
||||
max-width: 100vh;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
input {
|
||||
background-clip: text !important;
|
||||
-webkit-background-clip: text !important;
|
||||
}
|
||||
|
||||
.required::after {
|
||||
content: " *";
|
||||
color: red;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #f5f5f5;
|
||||
margin-top: 40px;
|
||||
}
|
||||
25
frontend/src/css/quasar.variables.scss
Normal file
@@ -0,0 +1,25 @@
|
||||
// Quasar SCSS (& Sass) Variables
|
||||
// --------------------------------------------------
|
||||
// To customize the look and feel of this app, you can override
|
||||
// the Sass/SCSS variables found in Quasar's source Sass/SCSS files.
|
||||
|
||||
// Check documentation for full list of Quasar variables
|
||||
|
||||
// Your own variables (that are declared here) and Quasar's own
|
||||
// ones will be available out of the box in your .vue/.scss/.sass files
|
||||
|
||||
// It's highly recommended to change the default colors
|
||||
// to match your app's branding.
|
||||
// Tip: Use the "Theme Builder" on Quasar's documentation website.
|
||||
|
||||
$primary : #1976D2;
|
||||
$secondary : #26A69A;
|
||||
$accent : #9C27B0;
|
||||
|
||||
$dark : #1D1D1D;
|
||||
$dark-page : #121212;
|
||||
|
||||
$positive : #21BA45;
|
||||
$negative : #C10015;
|
||||
$info : #31CCEC;
|
||||
$warning : #F2C037;
|
||||
13
frontend/src/env.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
/* eslint-disable */
|
||||
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv {
|
||||
NODE_ENV: string;
|
||||
VUE_ROUTER_MODE: 'hash' | 'history' | 'abstract' | undefined;
|
||||
VUE_ROUTER_BASE: string | undefined;
|
||||
VUE_APP_CLIENT_ID: string | undefined;
|
||||
VUE_APP_API: string | undefined;
|
||||
VUE_APP_API_SERVER: string | undefined;
|
||||
VITE_APP_GEO_MAP_API_KEY: string | undefined;
|
||||
}
|
||||
}
|
||||