Initial commit: LACA parking management system

This commit is contained in:
2025-08-13 10:05:36 +07:00
commit 8b07467b61
275 changed files with 66828 additions and 0 deletions

5
frontend/.eslintrc.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
extends: [
'@quasar/app-vite/eslint-config/index.js'
]
}

1
frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
node_modules

27
frontend/README.md Normal file
View 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
View 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

File diff suppressed because it is too large Load Diff

55
frontend/package.json Normal file
View 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"
}
}

View 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')
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

31
frontend/public/en.svg Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 790 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 717 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1009 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 494 KiB

BIN
frontend/public/logo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

7
frontend/public/vi.svg Normal file
View 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
View 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
View File

@@ -0,0 +1,11 @@
<template>
<router-view />
</template>
<script>
import { defineComponent } from 'vue';
export default defineComponent({
name: 'App',
});
</script>

View 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.

View 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 arent 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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

View File

View 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 };

View 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
View 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);
});

View 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>

View 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>

View 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>

View 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>

View File

@@ -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>

View 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
)
}
}

View 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
}

View 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()
}

View 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()
}

View 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)
}
})
}

View File

@@ -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()
})
}

View File

@@ -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
}

View 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
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}
}
}

View File

@@ -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]
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}

View 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()
}
}
}

View 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>

View 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>

View 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>

View 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
}
}

View 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
);
}
},
};

View 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>

View 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>

View 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)
}

View 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
View 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;
}

View 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
View 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;
}
}

Some files were not shown because too many files have changed in this diff Show More