mirror of
https://github.com/janishutz/MusicPlayerV2.git
synced 2026-01-11 16:38:26 +00:00
Prepare for rewrite
This commit is contained in:
30
MusicPlayerV2-GUI/.gitignore
vendored
30
MusicPlayerV2-GUI/.gitignore
vendored
@@ -1,30 +0,0 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
7
MusicPlayerV2-GUI/.vscode/extensions.json
vendored
7
MusicPlayerV2-GUI/.vscode/extensions.json
vendored
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"Vue.vscode-typescript-vue-plugin",
|
||||
"dbaeumer.vscode-eslint"
|
||||
]
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
# MusicPlayerV2-GUI
|
||||
|
||||
This template should help get you started developing with Vue 3 in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
||||
|
||||
## Type Support for `.vue` Imports in TS
|
||||
|
||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
||||
|
||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
||||
|
||||
1. Disable the built-in TypeScript Extension
|
||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
||||
|
||||
## Customize configuration
|
||||
|
||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
||||
|
||||
## Project Setup
|
||||
|
||||
```sh
|
||||
npm install
|
||||
```
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Type-Check, Compile and Minify for Production
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
npm run lint
|
||||
```
|
||||
1
MusicPlayerV2-GUI/env.d.ts
vendored
1
MusicPlayerV2-GUI/env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,714 +0,0 @@
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const style = {
|
||||
'plugins': {
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic/js': stylistic,
|
||||
'@stylistic/ts': stylistic,
|
||||
},
|
||||
'files': [
|
||||
'**/*.ts',
|
||||
'**/*.js',
|
||||
'**/*.mjs',
|
||||
'**/*.cjs',
|
||||
'**/*.tsx',
|
||||
'**/*.jsx'
|
||||
],
|
||||
'rules': {
|
||||
'sort-imports': [
|
||||
'error',
|
||||
{
|
||||
"ignoreCase": false,
|
||||
"ignoreDeclarationSort": false,
|
||||
"ignoreMemberSort": false,
|
||||
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"],
|
||||
"allowSeparatedGroups": false
|
||||
}
|
||||
],
|
||||
// Formatting
|
||||
'@stylistic/array-bracket-newline': [
|
||||
'error',
|
||||
{
|
||||
'multiline': true,
|
||||
'minItems': 2
|
||||
}
|
||||
],
|
||||
'@stylistic/array-bracket-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/array-element-newline': [
|
||||
'error',
|
||||
{
|
||||
'multiline': true,
|
||||
'minItems': 2
|
||||
}
|
||||
],
|
||||
'@stylistic/arrow-parens': [
|
||||
'error',
|
||||
'as-needed'
|
||||
],
|
||||
'@stylistic/arrow-spacing': [
|
||||
'error',
|
||||
{
|
||||
'before': true,
|
||||
'after': true
|
||||
}
|
||||
],
|
||||
'@stylistic/block-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/brace-style': [
|
||||
'error',
|
||||
'1tbs'
|
||||
],
|
||||
'@stylistic/comma-spacing': [
|
||||
'error',
|
||||
{
|
||||
'before': false,
|
||||
'after': true
|
||||
}
|
||||
],
|
||||
'@stylistic/comma-style': [
|
||||
'error',
|
||||
'last'
|
||||
],
|
||||
'@stylistic/dot-location': [
|
||||
'error',
|
||||
'property'
|
||||
],
|
||||
'@stylistic/eol-last': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/function-call-spacing': [
|
||||
'error',
|
||||
'never'
|
||||
],
|
||||
'@stylistic/function-paren-newline': [
|
||||
'error',
|
||||
{
|
||||
'minItems': 3
|
||||
}
|
||||
],
|
||||
'@stylistic/function-call-argument-newline': [
|
||||
'error',
|
||||
'consistent'
|
||||
],
|
||||
'@stylistic/implicit-arrow-linebreak': [
|
||||
'error',
|
||||
'beside'
|
||||
],
|
||||
'@stylistic/indent': [
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'@stylistic/key-spacing': [
|
||||
'error',
|
||||
{
|
||||
'beforeColon': false,
|
||||
'afterColon': true
|
||||
}
|
||||
],
|
||||
'@stylistic/keyword-spacing': [
|
||||
'error',
|
||||
{
|
||||
'before': true,
|
||||
'after': true
|
||||
}
|
||||
],
|
||||
'@stylistic/lines-between-class-members': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/max-len': [
|
||||
'warn',
|
||||
{
|
||||
'code': 120,
|
||||
'comments': 140,
|
||||
'ignoreComments': false,
|
||||
'ignoreUrls': true,
|
||||
'ignoreStrings': false
|
||||
}
|
||||
],
|
||||
'@stylistic/new-parens': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/newline-per-chained-call': [ 'error' ],
|
||||
'@stylistic/no-extra-parens': [
|
||||
'error',
|
||||
'all',
|
||||
{
|
||||
'nestedBinaryExpressions': false,
|
||||
'ternaryOperandBinaryExpressions': false,
|
||||
'ignoreJSX': 'multi-line',
|
||||
'nestedConditionalExpressions': false
|
||||
}
|
||||
],
|
||||
'@stylistic/no-extra-semi': 'error',
|
||||
'@stylistic/no-floating-decimal': 'error',
|
||||
'@stylistic/no-mixed-operators': 'error',
|
||||
'@stylistic/no-mixed-spaces-and-tabs': 'error',
|
||||
'@stylistic/no-multi-spaces': 'error',
|
||||
'@stylistic/no-multiple-empty-lines': [
|
||||
'error',
|
||||
{
|
||||
'max': 3,
|
||||
'maxEOF': 2
|
||||
}
|
||||
],
|
||||
'@stylistic/no-tabs': 'error',
|
||||
'@stylistic/no-trailing-spaces': 'error',
|
||||
'@stylistic/no-whitespace-before-property': 'error',
|
||||
'@stylistic/object-curly-newline': [
|
||||
'error',
|
||||
{
|
||||
'multiline': true,
|
||||
'minProperties': 1
|
||||
}
|
||||
],
|
||||
'@stylistic/object-curly-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/object-property-newline': 'error',
|
||||
'@stylistic/operator-linebreak': [
|
||||
'error',
|
||||
'before'
|
||||
],
|
||||
'@stylistic/one-var-declaration-per-line': 'error',
|
||||
'@stylistic/padded-blocks': [
|
||||
'error',
|
||||
{
|
||||
'blocks': 'never',
|
||||
'classes': 'always',
|
||||
'switches': 'never',
|
||||
}
|
||||
],
|
||||
|
||||
// Padding lines. The most in-depth part of this config
|
||||
'@stylistic/padding-line-between-statements': [
|
||||
'error',
|
||||
// Variables, Constants
|
||||
{
|
||||
'blankLine': 'never',
|
||||
'prev': 'var',
|
||||
'next': 'var'
|
||||
},
|
||||
{
|
||||
'blankLine': 'never',
|
||||
'prev': 'let',
|
||||
'next': 'let'
|
||||
},
|
||||
{
|
||||
'blankLine': 'never',
|
||||
'prev': 'const',
|
||||
'next': 'const'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'var',
|
||||
'next': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'cjs-import',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'import',
|
||||
'let',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'with'
|
||||
]
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'let',
|
||||
'next': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'cjs-import',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'import',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'while',
|
||||
'with'
|
||||
]
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'const',
|
||||
'next': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'cjs-import',
|
||||
'class',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'import',
|
||||
'let',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'while',
|
||||
'with'
|
||||
]
|
||||
},
|
||||
// Import
|
||||
{
|
||||
'blankLine': 'never',
|
||||
'prev': 'import',
|
||||
'next': 'import'
|
||||
},
|
||||
{
|
||||
'blankLine': 'never',
|
||||
'prev': 'cjs-import',
|
||||
'next': 'cjs-import'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'let',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'while',
|
||||
'with'
|
||||
],
|
||||
'next': 'cjs-import'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'cjs-import',
|
||||
'next': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'let',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'while',
|
||||
'with'
|
||||
]
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'let',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'while',
|
||||
'with'
|
||||
],
|
||||
'next': 'import'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'import',
|
||||
'next': [
|
||||
'block',
|
||||
'block-like',
|
||||
'break',
|
||||
'cjs-export',
|
||||
'class',
|
||||
'const',
|
||||
'continue',
|
||||
'debugger',
|
||||
'directive',
|
||||
'do',
|
||||
'empty',
|
||||
'export',
|
||||
'expression',
|
||||
'for',
|
||||
'function',
|
||||
'if',
|
||||
'iife',
|
||||
'let',
|
||||
'return',
|
||||
'switch',
|
||||
'throw',
|
||||
'try',
|
||||
'var',
|
||||
'while',
|
||||
'with'
|
||||
]
|
||||
},
|
||||
// If
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'if'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'if',
|
||||
'next': '*'
|
||||
},
|
||||
// For
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'for'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'for',
|
||||
'next': '*'
|
||||
},
|
||||
// While
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'while'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'while',
|
||||
'next': '*'
|
||||
},
|
||||
// Functions
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'function'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'function',
|
||||
'next': '*'
|
||||
},
|
||||
// Block Statements
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'block-like'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'block-like',
|
||||
'next': '*'
|
||||
},
|
||||
// Switch
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'switch'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'switch',
|
||||
'next': '*'
|
||||
},
|
||||
// Try-Catch
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'try'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'try',
|
||||
'next': '*'
|
||||
},
|
||||
// Throw
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'throw'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'throw',
|
||||
'next': '*'
|
||||
},
|
||||
// Return
|
||||
{
|
||||
'blankLine': 'never',
|
||||
'prev': 'return',
|
||||
'next': '*'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'return'
|
||||
},
|
||||
// Export
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'export'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'export',
|
||||
'next': '*'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'cjs-export'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'cjs-export',
|
||||
'next': '*'
|
||||
},
|
||||
// Classes
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': '*',
|
||||
'next': 'class'
|
||||
},
|
||||
{
|
||||
'blankLine': 'always',
|
||||
'prev': 'class',
|
||||
'next': '*'
|
||||
},
|
||||
],
|
||||
'@stylistic/quote-props': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/quotes': [
|
||||
'error',
|
||||
'single'
|
||||
],
|
||||
'@stylistic/rest-spread-spacing': [
|
||||
'error',
|
||||
'never'
|
||||
],
|
||||
'@stylistic/semi': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/semi-spacing': [
|
||||
'error',
|
||||
{
|
||||
'before': false,
|
||||
'after': true
|
||||
}
|
||||
],
|
||||
'@stylistic/semi-style': [
|
||||
'error',
|
||||
'last'
|
||||
],
|
||||
'@stylistic/space-before-blocks': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/space-before-function-paren': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/space-in-parens': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/space-infix-ops': [
|
||||
'error',
|
||||
{
|
||||
'int32Hint': false
|
||||
}
|
||||
],
|
||||
'@stylistic/space-unary-ops': 'error',
|
||||
'@stylistic/spaced-comment': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/template-curly-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@stylistic/wrap-iife': [
|
||||
'error',
|
||||
'inside'
|
||||
],
|
||||
'@stylistic/wrap-regex': 'error',
|
||||
'@stylistic/ts/type-annotation-spacing': 'error',
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
export default tseslint.config(
|
||||
// Base JavaScript rules
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
style,
|
||||
|
||||
// Vue support (including TS and JSX inside SFCs)
|
||||
{
|
||||
'files': [ '**/*.vue' ],
|
||||
'languageOptions': {
|
||||
'sourceType': 'module',
|
||||
'ecmaVersion': 'latest',
|
||||
'globals': globals.browser,
|
||||
'parserOptions': {
|
||||
'parser': tseslint.parser,
|
||||
},
|
||||
},
|
||||
'plugins': {
|
||||
'vue': vue,
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic/js': stylistic,
|
||||
'@stylistic/ts': stylistic,
|
||||
'@typescript-eslint': typescript,
|
||||
},
|
||||
'extends': [
|
||||
eslint.configs.recommended,
|
||||
...vue.configs['flat/recommended']
|
||||
],
|
||||
'rules': {
|
||||
...typescript.configs.recommended.rules,
|
||||
...style.rules,
|
||||
|
||||
// Vue specific rules
|
||||
'@stylistic/indent': 'off',
|
||||
'vue/html-indent': [
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'vue/html-comment-indent': [
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'vue/script-indent': [
|
||||
'error',
|
||||
4,
|
||||
{
|
||||
'baseIndent': 1,
|
||||
'switchCase': 1
|
||||
}
|
||||
],
|
||||
'vue/html-self-closing': [
|
||||
'error',
|
||||
{
|
||||
'html': {
|
||||
'void': 'never',
|
||||
'normal': 'never',
|
||||
'component': 'always'
|
||||
},
|
||||
'svg': 'always',
|
||||
'math': 'never'
|
||||
}
|
||||
],
|
||||
'vue/max-attributes-per-line': [
|
||||
'error',
|
||||
{
|
||||
'singleline': 3,
|
||||
'multiline': 1,
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -1,25 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200">
|
||||
<!-- TODO: Update URL -->
|
||||
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
|
||||
<script src="https://static.janishutz.com/libs/jquery/jquery.min.js"></script>
|
||||
<title>MusicPlayer</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>This application requires JavaScript to work!</noscript>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
localStorage.setItem('music-player-config', 'sse');// Or 'ws'
|
||||
</script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "musicplayerv2-gui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@janishutz/login-sdk-browser": "^1.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@melloware/coloris": "^0.24.0",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"colorthief": "^2.6.0",
|
||||
"music-metadata": "^11.9.0",
|
||||
"musickit-typescript": "^1.2.4",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@tsconfig/node20": "^20.1.2",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/node": "^20.11.10",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"sass-embedded": "^1.92.0",
|
||||
"typescript": "~5.3.0",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.4",
|
||||
"vue-tsc": "^2.0.29"
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
{
|
||||
"ages": {
|
||||
"below": "Orange",
|
||||
"16-18": "Yellow",
|
||||
"18+": "Turquoise"
|
||||
},
|
||||
"offering": {
|
||||
"big-bar": {
|
||||
"offering": {
|
||||
"softdrinks": {
|
||||
"name": "Softdrinks",
|
||||
"price": 400,
|
||||
"id": "softdrinks"
|
||||
},
|
||||
"energy": {
|
||||
"name": "Energy",
|
||||
"price": 400,
|
||||
"id": "energy"
|
||||
},
|
||||
"mate": {
|
||||
"name": "Mate",
|
||||
"price": 500,
|
||||
"id": "mate",
|
||||
"depot": 200
|
||||
},
|
||||
"sparkly-water": {
|
||||
"name": "Mineralwasser mit",
|
||||
"price": 300,
|
||||
"id": "sparkly-water",
|
||||
"showLine": true
|
||||
},
|
||||
"rose": {
|
||||
"name": "Rosé",
|
||||
"price": 1500,
|
||||
"id": "rose",
|
||||
"depot": 200
|
||||
},
|
||||
"red": {
|
||||
"name": "Rotwein",
|
||||
"price": 2000,
|
||||
"id": "red",
|
||||
"depot": 200
|
||||
},
|
||||
"wine-glasses": {
|
||||
"name": "Weingläser",
|
||||
"price": 0,
|
||||
"id": "wine-glasses",
|
||||
"depot": 200
|
||||
},
|
||||
"appenzeller": {
|
||||
"name": "Appenzeller Vollmond",
|
||||
"price": 500,
|
||||
"id": "appenzeller",
|
||||
"depot": 200
|
||||
},
|
||||
"feldschloesschen": {
|
||||
"name": "Feldschlösschen",
|
||||
"price": 500,
|
||||
"id": "feldschloesschen",
|
||||
"depot": 200
|
||||
},
|
||||
"sommersby": {
|
||||
"name": "Sommersby",
|
||||
"price": 500,
|
||||
"id": "sommersby",
|
||||
"depot": 200
|
||||
},
|
||||
"jever-fun": {
|
||||
"name": "Jever Fun",
|
||||
"price": 400,
|
||||
"id": "jever-fun",
|
||||
"depot": 200
|
||||
},
|
||||
"trojka-ice": {
|
||||
"name": "Trojka Ice",
|
||||
"price": 600,
|
||||
"id": "trojka-ice",
|
||||
"depot": 200,
|
||||
"showLine": true
|
||||
},
|
||||
"vodka-red-energy": {
|
||||
"name": "Vodka Rot Energy",
|
||||
"price": 800,
|
||||
"id": "vodka-red-energy"
|
||||
},
|
||||
"vodka-green-citro": {
|
||||
"name": "Vodka Grün Citro",
|
||||
"price": 800,
|
||||
"id": "vodka-green-citro"
|
||||
},
|
||||
"vodka-white-energy": {
|
||||
"name": "Vodka White Energy",
|
||||
"price": 900,
|
||||
"id": "vodka-white-energy"
|
||||
},
|
||||
"gin-tonic": {
|
||||
"name": "Gin Tonic",
|
||||
"price": 900,
|
||||
"id": "gin-tonic"
|
||||
},
|
||||
"rum-cola": {
|
||||
"name": "Rum Cola",
|
||||
"price": 900,
|
||||
"id": "rum-cola"
|
||||
},
|
||||
"whiskey-cola": {
|
||||
"name": "Whiskey Cola",
|
||||
"price": 900,
|
||||
"id": "whiskey-cola"
|
||||
},
|
||||
"mate-mit-schuss": {
|
||||
"name": "Mate mit Schuss",
|
||||
"price": 1200,
|
||||
"id": "mate-mit-schuss",
|
||||
"depot": 200,
|
||||
"showLine": true
|
||||
},
|
||||
"poseidon": {
|
||||
"name": "Poseidon",
|
||||
"price": 900,
|
||||
"id": "poseidon"
|
||||
},
|
||||
"arielle": {
|
||||
"name": "Arielle",
|
||||
"price": 900,
|
||||
"id": "arielle"
|
||||
},
|
||||
"pearl-driver": {
|
||||
"name": "Pearl Driver",
|
||||
"price": 400,
|
||||
"id": "pearl-driver"
|
||||
}
|
||||
},
|
||||
"name": "Poseidon's Quelle",
|
||||
"id": "big-bar"
|
||||
},
|
||||
"small-bar": {
|
||||
"offering": {
|
||||
"softdrinks": {
|
||||
"name": "Softdrinks (Alle)",
|
||||
"price": 300,
|
||||
"id": "softdrinks",
|
||||
"showLine": true
|
||||
},
|
||||
"appenzeller": {
|
||||
"name": "Appenzeller Vollmond",
|
||||
"price": 500,
|
||||
"id": "appenzeller",
|
||||
"depot": 200
|
||||
},
|
||||
"feldschloesschen": {
|
||||
"name": "Feldschlösschen",
|
||||
"price": 500,
|
||||
"id": "feldschloesschen",
|
||||
"depot": 200
|
||||
},
|
||||
"sommersby": {
|
||||
"name": "Sommersby",
|
||||
"price": 500,
|
||||
"id": "sommersby",
|
||||
"depot": 200
|
||||
},
|
||||
"jever-fun": {
|
||||
"name": "Jever Fun",
|
||||
"price": 400,
|
||||
"id": "jever-fun",
|
||||
"depot": 200,
|
||||
"showLine": true
|
||||
},
|
||||
"rose": {
|
||||
"name": "Rosé",
|
||||
"price": 1500,
|
||||
"id": "rose",
|
||||
"depot": 200
|
||||
},
|
||||
"red": {
|
||||
"name": "Rotwein",
|
||||
"price": 2000,
|
||||
"id": "red",
|
||||
"depot": 200
|
||||
},
|
||||
"wine-glasses": {
|
||||
"name": "Weingläser",
|
||||
"price": 0,
|
||||
"id": "wine-glasses",
|
||||
"depot": 200
|
||||
}
|
||||
},
|
||||
"name": "Seepferdchenbar",
|
||||
"id": "small-bar"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,209 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="material-symbols-outlined" v-html="theme"></span>
|
||||
</button>
|
||||
<router-view id="main-view" v-slot="{ Component, route }">
|
||||
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
RouterView
|
||||
} from 'vue-router';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const theme = ref( '☼' );
|
||||
|
||||
const changeTheme = () => {
|
||||
if ( theme.value === '☽' ) {
|
||||
document.documentElement.classList.remove( 'dark' );
|
||||
document.documentElement.classList.add( 'light' );
|
||||
localStorage.setItem( 'theme', '☼' );
|
||||
theme.value = '☼';
|
||||
} else if ( theme.value === '☼' ) {
|
||||
document.documentElement.classList.remove( 'light' );
|
||||
document.documentElement.classList.add( 'dark' );
|
||||
localStorage.setItem( 'theme', '☽' );
|
||||
theme.value = '☽';
|
||||
}
|
||||
};
|
||||
|
||||
theme.value = localStorage.getItem( 'theme' ) ?? '';
|
||||
|
||||
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === '☽' ) {
|
||||
document.documentElement.classList.add( 'dark' );
|
||||
theme.value = '☽';
|
||||
} else {
|
||||
document.documentElement.classList.add( 'light' );
|
||||
theme.value = '☼';
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: var( --background-color );
|
||||
}
|
||||
|
||||
:root, :root.light {
|
||||
--primary-color: #0a1520;
|
||||
--secondary-color: white;
|
||||
--background-color: rgb(221, 221, 221);
|
||||
--nav-background: white;
|
||||
--hover-color: #00457a;
|
||||
--popup-color: rgb(224, 224, 224);
|
||||
--overlay-color: rgba(0, 0, 0, 0.7);
|
||||
--PI: 3.14159265358979;
|
||||
--gray-color: rgb(53, 53, 53);
|
||||
--footer-background: rgb(233, 233, 233);
|
||||
--accent-background: rgb(195, 235, 243);
|
||||
--loading-color: rgb(167, 167, 167);
|
||||
--slider-color: rgb(119, 132, 255);
|
||||
}
|
||||
|
||||
:root.dark {
|
||||
--primary-color: white;
|
||||
--secondary-color: black;
|
||||
--background-color: rgb(32, 32, 32);
|
||||
--nav-background: rgb(54, 54, 54);
|
||||
--popup-color: rgb(58, 58, 58);
|
||||
--hover-color: #007ddd;
|
||||
--overlay-color: rgba(104, 104, 104, 0.575);
|
||||
--gray-color: rgb(207, 207, 207);
|
||||
--footer-background: rgb(53, 53, 53);
|
||||
--accent-background: rgb(24, 12, 58);
|
||||
--loading-color: rgb(65, 65, 65);
|
||||
--slider-color: rgb(119, 132, 255);
|
||||
}
|
||||
|
||||
@media ( prefers-color-scheme: dark ) {
|
||||
:root {
|
||||
--primary-color: white;
|
||||
--secondary-color: black;
|
||||
--background-color: rgb(32, 32, 32);
|
||||
--nav-background: rgb(54, 54, 54);
|
||||
--popup-color: rgb(58, 58, 58);
|
||||
--hover-color: #007ddd;
|
||||
--overlay-color: rgba(104, 104, 104, 0.575);
|
||||
--gray-color: rgb(207, 207, 207);
|
||||
--footer-background: rgb(53, 53, 53);
|
||||
--accent-background: rgb(24, 12, 58);
|
||||
--loading-color: rgb(65, 65, 65);
|
||||
--slider-color: rgb(119, 132, 255);
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #7c8cec;
|
||||
color: white;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
#app {
|
||||
transition: 0.5s;
|
||||
background-color: var( --background-color );
|
||||
font-family: 'Plus Jakarta Sans', sans-serif;
|
||||
/* font-family: Avenir, Helvetica, Arial, sans-serif; */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: var( --primary-color );
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
width: 100vw;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main-view {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.4s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-variation-settings:
|
||||
'FILL' 0,
|
||||
'wght' 400,
|
||||
'GRAD' 0,
|
||||
'opsz' 48
|
||||
}
|
||||
|
||||
.clr-open {
|
||||
border: black solid 1px !important;
|
||||
}
|
||||
|
||||
#themeSelector {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var( --primary-color );
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.fancy-button {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 20px;
|
||||
border: none;
|
||||
background: linear-gradient( 45deg, rgb(0, 33, 139), rgb(151, 0, 0) );
|
||||
font-size: larger;
|
||||
transition: all 0.5s;
|
||||
background-size: 150%;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.fancy-button:hover {
|
||||
border-radius: 5px;
|
||||
background-position: 50%;
|
||||
}
|
||||
|
||||
.fancy-button-inactive {
|
||||
background: linear-gradient( 45deg, rgba(0, 33, 139, 0.6), rgba(151, 0, 0, 0.6) );
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.fancy-button-inactive:hover {
|
||||
border-radius: 20px;
|
||||
background-position: 0px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,56 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
width="361px" height="361px" viewBox="0 0 361 361" style="enable-background:new 0 0 361 361;" xml:space="preserve">
|
||||
<style type="text/css">
|
||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
|
||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
||||
</style>
|
||||
<g id="Layer_5">
|
||||
</g>
|
||||
<g>
|
||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="180" y1="358.6047" x2="180" y2="7.7586">
|
||||
<stop offset="0" style="stop-color:#FA233B"/>
|
||||
<stop offset="1" style="stop-color:#FB5C74"/>
|
||||
</linearGradient>
|
||||
<path class="st0" d="M360,112.61c0-4.3,0-8.6-0.02-12.9c-0.02-3.62-0.06-7.24-0.16-10.86c-0.21-7.89-0.68-15.84-2.08-23.64
|
||||
c-1.42-7.92-3.75-15.29-7.41-22.49c-3.6-7.07-8.3-13.53-13.91-19.14c-5.61-5.61-12.08-10.31-19.15-13.91
|
||||
c-7.19-3.66-14.56-5.98-22.47-7.41c-7.8-1.4-15.76-1.87-23.65-2.08c-3.62-0.1-7.24-0.14-10.86-0.16C255.99,0,251.69,0,247.39,0
|
||||
H112.61c-4.3,0-8.6,0-12.9,0.02c-3.62,0.02-7.24,0.06-10.86,0.16C80.96,0.4,73,0.86,65.2,2.27c-7.92,1.42-15.28,3.75-22.47,7.41
|
||||
c-7.07,3.6-13.54,8.3-19.15,13.91c-5.61,5.61-10.31,12.07-13.91,19.14c-3.66,7.2-5.99,14.57-7.41,22.49
|
||||
c-1.4,7.8-1.87,15.76-2.08,23.64c-0.1,3.62-0.14,7.24-0.16,10.86C0,104.01,0,108.31,0,112.61v134.77c0,4.3,0,8.6,0.02,12.9
|
||||
c0.02,3.62,0.06,7.24,0.16,10.86c0.21,7.89,0.68,15.84,2.08,23.64c1.42,7.92,3.75,15.29,7.41,22.49c3.6,7.07,8.3,13.53,13.91,19.14
|
||||
c5.61,5.61,12.08,10.31,19.15,13.91c7.19,3.66,14.56,5.98,22.47,7.41c7.8,1.4,15.76,1.87,23.65,2.08c3.62,0.1,7.24,0.14,10.86,0.16
|
||||
c4.3,0.03,8.6,0.02,12.9,0.02h134.77c4.3,0,8.6,0,12.9-0.02c3.62-0.02,7.24-0.06,10.86-0.16c7.89-0.21,15.85-0.68,23.65-2.08
|
||||
c7.92-1.42,15.28-3.75,22.47-7.41c7.07-3.6,13.54-8.3,19.15-13.91c5.61-5.61,10.31-12.07,13.91-19.14
|
||||
c3.66-7.2,5.99-14.57,7.41-22.49c1.4-7.8,1.87-15.76,2.08-23.64c0.1-3.62,0.14-7.24,0.16-10.86c0.03-4.3,0.02-8.6,0.02-12.9V112.61
|
||||
z"/>
|
||||
</g>
|
||||
<g id="Glyph_2_">
|
||||
<g>
|
||||
<path class="st1" d="M254.5,55c-0.87,0.08-8.6,1.45-9.53,1.64l-107,21.59l-0.04,0.01c-2.79,0.59-4.98,1.58-6.67,3
|
||||
c-2.04,1.71-3.17,4.13-3.6,6.95c-0.09,0.6-0.24,1.82-0.24,3.62c0,0,0,109.32,0,133.92c0,3.13-0.25,6.17-2.37,8.76
|
||||
c-2.12,2.59-4.74,3.37-7.81,3.99c-2.33,0.47-4.66,0.94-6.99,1.41c-8.84,1.78-14.59,2.99-19.8,5.01
|
||||
c-4.98,1.93-8.71,4.39-11.68,7.51c-5.89,6.17-8.28,14.54-7.46,22.38c0.7,6.69,3.71,13.09,8.88,17.82
|
||||
c3.49,3.2,7.85,5.63,12.99,6.66c5.33,1.07,11.01,0.7,19.31-0.98c4.42-0.89,8.56-2.28,12.5-4.61c3.9-2.3,7.24-5.37,9.85-9.11
|
||||
c2.62-3.75,4.31-7.92,5.24-12.35c0.96-4.57,1.19-8.7,1.19-13.26l0-116.15c0-6.22,1.76-7.86,6.78-9.08c0,0,88.94-17.94,93.09-18.75
|
||||
c5.79-1.11,8.52,0.54,8.52,6.61l0,79.29c0,3.14-0.03,6.32-2.17,8.92c-2.12,2.59-4.74,3.37-7.81,3.99
|
||||
c-2.33,0.47-4.66,0.94-6.99,1.41c-8.84,1.78-14.59,2.99-19.8,5.01c-4.98,1.93-8.71,4.39-11.68,7.51
|
||||
c-5.89,6.17-8.49,14.54-7.67,22.38c0.7,6.69,3.92,13.09,9.09,17.82c3.49,3.2,7.85,5.56,12.99,6.6c5.33,1.07,11.01,0.69,19.31-0.98
|
||||
c4.42-0.89,8.56-2.22,12.5-4.55c3.9-2.3,7.24-5.37,9.85-9.11c2.62-3.75,4.31-7.92,5.24-12.35c0.96-4.57,1-8.7,1-13.26V64.46
|
||||
C263.54,58.3,260.29,54.5,254.5,55z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,44 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Library</h1>
|
||||
<playlistsView
|
||||
:playlists="$props.playlists"
|
||||
:is-logged-in="$props.isLoggedIn"
|
||||
@selected-playlist="( id ) => selectPlaylist( id )"
|
||||
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import playlistsView from '@/components/playlistsView.vue';
|
||||
import type {
|
||||
ReadFile
|
||||
} from '@/scripts/song';
|
||||
|
||||
const emits = defineEmits( [
|
||||
'selected-playlist',
|
||||
'custom-playlist'
|
||||
] );
|
||||
|
||||
const selectPlaylist = ( id: string ) => {
|
||||
emits( 'selected-playlist', id );
|
||||
};
|
||||
|
||||
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
|
||||
emits( 'custom-playlist', playlist );
|
||||
};
|
||||
|
||||
defineProps( {
|
||||
'playlists': {
|
||||
'default': [],
|
||||
'type': Array<any>,
|
||||
'required': true,
|
||||
},
|
||||
'isLoggedIn': {
|
||||
'default': false,
|
||||
'type': Boolean,
|
||||
'required': true,
|
||||
}
|
||||
} );
|
||||
</script>
|
||||
@@ -1,416 +0,0 @@
|
||||
<!-- eslint-disable no-undef -->
|
||||
<template>
|
||||
<div id="notifications">
|
||||
<div class="message-box" :class="[ location, size ]" :style="'z-index: ' + ( messageType === 'hide' ? '-1' : '1000' )">
|
||||
<div class="message-container" :class="messageType">
|
||||
<button class="close-notification" @click="handleNotifications();">
|
||||
<span class="material-symbols-outlined close-notification-icon">close</span>
|
||||
</button>
|
||||
<span v-if="messageType == 'hide'" class="material-symbols-outlined types hide">question_mark</span>
|
||||
<span v-else-if="messageType == 'ok'" class="material-symbols-outlined types" style="background-color: green;">done</span>
|
||||
<span v-else-if="messageType == 'error'" class="material-symbols-outlined types" style="background-color: red;">close</span>
|
||||
<span v-else-if="messageType == 'progress'" class="material-symbols-outlined types progress-spinner" style="background-color: blue;">progress_activity</span>
|
||||
<span v-else-if="messageType == 'info'" class="material-symbols-outlined types" style="background-color: lightblue;">info</span>
|
||||
<span v-else-if="messageType == 'warning'" class="material-symbols-outlined types" style="background-color: orangered;">warning</span>
|
||||
<p class="message" @click="notificationAction()">
|
||||
{{ notifications[ currentDID ] ? notifications[ currentDID ].message : '' }}
|
||||
</p>
|
||||
<div :class="'countdown countdown-' + messageType" :style="'width: ' + ( 100 - ( currentTime - notificationDisplayStartTime ) / ( notifications[ currentDID ] ? notifications[ currentDID ].showDuration : 1 ) / 10 ) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import router from '@/router';
|
||||
import {
|
||||
type Ref, onUnmounted, ref
|
||||
} from 'vue';
|
||||
|
||||
defineProps( {
|
||||
'location': {
|
||||
'type': String,
|
||||
'default': 'topleft',
|
||||
},
|
||||
'size': {
|
||||
'type': String,
|
||||
'default': 'default',
|
||||
}
|
||||
// Size options: small, default (default option), big, bigger, huge
|
||||
} );
|
||||
|
||||
interface Notification {
|
||||
'message': string;
|
||||
'showDuration': number;
|
||||
'messageType': string;
|
||||
'priority': string;
|
||||
'id': number;
|
||||
'redirect'?: string;
|
||||
'openInNewTab'?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationList {
|
||||
[ key: string ]: Notification
|
||||
}
|
||||
|
||||
const notifications: Ref<NotificationList> = ref( {} );
|
||||
const queue: Ref<number[]> = ref( [] );
|
||||
const currentDID: Ref<number> = ref( 0 );
|
||||
const messageType: Ref<string> = ref( 'hide' );
|
||||
const currentID = ref( {
|
||||
'critical': 0,
|
||||
'medium': 1000,
|
||||
'low': 10000
|
||||
} );
|
||||
const notificationDisplayStartTime: Ref<number> = ref( 0 );
|
||||
const currentTime: Ref<number> = ref( 0 );
|
||||
|
||||
let progressBar = 0;
|
||||
let notificationTimeout = 0;
|
||||
|
||||
const notificationAction = () => {
|
||||
if ( notifications.value[ currentDID.value ] ) {
|
||||
if ( notifications.value[ currentDID.value ].redirect ) {
|
||||
if ( notifications.value[ currentDID.value ].openInNewTab ) {
|
||||
window.open( notifications.value[ currentDID.value ].redirect ?? '' );
|
||||
} else {
|
||||
router.push( notifications.value[ currentDID.value ].redirect ?? '' );
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a notification that will be displayed using the internal notification scheduler
|
||||
* @param {string} message The message to show. Can only be plain text (no HTML)
|
||||
* @param {number} showDuration The duration in seconds for which to show the notification
|
||||
* @param {string} msgType Type of notification to show. Will dictate how it looks: 'ok', 'error', 'info', 'warn', 'progress'
|
||||
* @param {string} priority The priority of the message: 'low', 'normal', 'critical'
|
||||
* @returns {number}
|
||||
*/
|
||||
const createNotification = (
|
||||
message: string, showDuration: number, msgType: string, priority: string, redirect?: string, openInNewTab?: boolean
|
||||
): number => {
|
||||
/*
|
||||
Takes a notification options array that contains: message, showDuration (in seconds), msgType (ok, error, progress, info) and priority (low, normal, critical).
|
||||
Returns a notification ID which can be used to cancel the notification. The component will throttle notifications and display
|
||||
one at a time and prioritize messages with higher priority. Use vue refs to access these methods.
|
||||
*/
|
||||
let id = 0;
|
||||
|
||||
if ( priority === 'critical' ) {
|
||||
currentID.value[ 'critical' ] += 1;
|
||||
id = currentID.value[ 'critical' ];
|
||||
} else if ( priority === 'normal' ) {
|
||||
currentID.value[ 'medium' ] += 1;
|
||||
id = currentID.value[ 'medium' ];
|
||||
} else if ( priority === 'low' ) {
|
||||
currentID.value[ 'low' ] += 1;
|
||||
id = currentID.value[ 'low' ];
|
||||
}
|
||||
|
||||
notifications.value[ id ] = {
|
||||
'message': message,
|
||||
'showDuration': showDuration,
|
||||
'messageType': msgType,
|
||||
'priority': priority,
|
||||
'id': id,
|
||||
'redirect': redirect,
|
||||
'openInNewTab': openInNewTab
|
||||
};
|
||||
queue.value.push( id );
|
||||
console.log( 'scheduled notification: ' + id + ' (' + message + ')' );
|
||||
|
||||
if ( ( new Date().getTime() - notificationDisplayStartTime.value ) / 1000 >= ( notifications.value[ currentDID.value ] ? notifications.value[ currentDID.value ].showDuration : 0 ) || messageType.value === 'hide' ) {
|
||||
handleNotifications();
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a notification's message after creating it
|
||||
* @param {number} id The notification ID returned by createNotification
|
||||
* @param {string} message The new message
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateNotification = ( id: number, message: string ): void => {
|
||||
if ( notifications.value[ id ] ) {
|
||||
notifications.value[ id ].message = message;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Delete a previously created notification
|
||||
* @param {string} id The notification ID returned by createNotification
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const cancelNotification = ( id: number ): undefined => {
|
||||
try {
|
||||
delete notifications.value[ id ];
|
||||
} catch ( error ) {
|
||||
console.log( 'notification to be deleted is nonexistent or currently being displayed' );
|
||||
}
|
||||
|
||||
try {
|
||||
queue.value.splice( queue.value.indexOf( id ), 1 );
|
||||
} catch {
|
||||
console.debug( 'queue empty' );
|
||||
}
|
||||
|
||||
if ( currentDID.value == id ) {
|
||||
try {
|
||||
clearTimeout( notificationTimeout );
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
handleNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotifications = () => {
|
||||
notificationDisplayStartTime.value = new Date().getTime();
|
||||
queue.value.sort();
|
||||
|
||||
if ( queue.value.length > 0 ) {
|
||||
if ( currentDID.value !== 0 ) {
|
||||
delete notifications.value[ currentDID.value ];
|
||||
}
|
||||
|
||||
currentDID.value = notifications.value[ queue.value[ 0 ] ][ 'id' ];
|
||||
messageType.value = notifications.value[ queue.value[ 0 ] ].messageType;
|
||||
queue.value.reverse();
|
||||
queue.value.pop();
|
||||
progressBar = setInterval( progressBarHandler, 25 );
|
||||
notificationTimeout = setTimeout( () => {
|
||||
handleNotifications();
|
||||
}, notifications.value[ currentDID.value ].showDuration * 1000 );
|
||||
} else {
|
||||
try {
|
||||
clearInterval( progressBar );
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
messageType.value = 'hide';
|
||||
}
|
||||
};
|
||||
|
||||
const progressBarHandler = () => {
|
||||
currentTime.value = new Date().getTime();
|
||||
};
|
||||
|
||||
onUnmounted( () => {
|
||||
try {
|
||||
clearInterval( progressBar );
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
try {
|
||||
clearInterval( notificationTimeout );
|
||||
} catch ( err ) { /* empty */ }
|
||||
} );
|
||||
|
||||
defineExpose( {
|
||||
createNotification,
|
||||
cancelNotification,
|
||||
updateNotification
|
||||
} );
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-box {
|
||||
position: fixed;
|
||||
z-index: -100;
|
||||
color: white;
|
||||
transition: all 0.5s;
|
||||
width: 95vw;
|
||||
right: 2.5vw;
|
||||
top: 1vh;
|
||||
height: 10vh;
|
||||
}
|
||||
|
||||
.close-notification {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: none;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-notification-icon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
transition: all 0.5s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.types {
|
||||
color: white;
|
||||
border-radius: 100%;
|
||||
margin-right: auto;
|
||||
margin-left: 5%;
|
||||
padding: 1.5%;
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-right: calc( 5% + 30px );
|
||||
text-align: end;
|
||||
height: 90%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background-color: rgb(1, 71, 1);
|
||||
}
|
||||
|
||||
.countdown-ok {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: rgb(114, 1, 1);
|
||||
}
|
||||
|
||||
.countdown-error {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: rgb(44, 112, 151);
|
||||
}
|
||||
|
||||
.countdown-info {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
.countdown-warning {
|
||||
background-color: orangered;
|
||||
}
|
||||
|
||||
.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
z-index: 100;
|
||||
background-color: rgb(0, 0, 99);
|
||||
}
|
||||
|
||||
.countdown-ok {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.progress-spinner {
|
||||
animation: spin 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate( 0deg );
|
||||
}
|
||||
to {
|
||||
transform: rotate( 720deg );
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 750px) {
|
||||
|
||||
.default {
|
||||
height: 10vh;
|
||||
width: 32vw;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 7vh;
|
||||
width: 27vw;
|
||||
}
|
||||
|
||||
.big {
|
||||
height: 12vh;
|
||||
width: 38vw;
|
||||
}
|
||||
|
||||
.bigger {
|
||||
height: 15vh;
|
||||
width: 43vw;
|
||||
}
|
||||
|
||||
.huge {
|
||||
height: 20vh;
|
||||
width: 50vw;
|
||||
}
|
||||
|
||||
.topleft {
|
||||
top: 3vh;
|
||||
left: 0.5vw;
|
||||
}
|
||||
|
||||
.topright {
|
||||
top: 3vh;
|
||||
right: 0.5vw;
|
||||
}
|
||||
|
||||
.bottomright {
|
||||
bottom: 3vh;
|
||||
right: 0.5vw;
|
||||
}
|
||||
|
||||
.bottomleft {
|
||||
bottom: 3vh;
|
||||
right: 0.5vw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (min-width: 1500px) {
|
||||
.default {
|
||||
height: 10vh;
|
||||
width: 15vw;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 7vh;
|
||||
width: 11vw;
|
||||
}
|
||||
|
||||
.big {
|
||||
height: 12vh;
|
||||
width: 17vw;
|
||||
}
|
||||
|
||||
.bigger {
|
||||
height: 15vh;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
.huge {
|
||||
height: 20vh;
|
||||
width: 25vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,473 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Queue</h1>
|
||||
<input
|
||||
id="more-songs"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
class="small-buttons"
|
||||
>
|
||||
<button class="small-buttons" title="Load selected files" @click="addNewSongs()">
|
||||
<span class="material-symbols-outlined">upload</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="$props.isLoggedIntoAppleMusic"
|
||||
class="small-buttons"
|
||||
title="Search Apple Music for the song"
|
||||
@click="openSearch()"
|
||||
>
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
</button>
|
||||
<button class="small-buttons" title="Clear the playlist" @click="clearPlaylist()">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
<button title="Transmit additional information" class="small-buttons" @click="sendAdditionalInfo()">
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
<p v-if="!hasSelectedSongs">
|
||||
Please select at least one song to proceed
|
||||
</p>
|
||||
<div id="pl-box" class="playlist-box">
|
||||
<!-- TODO: Allow editing additionalInfo. Think also how to make it persist over reloads... Export to JSON and then best-guess add them? Very easy for Apple Music 'cause ID, but how for local songs? Maybe using retrieved ID from Apple Music? -->
|
||||
<!-- TODO: Handle long AppleMusic Playlists, as AppleMusic doesn't automatically load all songs of a playlist -->
|
||||
<div
|
||||
v-for="song in computedPlaylist"
|
||||
:key="song.id"
|
||||
class="song"
|
||||
:class="( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && isPlaying ? 'playing' : ' not-playing' )
|
||||
+ ( ( !isPlaying && ( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) ) ) ? ' active-song' : '' )"
|
||||
>
|
||||
<img
|
||||
v-if="song.cover"
|
||||
:src="song.cover"
|
||||
alt="Song cover"
|
||||
class="song-cover"
|
||||
>
|
||||
<span v-else class="material-symbols-outlined song-cover">music_note</span>
|
||||
<div v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && $props.isPlaying" class="playing-symbols">
|
||||
<div class="playing-symbols-wrapper">
|
||||
<div id="bar-1" class="playing-bar"></div>
|
||||
<div id="bar-2" class="playing-bar"></div>
|
||||
<div id="bar-3" class="playing-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' )" class="material-symbols-outlined play-icon" @click="control( 'play' )">play_arrow</span>
|
||||
<span v-else class="material-symbols-outlined play-icon" @click="play( song.id )">play_arrow</span>
|
||||
<span class="material-symbols-outlined pause-icon" @click="control( 'pause' )">pause</span>
|
||||
<span
|
||||
v-if="canBeMoved( 'up', song.id )"
|
||||
class="material-symbols-outlined move-icon"
|
||||
title="Move song up"
|
||||
@click="moveSong( song.id, 'up' )"
|
||||
>arrow_upward</span>
|
||||
<span
|
||||
v-if="canBeMoved( 'down', song.id )"
|
||||
class="material-symbols-outlined move-icon"
|
||||
title="Move song down"
|
||||
@click="moveSong( song.id, 'down' )"
|
||||
>arrow_downward</span>
|
||||
<h3 class="song-title">
|
||||
{{ song.title }}
|
||||
</h3>
|
||||
<div>
|
||||
<input
|
||||
v-model="song.additionalInfo"
|
||||
type="text"
|
||||
placeholder="Additional information for remote display"
|
||||
title="Additional information for remote display"
|
||||
@focusin="kbControl( 'on' )"
|
||||
@focusout="kbControl( 'off' )"
|
||||
>
|
||||
<p class="playing-in">
|
||||
{{ getTimeUntil( song ) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="canBeMoved( 'down', song.id ) || canBeMoved( 'up', song.id )"
|
||||
class="small-buttons"
|
||||
title="Remove this song from the queue"
|
||||
@click="deleteSong( song.id )"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<searchView ref="search" @selected-song="( song ) => { addNewSongsAppleMusic( song ) }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Add logout button
|
||||
import type {
|
||||
AppleMusicSongData, ReadFile, Song
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
computed, ref
|
||||
} from 'vue';
|
||||
import searchView from './searchView.vue';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/stores/userStore';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const search = ref( searchView );
|
||||
const props = defineProps( {
|
||||
'playlist': {
|
||||
'default': [],
|
||||
'required': true,
|
||||
'type': Array<Song>
|
||||
},
|
||||
'currentlyPlaying': {
|
||||
'default': 0,
|
||||
'required': true,
|
||||
'type': Number,
|
||||
},
|
||||
'isPlaying': {
|
||||
'default': true,
|
||||
'required': true,
|
||||
'type': Boolean,
|
||||
},
|
||||
'pos': {
|
||||
'default': 0,
|
||||
'required': false,
|
||||
'type': Number,
|
||||
},
|
||||
'isLoggedIntoAppleMusic': {
|
||||
'default': false,
|
||||
'required': true,
|
||||
'type': Boolean,
|
||||
}
|
||||
} );
|
||||
const hasSelectedSongs = ref( true );
|
||||
const computedPlaylist = computed( () => {
|
||||
let pl: Song[] = [];
|
||||
|
||||
// ( document.getElementById( 'pl-box' ) as HTMLDivElement ).scrollTo( { behavior: 'smooth', top: 0 } );
|
||||
for ( let i = props.currentlyPlaying; i < props.playlist.length; i++ ) {
|
||||
pl.push( props.playlist[ i ] );
|
||||
}
|
||||
|
||||
return pl;
|
||||
} );
|
||||
|
||||
const kbControl = ( action: string ) => {
|
||||
if ( action === 'off' ) {
|
||||
userStore.setKeyboardUsageStatus( false );
|
||||
} else {
|
||||
userStore.setKeyboardUsageStatus( true );
|
||||
}
|
||||
};
|
||||
|
||||
const openSearch = () => {
|
||||
if ( search.value ) {
|
||||
search.value.controlSearch( 'show' );
|
||||
}
|
||||
};
|
||||
|
||||
const canBeMoved = computed( () => {
|
||||
return ( direction: movementDirection, songID: string ): boolean => {
|
||||
let id = 0;
|
||||
|
||||
for ( let song in props.playlist ) {
|
||||
if ( props.playlist[ song ].id === songID ) {
|
||||
id = parseInt( song );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( direction === 'up' ) {
|
||||
if ( props.currentlyPlaying + 1 === id || props.currentlyPlaying === id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
if ( id === props.playlist.length - 1 || props.currentlyPlaying === id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
} );
|
||||
const getTimeUntil = computed( () => {
|
||||
return ( song: Song ) => {
|
||||
let timeRemaining = 0;
|
||||
|
||||
for ( let i = props.currentlyPlaying; i < Object.keys( props.playlist ).length; i++ ) {
|
||||
if ( props.playlist[ i ] == song ) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeRemaining += props.playlist[ i ].duration;
|
||||
}
|
||||
|
||||
if ( props.isPlaying ) {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Currently playing';
|
||||
} else {
|
||||
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min';
|
||||
}
|
||||
} else {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Plays next';
|
||||
} else {
|
||||
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min after starting to play';
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
const deleteSong = ( songID: string ) => {
|
||||
for ( const song in props.playlist ) {
|
||||
if ( props.playlist[ song ].id === songID ) {
|
||||
emits( 'delete-song', parseInt( song ) );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearPlaylist = () => {
|
||||
emits( 'clear-playlist', '' );
|
||||
};
|
||||
|
||||
|
||||
const control = ( action: string ) => {
|
||||
emits( 'control', action );
|
||||
};
|
||||
|
||||
const play = ( song: string ) => {
|
||||
emits( 'play-song', song );
|
||||
};
|
||||
|
||||
const addNewSongs = () => {
|
||||
const fileURLList: ReadFile[] = [];
|
||||
const allFiles = ( document.getElementById( 'more-songs' ) as HTMLInputElement ).files ?? [];
|
||||
|
||||
if ( allFiles.length > 0 ) {
|
||||
hasSelectedSongs.value = true;
|
||||
|
||||
for ( let file = 0; file < allFiles.length; file++ ) {
|
||||
fileURLList.push( {
|
||||
'url': URL.createObjectURL( allFiles[ file ] ),
|
||||
'filename': allFiles[ file ].name
|
||||
} );
|
||||
}
|
||||
|
||||
emits( 'add-new-songs', fileURLList );
|
||||
} else {
|
||||
hasSelectedSongs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addNewSongsAppleMusic = ( songData: AppleMusicSongData ) => {
|
||||
const song: Song = {
|
||||
'artist': songData.attributes.artistName,
|
||||
'cover': songData.attributes.artwork.url.replace( '{w}', String( songData.attributes.artwork.width ) ).replace( '{h}', String( songData.attributes.artwork.height ) ),
|
||||
'duration': songData.attributes.durationInMillis / 1000,
|
||||
'id': songData.id,
|
||||
'origin': 'apple-music',
|
||||
'title': songData.attributes.name
|
||||
};
|
||||
|
||||
emits( 'add-new-songs-apple-music', song );
|
||||
};
|
||||
|
||||
type movementDirection = 'up' | 'down';
|
||||
|
||||
const moveSong = ( songID: string, direction: movementDirection ) => {
|
||||
let newSongPos = 0;
|
||||
let hasFoundSongToMove = false;
|
||||
|
||||
for ( let el in props.playlist ) {
|
||||
if ( props.playlist[ el ].id === songID ) {
|
||||
const currPos = parseInt( el );
|
||||
|
||||
newSongPos = currPos + ( direction === 'up' ? -1 : 1 );
|
||||
hasFoundSongToMove = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( hasFoundSongToMove ) {
|
||||
emits( 'playlist-reorder', {
|
||||
'songID': songID,
|
||||
'newPos': newSongPos
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
const sendAdditionalInfo = () => {
|
||||
emits( 'send-additional-info' );
|
||||
};
|
||||
|
||||
const emits = defineEmits( [
|
||||
'play-song',
|
||||
'control',
|
||||
'playlist-reorder',
|
||||
'add-new-songs',
|
||||
'add-new-songs-apple-music',
|
||||
'delete-song',
|
||||
'clear-playlist',
|
||||
'send-additional-info'
|
||||
] );
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlist-box {
|
||||
height: calc( 100% - 150px );
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.song {
|
||||
border: solid var( --primary-color ) 1px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
margin: 2px;
|
||||
padding: 1vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.song .song-cover {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
.song img.song-cover {
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 1vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
background-color: rgba( 0, 0, 0, 0.6 );
|
||||
}
|
||||
|
||||
.playing-symbols-wrapper {
|
||||
width: 5rem;
|
||||
height: 6rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.playing-bar {
|
||||
height: 60%;
|
||||
background-color: white;
|
||||
width: 10%;
|
||||
border-radius: 50px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#bar-1 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#bar-2 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
#bar-3 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes music-playing {
|
||||
0% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
50% {
|
||||
transform: scaleY( 0.5 );
|
||||
}
|
||||
100% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
}
|
||||
|
||||
.play-icon, .pause-icon {
|
||||
display: none;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.playing:hover .pause-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playing:hover .playing-symbols {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song:hover .song-cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.not-playing:hover .play-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.active-song .pause-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.active-song.not-playing .song-cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.active-song .song-image, .active-song:hover .pause-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.move-icon {
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.small-buttons {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.small-buttons .material-symbols-outlined {
|
||||
font-size: 1.5rem;
|
||||
color: var( --primary-color );
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.small-buttons:hover .material-symbols-outlined {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<div class="playlists">
|
||||
<h3 style="width: fit-content;">
|
||||
Your playlists
|
||||
</h3>
|
||||
<div v-if="( $props.playlists ? $props.playlists.length < 1 : true ) && $props.isLoggedIn">
|
||||
Loading...
|
||||
<!-- TODO: Make prettier -->
|
||||
</div>
|
||||
<div v-else-if="!$props.isLoggedIn" class="not-logged-in">
|
||||
<p>
|
||||
You are not logged into Apple Music. We therefore can't show you your playlists.
|
||||
<a href="" title="Refreshes the page, allowing you to log in">Change that</a>
|
||||
</p>
|
||||
<p>Use the button below to load songs from your local disk</p>
|
||||
<input
|
||||
id="pl-loader"
|
||||
class="pl-loader-button"
|
||||
type="file"
|
||||
multiple="true"
|
||||
accept="audio/*"
|
||||
><br>
|
||||
<button id="load-button" class="pl-loader-button" @click="loadPlaylistFromDisk()">
|
||||
Load
|
||||
</button>
|
||||
<p v-if="!hasSelectedSongs">
|
||||
Please select at least one song to proceed!
|
||||
</p>
|
||||
</div>
|
||||
<div class="playlist-wrapper">
|
||||
<div
|
||||
v-for="pl in $props.playlists"
|
||||
:key="pl.id"
|
||||
class="playlist"
|
||||
@click="selectPlaylist( pl.id )"
|
||||
>
|
||||
{{ pl.attributes.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ReadFile
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const hasSelectedSongs = ref( true );
|
||||
|
||||
defineProps( {
|
||||
'playlists': {
|
||||
'default': [],
|
||||
'type': Array<any>,
|
||||
'required': true,
|
||||
},
|
||||
'isLoggedIn': {
|
||||
'default': false,
|
||||
'type': Boolean,
|
||||
'required': true,
|
||||
}
|
||||
} );
|
||||
|
||||
const loadPlaylistFromDisk = () => {
|
||||
const fileURLList: ReadFile[] = [];
|
||||
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
|
||||
|
||||
if ( allFiles.length > 0 ) {
|
||||
hasSelectedSongs.value = true;
|
||||
|
||||
for ( let file = 0; file < allFiles.length; file++ ) {
|
||||
fileURLList.push( {
|
||||
'url': URL.createObjectURL( allFiles[ file ] ),
|
||||
'filename': allFiles[ file ].name
|
||||
} );
|
||||
}
|
||||
|
||||
emits( 'custom-playlist', fileURLList );
|
||||
} else {
|
||||
hasSelectedSongs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const emits = defineEmits( [
|
||||
'selected-playlist',
|
||||
'custom-playlist'
|
||||
] );
|
||||
|
||||
const selectPlaylist = ( id: string ) => {
|
||||
emits( 'selected-playlist', id );
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlists {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playlist-wrapper {
|
||||
width: 85%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.playlist {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border: solid var( --primary-color ) 1px;
|
||||
border-radius: 5px;
|
||||
margin: 1px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pl-loader-button {
|
||||
background-color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 5px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#load-button {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.not-logged-in {
|
||||
width: 80%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,304 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div :class="'popup-backdrop' + ( isShowingPopup ? '' : ' hidden' )" :style="'transform-origin: ' + transformOriginVertical + ';'">
|
||||
<div class="popup-main">
|
||||
<span class="material-symbols-outlined close-icon" @click="closePopup()">close</span>
|
||||
<h2>{{ popupContent.title }}</h2>
|
||||
<div v-if="popupContent.popupType === 'information' || popupContent.popupType === 'confirmation'" class="popup-content">
|
||||
<p v-html="popupContent.subtitle"></p>
|
||||
</div>
|
||||
<div v-else class="popup-content">
|
||||
<p v-if="isShowingIncomplete" class="incomplete-message">{{ popupContent.incompleteMessage ? popupContent.incompleteMessage : 'Some entries are not filled out. Please fill them out to proceed.' }}</p>
|
||||
<p v-html="popupContent.subtitle"></p>
|
||||
<div v-for="content in popupContent.data" v-bind:key="content.id" class="popup-content-wrapper">
|
||||
<div v-if="content.dataType === 'text'">
|
||||
<label :for="'text-' + content.id">{{ content.name }}</label><br>
|
||||
<input type="text" :id="'text-' + content.id" v-model="data[ content.id ]" class="input">
|
||||
</div>
|
||||
<div v-else-if="content.dataType === 'number'">
|
||||
<label :for="'number-' + content.id">{{ content.name }}</label><br>
|
||||
<input type="number" :id="'number-' + content.id" v-model="data[ content.id ]" class="input">
|
||||
</div>
|
||||
<div v-else-if="content.dataType === 'checkbox'">
|
||||
<label :for="'checkbox-' + content.id">{{ content.name }}</label><br>
|
||||
<label class="switch">
|
||||
<input type="checkbox" v-model="data[ content.id ]">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div v-else-if="content.dataType === 'textbox'">
|
||||
<label :for="'textarea-' + content.id">{{ content.name }}</label><br>
|
||||
<textarea :id="'textarea-' + content.id" v-model="data[ content.id ]" class="textarea"></textarea>
|
||||
</div>
|
||||
<div v-else-if="content.dataType === 'colour'">
|
||||
<label :for="'colour-' + content.id">{{ content.name }}</label><br>
|
||||
<input type="text" :id="'colour-' + content.id" v-model="data[ content.id ]" class="input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top: 20px;">
|
||||
<button @click="closePopup()" style="margin-right: 10px;" class="fancy-button" v-if="popupContent.popupType === 'confirmation'">Cancel</button>
|
||||
<button @click="closePopupReturn()" class="fancy-button">Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
type PopupType = 'confirmation' | 'information' | 'input';
|
||||
|
||||
interface PopupData {
|
||||
/**
|
||||
* What to display to the user in front of the input field
|
||||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* The type of data to display
|
||||
*/
|
||||
dataType: 'text' | 'number' | 'checkbox' | 'textbox' | 'colour';
|
||||
|
||||
/**
|
||||
* ID that is used for internal usage only. May only contain alphanumerical characters, as well as dashes and underscores
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
interface PopupContent {
|
||||
/**
|
||||
* The title shown in big letters at the top of the popup
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* (OPTIONAL) The subtitle shown to the user in normal sized letters below title
|
||||
*/
|
||||
subtitle?: string;
|
||||
|
||||
/**
|
||||
* (OPTIONAL) The message to show, if the user has not filled out all fields
|
||||
*/
|
||||
incompleteMessage?: string;
|
||||
|
||||
/**
|
||||
* The type of popup (i.e. what it is for)
|
||||
*/
|
||||
popupType: PopupType;
|
||||
|
||||
/**
|
||||
* (REQUIRED ONLY when popupType === 'input') The input fields to show
|
||||
*/
|
||||
data?: PopupData[];
|
||||
}
|
||||
|
||||
interface Data {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
import "@melloware/coloris/dist/coloris.css";
|
||||
import { ref, type Ref } from 'vue';
|
||||
import Coloris from '@melloware/coloris';
|
||||
|
||||
Coloris.init();
|
||||
|
||||
const isShowingPopup = ref( false );
|
||||
const transformOriginVertical = ref( '50% 50%' );
|
||||
const data: Ref<Data> = ref( {} );
|
||||
const isShowingIncomplete = ref( false );
|
||||
|
||||
const popupContent: Ref<PopupContent> = ref( {
|
||||
title: 'Undefined popup title',
|
||||
popupType: 'information',
|
||||
subtitle: 'This popup was not configured correctly during development. Please send a bug-report at <a href="https://support.janishutz.com/index.php?a=add&category=7" target="_blank">support.janishutz.com</a> and inform us about where exactly you encountered this popup! We are sorry for the inconvenience'
|
||||
} );
|
||||
|
||||
const closePopup = () => {
|
||||
isShowingPopup.value = false;
|
||||
Coloris.close();
|
||||
}
|
||||
|
||||
const closePopupReturn = () => {
|
||||
for ( let el in popupContent.value.data ) {
|
||||
if ( !data.value[ popupContent.value.data[ parseInt( el ) ].id ] && popupContent.value.data[ parseInt( el ) ].dataType !== 'checkbox' ) {
|
||||
isShowingIncomplete.value = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
closePopup();
|
||||
if ( popupContent.value.popupType === 'confirmation' ) {
|
||||
emit( 'update', true );
|
||||
} else {
|
||||
emit( 'update', data.value );
|
||||
}
|
||||
}
|
||||
|
||||
const openPopup = ( popupConfig: PopupContent, transformOrigin?: string ) => {
|
||||
if ( transformOrigin ) {
|
||||
transformOriginVertical.value = transformOrigin;
|
||||
} else {
|
||||
transformOriginVertical.value = '50% 50%';
|
||||
}
|
||||
data.value = {};
|
||||
for ( let el in popupConfig.data ) {
|
||||
if ( !popupConfig.data[ parseInt( el ) ].id ) {
|
||||
console.warn( '[ popup ] Missing ID for input with name "' + popupConfig.data[ parseInt( el ) ].name + '"!' );
|
||||
} else if ( popupConfig.data[ parseInt( el ) ].dataType === 'colour' ) {
|
||||
Coloris( { el: '#colour-' + popupConfig.data[ parseInt( el ) ].id } );
|
||||
}
|
||||
}
|
||||
isShowingPopup.value = true;
|
||||
popupContent.value = popupConfig;
|
||||
for ( const el in popupContent.value.data ) {
|
||||
if ( popupContent.value.data[ parseInt( el ) ].dataType === 'checkbox' ) {
|
||||
data.value[ popupContent.value.data[ parseInt( el ) ].id ] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose( {
|
||||
openPopup,
|
||||
} );
|
||||
|
||||
const emit = defineEmits( [ 'update' ] );
|
||||
</script>
|
||||
|
||||
|
||||
<style scoped>
|
||||
.popup-backdrop {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
position: fixed;
|
||||
background-color: var( --overlay-color );
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s;
|
||||
transform: scale(1);
|
||||
z-index: 99;
|
||||
}
|
||||
|
||||
.incomplete-message {
|
||||
color: red;
|
||||
font-weight: 300;
|
||||
font-style: italic;
|
||||
margin-top: 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
.popup-main {
|
||||
width: 40%;
|
||||
height: 50%;
|
||||
background-color: var( --secondary-color );
|
||||
padding: 2.5%;
|
||||
border-radius: 20px;
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
font-size: 2rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.popup-content {
|
||||
position: unset;
|
||||
height: 60%;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 80%;
|
||||
resize: vertical;
|
||||
min-height: 30px;
|
||||
border-radius: 10px;
|
||||
border: solid var( --primary-color ) 1px;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.input {
|
||||
padding: 5px;
|
||||
border-radius: 10px;
|
||||
border: solid var( --primary-color ) 1px;
|
||||
}
|
||||
|
||||
.popup-content-wrapper {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
/* https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_switch */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 60px;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: #ccc;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 26px;
|
||||
width: 26px;
|
||||
left: 4px;
|
||||
bottom: 4px;
|
||||
background-color: white;
|
||||
-webkit-transition: .4s;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: #2196F3;
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px #2196F3;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
-webkit-transform: translateX(26px);
|
||||
-ms-transform: translateX(26px);
|
||||
transform: translateX(26px);
|
||||
}
|
||||
|
||||
/* Rounded sliders */
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,265 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div id="search-bar" :class="showsSearch ? 'search-shown' : ''">
|
||||
<div id="search-box-wrapper">
|
||||
<input type="text" v-model="searchText" id="search-box" placeholder="Type to search..." @keyup="e => { keyHandler( e ) }">
|
||||
<div class="symbol-wrapper" id="search-symbol-wrapper">
|
||||
<span class="material-symbols-outlined search-symbol" @click="search()">search</span>
|
||||
</div>
|
||||
<div :class="'search-result-wrapper' + ( searchText.length > 0 ? ' show-search-results' : '' )">
|
||||
<div v-for="result in searchResults" v-bind:key="result.id"
|
||||
:class="'search-result' + ( selectedProduct === result.id ? ' prod-selected' : '' )"
|
||||
@mouseenter="removeSelection()" @click="select( result )">
|
||||
<div :style="'background-image: url(' + result.attributes.artwork.url.replace( '{w}', '500' ).replace( '{h}', '500' ) + ');'" class="search-product-image"></div>
|
||||
<div class="search-product-name"><p><b>{{ result.attributes.name }}</b> <i>by {{ result.attributes.artistName }}</i></p></div>
|
||||
</div>
|
||||
<div v-if="searchResults.length === 0 && searchText.length < 3">
|
||||
<p>No results to show</p>
|
||||
</div>
|
||||
<div v-else-if="searchText.length < 3">
|
||||
<p>Enter at least three characters to start searching</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="symbol-wrapper">
|
||||
<span class="material-symbols-outlined search-symbol" @click="controlSearch( 'hide' )" id="close-icon">close</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<script setup lang="ts">
|
||||
import MusicKitJSWrapper from '@/scripts/music-player';
|
||||
import type { AppleMusicSongData } from '@/scripts/song';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { ref, type Ref } from 'vue';
|
||||
|
||||
const showsSearch = ref( false );
|
||||
const searchText = ref( '' );
|
||||
const selectedProduct = ref( '' );
|
||||
let selectedProductIndex = -1;
|
||||
const player = new MusicKitJSWrapper();
|
||||
|
||||
const updateSearchResults = () => {
|
||||
if ( searchText.value.length > 2 ) {
|
||||
player.findSongOnAppleMusic( searchText.value ).then( data => {
|
||||
searchResults.value = data.data.results.songs.data ?? [];
|
||||
selectedProductIndex = -1;
|
||||
selectedProduct.value = '';
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
const searchResults: Ref<AppleMusicSongData[]> = ref( [] );
|
||||
const userStore = useUserStore();
|
||||
|
||||
const controlSearch = ( action: string ) => {
|
||||
if ( action === 'show' ) {
|
||||
userStore.setKeyboardUsageStatus( true );
|
||||
showsSearch.value = true;
|
||||
setTimeout( () => {
|
||||
const searchBox = document.getElementById( 'search-box' ) as HTMLInputElement;
|
||||
searchBox.focus();
|
||||
}, 500 );
|
||||
} else if ( action === 'hide' ) {
|
||||
userStore.setKeyboardUsageStatus( false );
|
||||
showsSearch.value = false;
|
||||
}
|
||||
searchText.value = '';
|
||||
removeSelection();
|
||||
}
|
||||
|
||||
const removeSelection = () => {
|
||||
selectedProduct.value = '';
|
||||
selectedProductIndex = -1;
|
||||
}
|
||||
|
||||
const keyHandler = ( e: KeyboardEvent ) => {
|
||||
if ( e.key === 'Escape' ) {
|
||||
controlSearch( 'hide' );
|
||||
} else if ( e.key === 'Enter' ) {
|
||||
e.preventDefault();
|
||||
if ( selectedProductIndex >= 0 ) {
|
||||
select( searchResults.value[ selectedProductIndex ] );
|
||||
controlSearch( 'hide' );
|
||||
} else {
|
||||
search();
|
||||
}
|
||||
} else if ( e.key === 'ArrowDown' ) {
|
||||
e.preventDefault();
|
||||
if ( selectedProductIndex < searchResults.value.length - 1 ) {
|
||||
selectedProductIndex += 1;
|
||||
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
|
||||
}
|
||||
} else if ( e.key === 'ArrowUp' ) {
|
||||
e.preventDefault();
|
||||
if ( selectedProductIndex > 0 ) {
|
||||
selectedProductIndex -= 1;
|
||||
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
|
||||
} else {
|
||||
removeSelection();
|
||||
}
|
||||
} else {
|
||||
updateSearchResults();
|
||||
}
|
||||
}
|
||||
|
||||
const select = ( song: AppleMusicSongData ) => {
|
||||
emits( 'selected-song', song );
|
||||
controlSearch( 'hide' );
|
||||
}
|
||||
|
||||
const search = () => {
|
||||
emits( 'selected-song', searchResults.value[ 0 ] );
|
||||
controlSearch( 'hide' );
|
||||
}
|
||||
|
||||
const emits = defineEmits( [ 'selected-song' ] );
|
||||
|
||||
|
||||
defineExpose( {
|
||||
controlSearch
|
||||
} );
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#search-bar {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
top: -15vh;
|
||||
background-color: var( --accent-background );
|
||||
height: 15vh;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
transition: all 1s;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
#search-bar.search-shown {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
#search-box {
|
||||
width: calc(100% - 20px);
|
||||
padding: 10px;
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
#search-box-wrapper {
|
||||
width: 60vw;
|
||||
position: relative;
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1000px) {
|
||||
#search-box-wrapper {
|
||||
width: 45vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 1500px) {
|
||||
#search-box-wrapper {
|
||||
width: 30vw;
|
||||
}
|
||||
}
|
||||
|
||||
#search-symbol-wrapper {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 3px;
|
||||
}
|
||||
|
||||
.symbol-wrapper {
|
||||
display: flex;
|
||||
height: 3rem;
|
||||
width: 3rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-symbol {
|
||||
color: black;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: 200%;
|
||||
cursor: pointer;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.search-symbol:hover {
|
||||
font-size: 250%;
|
||||
}
|
||||
|
||||
#close-icon {
|
||||
margin-left: 5px;
|
||||
color: var( --primary-color );
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.search-result-wrapper {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 2px;
|
||||
padding-bottom: 5px;
|
||||
left: 0;
|
||||
text-decoration: none;
|
||||
background-color: white;
|
||||
border-bottom-left-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
transform-origin: top;
|
||||
transform: scaleY( 0 );
|
||||
transition: all 0.5s;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.show-search-results {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
|
||||
.search-result {
|
||||
padding: 3px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
width: 98%;
|
||||
text-decoration: none;
|
||||
transition: all 0.5s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-result:hover, .prod-selected {
|
||||
text-decoration: underline black;
|
||||
}
|
||||
|
||||
.search-product-image {
|
||||
height: 4rem;
|
||||
width: 4rem;
|
||||
background-position: center;
|
||||
background-size: cover;
|
||||
margin-right: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.search-product-name {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
font-weight: bold;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
width: calc( 100% - 4rem - 20px );
|
||||
}
|
||||
</style>
|
||||
@@ -1,145 +0,0 @@
|
||||
<template>
|
||||
<div style="width: 100%; height: 100%;">
|
||||
<progress :id="'progress-slider-' + name" class="progress-slider" :value="sliderProgress" max="1000" @mousedown="( e ) => { setPos( e ) }"
|
||||
:class="active ? '' : 'slider-inactive'"></progress>
|
||||
<div v-if="active" id="slider-knob" @mousedown="( e ) => { startMove( e ) }"
|
||||
:style="'left: ' + ( originalPos + sliderPos ) + 'px;'">
|
||||
<div id="slider-knob-style"></div>
|
||||
</div>
|
||||
<div v-else id="slider-knob" class="slider-inactive" style="left: 0;">
|
||||
<div id="slider-knob-style"></div>
|
||||
</div>
|
||||
<div id="drag-support" @mousemove="e => { handleDrag( e ) }" @mouseup="() => { stopMove(); }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-slider {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
height: 5px;
|
||||
cursor: pointer;
|
||||
background-color: var( --slider-color );
|
||||
}
|
||||
|
||||
.progress-slider::-webkit-progress-value {
|
||||
background-color: var( --slider-color );
|
||||
}
|
||||
|
||||
#slider-knob {
|
||||
height: 20px;
|
||||
width: 10px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 2;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
#slider-knob-style {
|
||||
background-color: var( --slider-color );
|
||||
height: 15px;
|
||||
width: 5px;
|
||||
}
|
||||
|
||||
#drag-support {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-support-active {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.slider-inactive {
|
||||
cursor: default !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
|
||||
const props = defineProps( {
|
||||
style: {
|
||||
type: Object,
|
||||
},
|
||||
position: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
duration: {
|
||||
type: Number,
|
||||
default: 100
|
||||
},
|
||||
active: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
default: '1',
|
||||
}
|
||||
} );
|
||||
|
||||
const offset = ref( 0 );
|
||||
const isDragging = ref( false );
|
||||
const sliderPos = ref( 0 );
|
||||
const originalPos= ref( 0 );
|
||||
const sliderProgress = ref( 0 );
|
||||
|
||||
const handleDrag = ( e: MouseEvent ) => {
|
||||
if ( isDragging.value ) {
|
||||
if ( 0 < originalPos.value + e.screenX - offset.value && originalPos.value + e.screenX - offset.value < ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) {
|
||||
sliderPos.value = e.screenX - offset.value;
|
||||
calcProgressPos();
|
||||
}
|
||||
}
|
||||
}
|
||||
const startMove = ( e: MouseEvent ) => {
|
||||
offset.value = e.screenX;
|
||||
isDragging.value = true;
|
||||
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.add( 'drag-support-active' );
|
||||
}
|
||||
const stopMove = () => {
|
||||
originalPos.value += sliderPos.value;
|
||||
isDragging.value = false;
|
||||
offset.value = 0;
|
||||
sliderPos.value = 0;
|
||||
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.remove( 'drag-support-active' );
|
||||
calcPlaybackPos();
|
||||
}
|
||||
const setPos = ( e: MouseEvent ) => {
|
||||
if ( props.active ) {
|
||||
originalPos.value = e.offsetX;
|
||||
calcProgressPos();
|
||||
calcPlaybackPos();
|
||||
}
|
||||
}
|
||||
const calcProgressPos = () => {
|
||||
sliderProgress.value = Math.ceil( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * 1000 );
|
||||
}
|
||||
const calcPlaybackPos = () => {
|
||||
emits( 'pos', Math.round( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * props.duration ) );
|
||||
}
|
||||
watch( () => props.position, () => {
|
||||
if ( !isDragging.value ) {
|
||||
sliderProgress.value = Math.ceil( props.position / props.duration * 1000 + 2 );
|
||||
originalPos.value = Math.ceil( props.position / props.duration * ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).scrollWidth - 5 ) );
|
||||
}
|
||||
} )
|
||||
|
||||
const emits = defineEmits( [ 'pos' ] );
|
||||
</script>
|
||||
@@ -1,26 +0,0 @@
|
||||
import App from './App.vue';
|
||||
import {
|
||||
createApp
|
||||
} from 'vue';
|
||||
import {
|
||||
createPinia
|
||||
} from 'pinia';
|
||||
import router from './router';
|
||||
import sdk from '@janishutz/login-sdk-browser';
|
||||
|
||||
|
||||
const app = createApp( App );
|
||||
|
||||
app.use( createPinia() );
|
||||
app.use( router );
|
||||
|
||||
// localStorage.setItem( 'url', 'http://localhost:8082' );
|
||||
localStorage.setItem( 'url', 'https://music-api.janishutz.com' );
|
||||
sdk.setUp(
|
||||
'jh-music',
|
||||
String( localStorage.getItem( 'url' ) ),
|
||||
'/app',
|
||||
false // Set to false for deploy to actual backend
|
||||
);
|
||||
|
||||
app.mount( '#app' );
|
||||
@@ -1,96 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '@/views/HomeView.vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
|
||||
const router = createRouter( {
|
||||
history: createWebHistory( import.meta.env.BASE_URL ),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Login'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
name: 'app',
|
||||
component: () => import( '../views/AppView.vue' ),
|
||||
meta: {
|
||||
'authRequired': true,
|
||||
'title': 'App'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/get',
|
||||
name: 'get',
|
||||
component: () => import( '../views/GetView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Get'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/share/:name',
|
||||
name: 'share',
|
||||
component: () => import( '../views/RemoteView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Share'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/fancy/:name',
|
||||
name: 'fancy',
|
||||
component: () => import( '../views/ShowcaseView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Fancy View'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/tools/bar',
|
||||
name: 'tool-bar',
|
||||
component: () => import( '../views/BarView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Bar utility'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import( '../views/404View.vue' ),
|
||||
meta: {
|
||||
title: '404 :: Page not found',
|
||||
transition: 'scale',
|
||||
}
|
||||
},
|
||||
]
|
||||
} );
|
||||
|
||||
// router.beforeResolve( ( to, _from, next ) => {
|
||||
// if ( to.name ) {
|
||||
// NProgress.start();
|
||||
// }
|
||||
// next();
|
||||
// } );
|
||||
|
||||
router.beforeEach( ( to ) => {
|
||||
const userStore = useUserStore();
|
||||
const isUserAuthenticated = userStore.getUserAuthenticated;
|
||||
if ( !isUserAuthenticated && to.meta.authRequired ) {
|
||||
localStorage.setItem( 'redirect', to.fullPath );
|
||||
return { name: 'home' };
|
||||
}
|
||||
} );
|
||||
|
||||
router.afterEach( ( to ) => {
|
||||
window.scrollTo( { top: 0, behavior: 'smooth' } );
|
||||
document.title = to.meta.title ? to.meta.title + ' - MusicPlayer' : 'MusicPlayer';
|
||||
// NProgress.done();
|
||||
} );
|
||||
|
||||
export default router;
|
||||
@@ -1,161 +0,0 @@
|
||||
import ColorThief from 'colorthief';
|
||||
|
||||
const colorThief = new ColorThief();
|
||||
|
||||
const getImageData = (): Promise<number[][]> => {
|
||||
return new Promise( resolve => {
|
||||
const img = document.getElementById( 'current-image' ) as HTMLImageElement;
|
||||
|
||||
if ( img.complete ) {
|
||||
resolve( colorThief.getPalette( img ) );
|
||||
} else {
|
||||
img.addEventListener( 'load', () => {
|
||||
resolve( colorThief.getPalette( img ) );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const createBackground = () => {
|
||||
return new Promise( resolve => {
|
||||
getImageData().then( palette => {
|
||||
const colourDetails: number[][] = [];
|
||||
const colours: string[] = [];
|
||||
|
||||
let differentEnough = true;
|
||||
|
||||
if ( palette[ 0 ] ) {
|
||||
for ( const i in palette ) {
|
||||
for ( const colour in colourDetails ) {
|
||||
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - palette[ i ][ 0 ] ) / 255
|
||||
+ Math.abs( colourDetails[ colour ][ 1 ] - palette[ i ][ 1 ] ) / 255
|
||||
+ Math.abs( colourDetails[ colour ][ 2 ] - palette[ i ][ 2 ] ) / 255 ) / 3 * 100;
|
||||
|
||||
if ( colourDiff > 15 ) {
|
||||
differentEnough = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( differentEnough ) {
|
||||
colourDetails.push( palette[ i ] );
|
||||
colours.push( 'rgb(' + palette[ i ][ 0 ] + ',' + palette[ i ][ 1 ] + ',' + palette[ i ][ 2 ] + ')' );
|
||||
}
|
||||
|
||||
differentEnough = false;
|
||||
}
|
||||
}
|
||||
|
||||
let outColours = 'conic-gradient(';
|
||||
|
||||
if ( colours.length < 3 ) {
|
||||
for ( let i = 0; i < 3; i++ ) {
|
||||
if ( colours[ i ] ) {
|
||||
outColours += colours[ i ] + ',';
|
||||
} else {
|
||||
if ( i === 0 ) {
|
||||
outColours += 'blue,';
|
||||
} else if ( i === 1 ) {
|
||||
outColours += 'green,';
|
||||
} else if ( i === 2 ) {
|
||||
outColours += 'red,';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ( colours.length < 11 ) {
|
||||
for ( const i in colours ) {
|
||||
outColours += colours[ i ] + ',';
|
||||
}
|
||||
} else {
|
||||
for ( let i = 0; i < 10; i++ ) {
|
||||
outColours += colours[ i ] + ',';
|
||||
}
|
||||
}
|
||||
|
||||
outColours += colours[ 0 ] ?? 'blue' + ')';
|
||||
resolve( outColours );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
let callbackFun = () => {};
|
||||
|
||||
const subscribeToBeatUpdate = ( cb: () => void ) => {
|
||||
callbackFun = cb;
|
||||
micAudioHandler();
|
||||
};
|
||||
|
||||
const unsubscribeFromBeatUpdate = () => {
|
||||
callbackFun = () => {};
|
||||
|
||||
try {
|
||||
clearInterval( micAnalyzer );
|
||||
} catch ( e ) { /* empty */ }
|
||||
};
|
||||
|
||||
const coolDown = () => {
|
||||
beatDetected = false;
|
||||
};
|
||||
|
||||
let micAnalyzer = 0;
|
||||
let beatDetected = false;
|
||||
|
||||
const micAudioHandler = () => {
|
||||
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
|
||||
analyser.fftSize = 256;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array( bufferLength );
|
||||
|
||||
beatDetected = false;
|
||||
|
||||
navigator.mediaDevices.getUserMedia( {
|
||||
'audio': true
|
||||
} ).then( stream => {
|
||||
const mic = audioContext.createMediaStreamSource( stream );
|
||||
|
||||
mic.connect( analyser );
|
||||
analyser.getByteFrequencyData( dataArray );
|
||||
let prevSpectrum: number[] = [];
|
||||
|
||||
const threshold = 10; // Adjust as needed
|
||||
|
||||
micAnalyzer = setInterval( () => {
|
||||
analyser.getByteFrequencyData( dataArray );
|
||||
// Convert the frequency data to a numeric array
|
||||
const currentSpectrum = Array.from( dataArray );
|
||||
|
||||
if ( prevSpectrum ) {
|
||||
// Calculate the spectral flux
|
||||
const flux = calculateSpectralFlux( prevSpectrum, currentSpectrum );
|
||||
|
||||
if ( flux > threshold && !beatDetected ) {
|
||||
// Beat detected
|
||||
beatDetected = true;
|
||||
callbackFun();
|
||||
}
|
||||
}
|
||||
|
||||
prevSpectrum = currentSpectrum;
|
||||
}, 60 / 180 * 250 );
|
||||
} );
|
||||
};
|
||||
|
||||
const calculateSpectralFlux = ( prevSpectrum: number[], currentSpectrum: number[] ) => {
|
||||
let flux = 0;
|
||||
|
||||
for ( let i = 0; i < prevSpectrum.length; i++ ) {
|
||||
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
|
||||
|
||||
flux += Math.max( 0, diff );
|
||||
}
|
||||
|
||||
return flux;
|
||||
};
|
||||
|
||||
export default {
|
||||
createBackground,
|
||||
subscribeToBeatUpdate,
|
||||
unsubscribeFromBeatUpdate,
|
||||
coolDown,
|
||||
};
|
||||
@@ -1,212 +0,0 @@
|
||||
// These functions handle connections to the backend with socket.io
|
||||
|
||||
import {
|
||||
type Socket, io
|
||||
} from 'socket.io-client';
|
||||
import type {
|
||||
SSEMap
|
||||
} from './song';
|
||||
|
||||
class SocketConnection {
|
||||
|
||||
socket: Socket;
|
||||
|
||||
roomName: string;
|
||||
|
||||
isConnected: boolean;
|
||||
|
||||
useSocket: boolean;
|
||||
|
||||
eventSource?: EventSource;
|
||||
|
||||
toBeListenedForItems: SSEMap;
|
||||
|
||||
reconnectRetryCount: number;
|
||||
|
||||
openConnectionsCount: number;
|
||||
|
||||
constructor () {
|
||||
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
|
||||
'autoConnect': false,
|
||||
} );
|
||||
this.roomName = location.pathname.split( '/' )[ 2 ];
|
||||
this.isConnected = false;
|
||||
this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws';
|
||||
this.toBeListenedForItems = {};
|
||||
this.reconnectRetryCount = 0;
|
||||
this.openConnectionsCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room token and connect to
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
connect (): Promise<unknown> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
if ( this.reconnectRetryCount < 5 ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.connect();
|
||||
this.socket.emit(
|
||||
'join-room', this.roomName, ( res: {
|
||||
'status': boolean,
|
||||
'msg': string,
|
||||
'data': unknown
|
||||
} ) => {
|
||||
if ( res.status === true ) {
|
||||
this.isConnected = true;
|
||||
resolve( res.data );
|
||||
} else {
|
||||
console.debug( res.msg );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if ( this.openConnectionsCount < 1 && !this.isConnected ) {
|
||||
this.openConnectionsCount += 1;
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
this.eventSource
|
||||
= new EventSource( localStorage.getItem( 'url' )
|
||||
+ '/socket/connection?room=' + this.roomName, {
|
||||
'withCredentials': true
|
||||
} );
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectRetryCount = 0;
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString() + ': Connection successfully established!' );
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = e => {
|
||||
const d = JSON.parse( e.data );
|
||||
|
||||
if ( this.toBeListenedForItems[ d.type ] ) {
|
||||
this.toBeListenedForItems[ d.type ]( d.data );
|
||||
} else if ( d.type === 'basics' ) {
|
||||
resolve( d.data );
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
if ( this.isConnected ) {
|
||||
this.isConnected = false;
|
||||
this.openConnectionsCount -= 1;
|
||||
this.eventSource?.close();
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Reconnecting due to connection error!' );
|
||||
// console.debug( e );
|
||||
|
||||
this.eventSource = undefined;
|
||||
|
||||
this.reconnectRetryCount += 1;
|
||||
setTimeout( () => {
|
||||
this.connect();
|
||||
}, 1000 * this.reconnectRetryCount );
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Could not connect due to error ' + res.status );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
} )
|
||||
.catch( () => {
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Could not connect due to error.' );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
} );
|
||||
} else {
|
||||
console.log( '[ SSE Connection ]: Trimmed connections' );
|
||||
reject( 'ERR_TOO_MANY_CONNECTIONS' );
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert( 'Could not reconnect to the share. Please reload the page to retry!' );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
* @param {string} event The event to emit
|
||||
* @param {any} data
|
||||
* @returns {void}
|
||||
*/
|
||||
emit ( event: string, data: unknown ): void {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.emit( event, {
|
||||
'roomName': this.roomName,
|
||||
'data': data
|
||||
} );
|
||||
} else {
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
|
||||
'method': 'post',
|
||||
'body': JSON.stringify( {
|
||||
'event': event,
|
||||
'roomName': this.roomName,
|
||||
'data': data
|
||||
} ),
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8'
|
||||
}
|
||||
} ).catch( () => {} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener function for an event
|
||||
* @param {string} event The event to listen for
|
||||
* @param {( data: any ) => void} cb The callback function / listener function
|
||||
* @returns {void}
|
||||
*/
|
||||
registerListener ( event: string, cb: ( data: unknown ) => void ): void {
|
||||
if ( this.useSocket ) {
|
||||
if ( this.isConnected ) {
|
||||
this.socket.on( event, cb );
|
||||
}
|
||||
} else {
|
||||
this.toBeListenedForItems[ event ] = cb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
* @returns {any}
|
||||
*/
|
||||
disconnect (): void {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.disconnect();
|
||||
} else {
|
||||
this.eventSource!.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatus (): boolean {
|
||||
if ( this.useSocket ) {
|
||||
return true;
|
||||
} else {
|
||||
if ( this.eventSource ) {
|
||||
return this.eventSource!.OPEN && this.isConnected;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SocketConnection;
|
||||
@@ -1,571 +0,0 @@
|
||||
import type {
|
||||
SearchResult, Song, SongMove
|
||||
} from './song';
|
||||
|
||||
interface Config {
|
||||
'devToken': string;
|
||||
'userToken': string;
|
||||
}
|
||||
|
||||
type ControlAction = 'play' | 'pause' | 'next' | 'previous' | 'skip-10' | 'back-10';
|
||||
type RepeatMode = 'off' | 'once' | 'all';
|
||||
|
||||
class MusicKitJSWrapper {
|
||||
|
||||
playingSongID: number;
|
||||
|
||||
playlist: Song[];
|
||||
|
||||
queue: number[];
|
||||
|
||||
config: Config;
|
||||
|
||||
musicKit: any;
|
||||
|
||||
isLoggedIn: boolean;
|
||||
|
||||
isPreparedToPlay: boolean;
|
||||
|
||||
repeatMode: RepeatMode;
|
||||
|
||||
isShuffleEnabled: boolean;
|
||||
|
||||
hasEncounteredAuthError: boolean;
|
||||
|
||||
queuePos: number;
|
||||
|
||||
audioPlayer: HTMLAudioElement;
|
||||
|
||||
constructor () {
|
||||
this.playingSongID = 0;
|
||||
this.playlist = [];
|
||||
this.queue = [];
|
||||
this.config = {
|
||||
'devToken': '',
|
||||
'userToken': '',
|
||||
};
|
||||
this.isShuffleEnabled = false;
|
||||
this.repeatMode = 'off';
|
||||
this.isPreparedToPlay = false;
|
||||
this.isLoggedIn = false;
|
||||
this.hasEncounteredAuthError = false;
|
||||
this.queuePos = 0;
|
||||
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
|
||||
|
||||
const self = this;
|
||||
|
||||
if ( !window.MusicKit ) {
|
||||
document.addEventListener( 'musickitloaded', () => {
|
||||
self.init();
|
||||
} );
|
||||
} else {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user into Apple Music. Will automatically initialize MusicKitJS, once user is logged in
|
||||
* @returns {void}
|
||||
*/
|
||||
logIn (): void {
|
||||
if ( !this.musicKit.isAuthorized ) {
|
||||
this.musicKit.authorize().then( () => {
|
||||
this.isLoggedIn = true;
|
||||
this.init();
|
||||
} )
|
||||
.catch( () => {
|
||||
this.hasEncounteredAuthError = true;
|
||||
} );
|
||||
} else {
|
||||
this.musicKit.authorize().then( () => {
|
||||
this.isLoggedIn = true;
|
||||
this.init();
|
||||
} )
|
||||
.catch( () => {
|
||||
this.hasEncounteredAuthError = true;
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MusicKitJS. Should not be called. Use logIn instead, which first tries to log the user in, then calls this method.
|
||||
* @returns {void}
|
||||
*/
|
||||
init (): void {
|
||||
fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.text().then( token => {
|
||||
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
|
||||
// MusicKit global is now defined
|
||||
MusicKit.configure( {
|
||||
'developerToken': token,
|
||||
'app': {
|
||||
'name': 'MusicPlayer',
|
||||
'build': '3'
|
||||
},
|
||||
'storefrontId': 'CH',
|
||||
} ).then( () => {
|
||||
this.config.devToken = token;
|
||||
this.musicKit = MusicKit.getInstance();
|
||||
|
||||
if ( this.musicKit.isAuthorized ) {
|
||||
this.isLoggedIn = true;
|
||||
this.config.userToken = this.musicKit.musicUserToken;
|
||||
}
|
||||
|
||||
this.musicKit.shuffleMode = MusicKit.PlayerShuffleMode.off;
|
||||
} );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication status of the user
|
||||
* @returns {boolean[]} Returns an array, where the first element indicates login status, the second one, if an error was encountered
|
||||
*/
|
||||
getAuth (): boolean[] {
|
||||
return [
|
||||
this.isLoggedIn,
|
||||
this.hasEncounteredAuthError
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data from the Apple Music API
|
||||
* @param {string} url The URL at the Apple Music API to call (including protocol and url)
|
||||
* @param {( data: object ) => void} callback A callback function that takes the data and returns nothing
|
||||
* @returns {void}
|
||||
*/
|
||||
apiGetRequest ( url: string, callback: ( data: object ) => void ): void {
|
||||
if ( this.config.devToken != '' && this.config.userToken != '' ) {
|
||||
fetch( url, {
|
||||
'method': 'GET',
|
||||
'headers': {
|
||||
'Authorization': `Bearer ${ this.config.devToken }`,
|
||||
'Music-User-Token': this.config.userToken
|
||||
}
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.json().then( json => {
|
||||
try {
|
||||
callback( {
|
||||
'status': 'ok',
|
||||
'data': json
|
||||
} );
|
||||
} catch ( err ) { /* empty */ }
|
||||
} );
|
||||
} else {
|
||||
try {
|
||||
callback( {
|
||||
'status': 'error',
|
||||
'error': res.status
|
||||
} );
|
||||
} catch ( err ) { /* empty */ }
|
||||
}
|
||||
} );
|
||||
} else return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playlist to play
|
||||
* @param {Song[]} playlist The playlist as an array of songs
|
||||
* @returns {void}
|
||||
*/
|
||||
setPlaylist ( playlist: Song[] ): void {
|
||||
this.playlist = playlist;
|
||||
this.setShuffle( this.isShuffleEnabled );
|
||||
}
|
||||
|
||||
setPlaylistByID ( id: string ): Promise<void> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
this.musicKit.setQueue( {
|
||||
'playlist': id
|
||||
} ).then( () => {
|
||||
const pl = this.musicKit.queue.items;
|
||||
const songs: Song[] = [];
|
||||
|
||||
for ( const item in pl ) {
|
||||
let url = pl[ item ].attributes.artwork.url;
|
||||
|
||||
url = url.replace( '{w}', pl[ item ].attributes.artwork.width );
|
||||
url = url.replace( '{h}', pl[ item ].attributes.artwork.height );
|
||||
const song: Song = {
|
||||
'artist': pl[ item ].attributes.artistName,
|
||||
'cover': url,
|
||||
'duration': pl[ item ].attributes.durationInMillis / 1000,
|
||||
'id': pl[ item ].id,
|
||||
'origin': 'apple-music',
|
||||
'title': pl[ item ].attributes.name,
|
||||
'genres': pl[ item ].attributes.genreNames
|
||||
};
|
||||
|
||||
songs.push( song );
|
||||
}
|
||||
|
||||
this.playlist = songs;
|
||||
this.setShuffle( this.isShuffleEnabled );
|
||||
this.queuePos = 0;
|
||||
this.playingSongID = this.queue[ 0 ];
|
||||
this.prepare( this.playingSongID );
|
||||
resolve();
|
||||
} )
|
||||
.catch( err => {
|
||||
console.error( err );
|
||||
reject( err );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a specific song in the queue for playing and start playing
|
||||
* @param {number} playlistID The ID of the song in the playlist to prepare to play
|
||||
* @returns {boolean} Returns true, if successful, false, if playlist is missing / empty. Set that first
|
||||
*/
|
||||
prepare ( playlistID: number ): boolean {
|
||||
if ( this.playlist.length > 0 ) {
|
||||
this.playingSongID = playlistID;
|
||||
this.isPreparedToPlay = true;
|
||||
|
||||
for ( const el in this.queue ) {
|
||||
if ( this.queue[ el ] === playlistID ) {
|
||||
this.queuePos = parseInt( el );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.setQueue( {
|
||||
'song': this.playlist[ this.playingSongID ].id
|
||||
} ).then( () => {
|
||||
setTimeout( () => {
|
||||
this.control( 'play' );
|
||||
}, 500 );
|
||||
} )
|
||||
.catch( err => {
|
||||
console.log( err );
|
||||
} );
|
||||
} else {
|
||||
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
|
||||
this.audioPlayer.src = this.playlist[ this.playingSongID ].id;
|
||||
setTimeout( () => {
|
||||
this.control( 'play' );
|
||||
}, 500 );
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control the player
|
||||
* @param {ControlAction} action Action to take on the player
|
||||
* @returns {boolean} returns a boolean indicating if there was a change in song.
|
||||
*/
|
||||
control ( action: ControlAction ): boolean {
|
||||
switch ( action ) {
|
||||
case 'play':
|
||||
if ( this.isPreparedToPlay ) {
|
||||
this.control( 'pause' );
|
||||
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.play();
|
||||
|
||||
return false;
|
||||
} else {
|
||||
this.audioPlayer.play();
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
if ( this.isPreparedToPlay ) {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.pause();
|
||||
|
||||
return false;
|
||||
} else {
|
||||
this.audioPlayer.pause();
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'back-10':
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime > 10 ? this.musicKit.currentPlaybackTime - 10 : 0 );
|
||||
|
||||
return false;
|
||||
} else {
|
||||
this.audioPlayer.currentTime = this.audioPlayer.currentTime > 10 ? this.audioPlayer.currentTime - 10 : 0;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'skip-10':
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
if ( this.musicKit.currentPlaybackTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
|
||||
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime + 10 );
|
||||
|
||||
return false;
|
||||
} else {
|
||||
if ( this.repeatMode !== 'once' ) {
|
||||
this.control( 'next' );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
this.musicKit.seekToTime( 0 );
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ( this.audioPlayer.currentTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
|
||||
this.audioPlayer.currentTime = this.audioPlayer.currentTime + 10;
|
||||
} else {
|
||||
if ( this.repeatMode !== 'once' ) {
|
||||
this.control( 'next' );
|
||||
} else {
|
||||
this.audioPlayer.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'next':
|
||||
this.control( 'pause' );
|
||||
|
||||
if ( this.queuePos < this.queue.length - 1 ) {
|
||||
this.queuePos += 1;
|
||||
this.prepare( this.queue[ this.queuePos ] );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
this.queuePos = 0;
|
||||
|
||||
if ( this.repeatMode !== 'all' ) {
|
||||
this.control( 'pause' );
|
||||
} else {
|
||||
this.playingSongID = this.queue[ this.queuePos ];
|
||||
this.prepare( this.queue[ this.queuePos ] );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'previous':
|
||||
this.control( 'pause' );
|
||||
|
||||
if ( this.queuePos > 0 ) {
|
||||
this.queuePos -= 1;
|
||||
this.prepare( this.queue[ this.queuePos ] );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
this.queuePos = this.queue.length - 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShuffle ( enabled: boolean ) {
|
||||
this.isShuffleEnabled = enabled;
|
||||
this.queue = [];
|
||||
|
||||
if ( enabled ) {
|
||||
const d = [];
|
||||
|
||||
for ( const el in this.playlist ) {
|
||||
d.push( parseInt( el ) );
|
||||
}
|
||||
|
||||
this.queue = d.map( value => ( {
|
||||
value,
|
||||
'sort': Math.random()
|
||||
} ) )
|
||||
.sort( ( a, b ) => a.sort - b.sort )
|
||||
.map( ( {
|
||||
value
|
||||
} ) => value );
|
||||
this.queue.splice( this.queue.indexOf( this.playingSongID ), 1 );
|
||||
this.queue.push( this.playingSongID );
|
||||
this.queue.reverse();
|
||||
} else {
|
||||
for ( const song in this.playlist ) {
|
||||
this.queue.push( parseInt( song ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Find current song ID in queue
|
||||
for ( const el in this.queue ) {
|
||||
if ( this.queue[ el ] === this.playingSongID ) {
|
||||
this.queuePos = parseInt( el );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRepeatMode ( mode: RepeatMode ) {
|
||||
this.repeatMode = mode;
|
||||
}
|
||||
|
||||
goToPos ( pos: number ) {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.seekToTime( pos );
|
||||
} else {
|
||||
this.audioPlayer.currentTime = pos;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
moveSong ( move: SongMove ) {
|
||||
const newQueue = [];
|
||||
const finishedQueue = [];
|
||||
|
||||
let songID = 0;
|
||||
|
||||
for ( const song in this.playlist ) {
|
||||
if ( this.playlist[ song ].id === move.songID ) {
|
||||
songID = parseInt( song );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for ( const el in this.queue ) {
|
||||
if ( this.queue[ el ] !== songID ) {
|
||||
newQueue.push( this.queue[ el ] );
|
||||
}
|
||||
}
|
||||
|
||||
let hasBeenAdded = false;
|
||||
|
||||
for ( const el in newQueue ) {
|
||||
if ( parseInt( el ) === move.newPos ) {
|
||||
finishedQueue.push( songID );
|
||||
hasBeenAdded = true;
|
||||
}
|
||||
|
||||
finishedQueue.push( newQueue[ el ] );
|
||||
}
|
||||
|
||||
if ( !hasBeenAdded ) {
|
||||
finishedQueue.push( songID );
|
||||
}
|
||||
|
||||
this.queue = finishedQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current position of the play heed. Will return in ms since start of the song
|
||||
* @returns {number}
|
||||
*/
|
||||
getPlaybackPos (): number {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
return this.musicKit.currentPlaybackTime;
|
||||
} else {
|
||||
return this.audioPlayer.currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details on the currently playing song
|
||||
* @returns {Song}
|
||||
*/
|
||||
getPlayingSong (): Song {
|
||||
return this.playlist[ this.playingSongID ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the playlist index of the currently playing song
|
||||
* @returns {number}
|
||||
*/
|
||||
getPlayingSongID (): number {
|
||||
return this.playingSongID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the queue index of the currently playing song
|
||||
* @returns {number}
|
||||
*/
|
||||
getQueueID (): number {
|
||||
return this.queuePos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full playlist, as it is set currently, not ordered by queue settings, but as passed in originally
|
||||
* @returns {Song[]}
|
||||
*/
|
||||
getPlaylist (): Song[] {
|
||||
return this.playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getPlaylist, but returns a ordered playlist, by how it will play according to the queue.
|
||||
* @returns {Song[]}
|
||||
*/
|
||||
getQueue (): Song[] {
|
||||
const data = [];
|
||||
|
||||
for ( const el in this.queue ) {
|
||||
data.push( this.playlist[ this.queue[ el ] ] );
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all playlists the authenticated user has on Apple Music. Only available once the user has authenticated!
|
||||
* @param {( data: object ) => void} cb The callback function called with the results from the API
|
||||
* @returns {boolean} Returns true, if user is authenticated and request was started, false if not.
|
||||
*/
|
||||
getUserPlaylists ( cb: ( data: object ) => void ): boolean {
|
||||
if ( this.isLoggedIn ) {
|
||||
this.apiGetRequest( 'https://api.music.apple.com/v1/me/library/playlists', cb );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPlaying ( ): boolean {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
return this.musicKit.isPlaying;
|
||||
} else {
|
||||
return !this.audioPlayer.paused;
|
||||
}
|
||||
}
|
||||
|
||||
findSongOnAppleMusic ( searchTerm: string ): Promise<SearchResult> {
|
||||
// TODO: Make storefront adjustable
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const queryParameters = {
|
||||
'term': searchTerm,
|
||||
'types': [ 'songs' ],
|
||||
};
|
||||
|
||||
this.musicKit.api.music( 'v1/catalog/ch/search', queryParameters )
|
||||
.then( results => {
|
||||
resolve( results );
|
||||
} )
|
||||
.catch( e => {
|
||||
console.error( e );
|
||||
reject( e );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default MusicKitJSWrapper;
|
||||
@@ -1,315 +0,0 @@
|
||||
// These functions handle connections to the backend with socket.io
|
||||
|
||||
import {
|
||||
type Socket, io
|
||||
} from 'socket.io-client';
|
||||
import type {
|
||||
SSEMap
|
||||
} from './song';
|
||||
|
||||
class NotificationHandler {
|
||||
|
||||
socket: Socket;
|
||||
|
||||
roomName: string;
|
||||
|
||||
roomToken: string;
|
||||
|
||||
isConnected: boolean;
|
||||
|
||||
useSocket: boolean;
|
||||
|
||||
eventSource?: EventSource;
|
||||
|
||||
toBeListenedForItems: SSEMap;
|
||||
|
||||
reconnectRetryCount: number;
|
||||
|
||||
lastEmitTimestamp: number;
|
||||
|
||||
openConnectionsCount: number;
|
||||
|
||||
pendingRequestCount: number;
|
||||
|
||||
connectionWasSuccessful: boolean;
|
||||
|
||||
constructor () {
|
||||
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
|
||||
'autoConnect': false,
|
||||
} );
|
||||
this.roomName = '';
|
||||
this.roomToken = '';
|
||||
this.isConnected = false;
|
||||
this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws';
|
||||
this.toBeListenedForItems = {};
|
||||
this.reconnectRetryCount = 0;
|
||||
this.lastEmitTimestamp = 0;
|
||||
this.pendingRequestCount = 0;
|
||||
this.openConnectionsCount = 0;
|
||||
this.connectionWasSuccessful = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room token and connect to
|
||||
* @param {string} roomName
|
||||
* @param {boolean} useAntiTamper
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
connect ( roomName: string, useAntiTamper: boolean ): Promise<void> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fetch( localStorage.getItem( 'url' ) + '/createRoomToken?roomName='
|
||||
+ roomName + '&useAntiTamper=' + useAntiTamper, {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.text().then( text => {
|
||||
this.roomToken = text;
|
||||
this.roomName = roomName;
|
||||
|
||||
if ( this.useSocket ) {
|
||||
this.socket.connect();
|
||||
this.socket.emit(
|
||||
'create-room', {
|
||||
'name': this.roomName,
|
||||
'token': this.roomToken
|
||||
}, ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => {
|
||||
if ( res.status === true ) {
|
||||
this.isConnected = true;
|
||||
resolve();
|
||||
} else {
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.sseConnect().then( () => {
|
||||
resolve();
|
||||
} )
|
||||
.catch( );
|
||||
}
|
||||
} );
|
||||
} else if ( res.status === 409 ) {
|
||||
reject( 'ERR_CONFLICT' );
|
||||
} else if ( res.status === 403 || res.status === 401 ) {
|
||||
reject( 'ERR_UNAUTHORIZED' );
|
||||
} else {
|
||||
reject( 'ERR_ROOM_CREATING' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
sseConnect (): Promise<void> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
if ( this.reconnectRetryCount < 5 ) {
|
||||
if ( this.openConnectionsCount < 1 && !this.isConnected ) {
|
||||
this.openConnectionsCount += 1;
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
this.eventSource = new EventSource( localStorage.getItem( 'url' )
|
||||
+ '/socket/connection?room=' + this.roomName, {
|
||||
'withCredentials': true
|
||||
} );
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.connectionWasSuccessful = true;
|
||||
this.reconnectRetryCount = 0;
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString() + ': Connection successfully established!' );
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = e => {
|
||||
const d = JSON.parse( e.data );
|
||||
|
||||
if ( this.toBeListenedForItems[ d.type ] ) {
|
||||
this.toBeListenedForItems[ d.type ]( d.data );
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = e => {
|
||||
if ( this.isConnected ) {
|
||||
this.isConnected = false;
|
||||
this.eventSource?.close();
|
||||
this.openConnectionsCount -= 1;
|
||||
console.debug( '[ SSE Connection ] - Error encountered: ', e );
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Reconnecting due to connection error!' );
|
||||
|
||||
this.eventSource = undefined;
|
||||
|
||||
this.reconnectRetryCount += 1;
|
||||
setTimeout( () => {
|
||||
this.sseConnect();
|
||||
}, 1000 * this.reconnectRetryCount );
|
||||
}
|
||||
};
|
||||
} else if ( res.status === 403 || res.status === 401
|
||||
|| res.status === 404 || res.status === 402 ) {
|
||||
document.dispatchEvent( new Event( 'musicplayer:autherror' ) );
|
||||
reject( 'ERR_UNAUTHORIZED' );
|
||||
} else {
|
||||
reject( 'ERR_ROOM_CONNECTING_STATUS_CODE' );
|
||||
}
|
||||
} )
|
||||
.catch( () => {
|
||||
if ( !this.connectionWasSuccessful ) {
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
} else {
|
||||
this.openConnectionsCount -= 1;
|
||||
console.log( '[ SSE Connection ] - ' + new Date().toISOString()
|
||||
+ ': Reconnecting due to severe connection error!' );
|
||||
|
||||
this.eventSource = undefined;
|
||||
|
||||
this.reconnectRetryCount += 1;
|
||||
setTimeout( () => {
|
||||
this.sseConnect();
|
||||
}, 1000 * this.reconnectRetryCount );
|
||||
}
|
||||
} );
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
if ( confirm( 'Connection lost and it could not be reestablished. Please click ok to retry or press cancel to stop retrying. Your share will be deleted as a result thereof.' ) ) {
|
||||
this.reconnectRetryCount = 0;
|
||||
this.sseConnect();
|
||||
} else {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
* @param {string} event The event to emit
|
||||
* @param {any} data
|
||||
* @returns {void}
|
||||
*/
|
||||
emit ( event: string, data: any ): void {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.emit( event, {
|
||||
'roomToken': this.roomToken,
|
||||
'roomName': this.roomName,
|
||||
'data': data
|
||||
} );
|
||||
} else {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if ( this.lastEmitTimestamp < now - 250 ) {
|
||||
this.lastEmitTimestamp = now;
|
||||
this.sendEmitConventionally( event, data );
|
||||
} else {
|
||||
this.pendingRequestCount += 1;
|
||||
setTimeout( () => {
|
||||
this.pendingRequestCount = 0;
|
||||
this.lastEmitTimestamp = now;
|
||||
this.sendEmitConventionally( event, data );
|
||||
}, 250 * this.pendingRequestCount );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendEmitConventionally ( event: string, data: any ): void {
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
|
||||
'method': 'post',
|
||||
'body': JSON.stringify( {
|
||||
'event': event,
|
||||
'roomName': this.roomName,
|
||||
'roomToken': this.roomToken,
|
||||
'data': data
|
||||
} ),
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8'
|
||||
}
|
||||
} ).catch( () => {} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener function for an event
|
||||
* @param {string} event The event to listen for
|
||||
* @param {( data: any ) => void} cb The callback function / listener function
|
||||
* @returns {void}
|
||||
*/
|
||||
registerListener ( event: string, cb: ( data: any ) => void ): void {
|
||||
if ( this.useSocket ) {
|
||||
if ( this.isConnected ) {
|
||||
this.socket.on( event, cb );
|
||||
}
|
||||
} else {
|
||||
this.toBeListenedForItems[ event ] = cb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
* @returns {any}
|
||||
*/
|
||||
async disconnect (): Promise<void> {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.emit(
|
||||
'delete-room', {
|
||||
'name': this.roomName,
|
||||
'token': this.roomToken
|
||||
}, ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => {
|
||||
this.socket.disconnect();
|
||||
|
||||
if ( !res.status ) {
|
||||
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/deleteRoom', {
|
||||
'method': 'post',
|
||||
'body': JSON.stringify( {
|
||||
'roomName': this.roomName,
|
||||
'roomToken': this.roomToken
|
||||
} ),
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8'
|
||||
}
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
this.eventSource!.close();
|
||||
} else {
|
||||
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
|
||||
}
|
||||
|
||||
return;
|
||||
} )
|
||||
.catch( () => {
|
||||
return;
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRoomName (): string {
|
||||
return this.roomName;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default NotificationHandler;
|
||||
95
MusicPlayerV2-GUI/src/scripts/song.d.ts
vendored
95
MusicPlayerV2-GUI/src/scripts/song.d.ts
vendored
@@ -1,95 +0,0 @@
|
||||
export type Origin = 'apple-music' | 'disk';
|
||||
|
||||
export interface Song {
|
||||
/**
|
||||
* The ID. Either the apple music ID, or if from local disk, an ID starting in local_
|
||||
*/
|
||||
'id': string;
|
||||
|
||||
/**
|
||||
* Origin of the song
|
||||
*/
|
||||
'origin': Origin;
|
||||
|
||||
/**
|
||||
* The cover image as a URL
|
||||
*/
|
||||
'cover': string;
|
||||
|
||||
/**
|
||||
* The artist of the song
|
||||
*/
|
||||
'artist': string;
|
||||
|
||||
/**
|
||||
* The name of the song
|
||||
*/
|
||||
'title': string;
|
||||
|
||||
/**
|
||||
* Duration of the song in milliseconds
|
||||
*/
|
||||
'duration': number;
|
||||
|
||||
/**
|
||||
* (OPTIONAL) The genres this song belongs to. Can be displayed on the showcase screen, but requires settings there
|
||||
*/
|
||||
'genres'?: string[];
|
||||
|
||||
/**
|
||||
* (OPTIONAL) This will be displayed in brackets on the showcase screens
|
||||
*/
|
||||
'additionalInfo'?: string;
|
||||
}
|
||||
|
||||
export interface SongTransmitted {
|
||||
'title': string;
|
||||
'artist': string;
|
||||
'duration': number;
|
||||
'cover': string;
|
||||
'additionalInfo'?: string;
|
||||
}
|
||||
|
||||
|
||||
export interface ReadFile {
|
||||
'url': string;
|
||||
'filename': string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
'data': {
|
||||
'results': {
|
||||
'songs': {
|
||||
'data': AppleMusicSongData[],
|
||||
'href': string;
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppleMusicSongData {
|
||||
'id': string,
|
||||
'type': string;
|
||||
'href': string;
|
||||
'attributes': {
|
||||
'albumName': string;
|
||||
'artistName': string;
|
||||
'artwork': {
|
||||
'width': number,
|
||||
'height': number,
|
||||
'url': string
|
||||
},
|
||||
'name': string;
|
||||
'genreNames': string[];
|
||||
'durationInMillis': number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SongMove {
|
||||
'songID': string;
|
||||
'newPos': number;
|
||||
}
|
||||
|
||||
export interface SSEMap {
|
||||
[key: string]: ( data: any ) => void;
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
import {
|
||||
defineStore
|
||||
} from 'pinia';
|
||||
|
||||
|
||||
// FOSS-VERSION: To enable the UI to be used with the FOSS version, change "isUserAuth" to true, you will be "logged in"
|
||||
export const useUserStore = defineStore( 'user', {
|
||||
'state': () => ( {
|
||||
'isUserAuth': false,
|
||||
'hasSubscribed': false,
|
||||
'isUsingKeyboard': false,
|
||||
'isFOSSVersion': false
|
||||
} ),
|
||||
'getters': {
|
||||
'getUserAuthenticated': state => state.isUserAuth,
|
||||
'getSubscriptionStatus': state => state.hasSubscribed,
|
||||
},
|
||||
'actions': {
|
||||
setUserAuth ( auth: boolean ) {
|
||||
this.isUserAuth = auth;
|
||||
},
|
||||
setSubscriptionStatus ( status: boolean ) {
|
||||
this.hasSubscribed = status;
|
||||
},
|
||||
setKeyboardUsageStatus ( status: boolean ) {
|
||||
this.isUsingKeyboard = status;
|
||||
}
|
||||
}
|
||||
} );
|
||||
@@ -1,24 +0,0 @@
|
||||
<template>
|
||||
<div class="home-view">
|
||||
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
|
||||
<h1>404</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 50vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,197 +0,0 @@
|
||||
<template>
|
||||
<div class="app-view">
|
||||
<button id="logout" @click="logout()">
|
||||
<span class="material-symbols-outlined">logout</span>
|
||||
</button>
|
||||
<div v-if="!hasFinishedLoading" class="loading-view">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
<div v-else-if="hasFinishedLoading && isReady" class="home-view">
|
||||
<libraryView
|
||||
class="library-view"
|
||||
:playlists="playlists"
|
||||
:is-logged-in="isLoggedIntoAppleMusic"
|
||||
@selected-playlist="( id ) => { selectPlaylist( id ) }"
|
||||
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="login-view">
|
||||
<img src="@/assets/appleMusicIcon.svg" alt="Apple Music Icon">
|
||||
<button class="fancy-button" style="margin-top: 20px;" @click="logIntoAppleMusic()">
|
||||
Log into Apple Music
|
||||
</button>
|
||||
<button
|
||||
class="fancy-button"
|
||||
title="This allows you to use local playlists only.
|
||||
Cover images for your songs will be fetched from the apple music api as good as possible"
|
||||
@click="skipLogin()"
|
||||
>
|
||||
Continue without logging in
|
||||
</button>
|
||||
</div>
|
||||
<playerView
|
||||
ref="player"
|
||||
:class="'player-view'
|
||||
+ ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )"
|
||||
@player-state-change="( state ) => { handlePlayerStateChange( state ) }"
|
||||
/>
|
||||
<!-- TODO: Call to backend to check if user has access -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import playerView from '@/components/playerView.vue';
|
||||
import libraryView from '@/components/libraryView.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
ReadFile
|
||||
} from '@/scripts/song';
|
||||
import router from '@/router';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/stores/userStore';
|
||||
|
||||
const isLoggedIntoAppleMusic = ref( false );
|
||||
const isReady = ref( false );
|
||||
const isShowingFullScreenPlayer = ref( false );
|
||||
const player = ref( playerView );
|
||||
const playlists = ref( [] );
|
||||
const hasFinishedLoading = ref( false );
|
||||
const userStore = useUserStore();
|
||||
|
||||
const handlePlayerStateChange = ( newState: string ) => {
|
||||
if ( newState === 'hide' ) {
|
||||
isShowingFullScreenPlayer.value = false;
|
||||
} else {
|
||||
isShowingFullScreenPlayer.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
let loginChecker = 0;
|
||||
|
||||
const logIntoAppleMusic = () => {
|
||||
player.value.logIntoAppleMusic();
|
||||
loginChecker = setInterval( () => {
|
||||
if ( player.value.getAuth()[ 0 ] ) {
|
||||
isLoggedIntoAppleMusic.value = true;
|
||||
isReady.value = true;
|
||||
player.value.getPlaylists( data => {
|
||||
playlists.value = data.data.data;
|
||||
} );
|
||||
clearInterval( loginChecker );
|
||||
} else if ( player.value.getAuth()[ 1 ] ) {
|
||||
clearInterval( loginChecker );
|
||||
alert( 'An error occurred when logging you in. Please try again!' );
|
||||
}
|
||||
}, 500 );
|
||||
};
|
||||
|
||||
const skipLogin = () => {
|
||||
isReady.value = true;
|
||||
isLoggedIntoAppleMusic.value = false;
|
||||
player.value.skipLogin();
|
||||
};
|
||||
|
||||
const selectPlaylist = ( id: string ) => {
|
||||
player.value.selectPlaylist( id );
|
||||
player.value.controlUI( 'show' );
|
||||
};
|
||||
|
||||
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
|
||||
player.value.selectCustomPlaylist( playlist );
|
||||
player.value.controlUI( 'show' );
|
||||
};
|
||||
|
||||
fetch( localStorage.getItem( 'url' ) + '/checkUserStatus', {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.text().then( text => {
|
||||
if ( text === 'ok' ) {
|
||||
hasFinishedLoading.value = true;
|
||||
userStore.setSubscriptionStatus( true );
|
||||
} else {
|
||||
userStore.setSubscriptionStatus( false );
|
||||
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
|
||||
router.push( '/get' );
|
||||
}
|
||||
} );
|
||||
} else if ( res.status === 402 ) {
|
||||
userStore.setSubscriptionStatus( false );
|
||||
router.push( '/get' );
|
||||
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
|
||||
} else {
|
||||
console.log( res.status );
|
||||
}
|
||||
} );
|
||||
|
||||
const logout = () => {
|
||||
// location.href = 'http://localhost:8080/logout?return=' + location.href;
|
||||
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#logout {
|
||||
border: none;
|
||||
background: none;
|
||||
position: fixed;
|
||||
left: calc( 10px + 2rem );
|
||||
top: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#logout .material-symbols-outlined {
|
||||
font-size: 1.5rem;
|
||||
color: var( --primary-color );
|
||||
}
|
||||
|
||||
.library-view {
|
||||
height: calc( 90vh - 10px );
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.player-view {
|
||||
height: 13vh;
|
||||
width: calc( 100vw - 20px );
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
background-color: var( --secondary-color );
|
||||
transition: all 0.75s ease-in-out;
|
||||
}
|
||||
|
||||
.full-screen-player {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.player-hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,229 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type Ref,
|
||||
computed, ref
|
||||
} from 'vue';
|
||||
|
||||
interface FullConfig {
|
||||
'offering': Bars;
|
||||
'ages': Ages;
|
||||
}
|
||||
|
||||
interface Bars {
|
||||
[name: string]: {
|
||||
'offering': BarConfig;
|
||||
'name': string;
|
||||
'id': string;
|
||||
}
|
||||
}
|
||||
|
||||
interface Ages {
|
||||
'18+': string;
|
||||
'16-18': string;
|
||||
}
|
||||
|
||||
interface BarConfig {
|
||||
[id: string]: Offer
|
||||
}
|
||||
|
||||
interface Offer {
|
||||
'name': string;
|
||||
'price': number; // In cents
|
||||
'depot'?: number; // In cents
|
||||
'showLine'?: boolean;
|
||||
'id': string;
|
||||
}
|
||||
|
||||
interface Selection {
|
||||
[id: string]: number;
|
||||
}
|
||||
|
||||
const ages: Ref<Ages> = ref( {
|
||||
'18+': '',
|
||||
'16-18': '',
|
||||
'below': ''
|
||||
} );
|
||||
const offering: Ref<Bars> = ref( {} );
|
||||
const selection: Ref<Selection> = ref( {} );
|
||||
const selectedBar: Ref<string> = ref( '' );
|
||||
const enableDepotReminder = ref( true );
|
||||
|
||||
let cashinInDepot = false;
|
||||
|
||||
fetch( '/bar-config.json', {
|
||||
'cache': 'no-store'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.json().then( json => {
|
||||
const data: FullConfig = json;
|
||||
|
||||
offering.value = data.offering;
|
||||
ages.value = data.ages;
|
||||
} );
|
||||
} else {
|
||||
alert( 'Failed to load' );
|
||||
}
|
||||
} );
|
||||
|
||||
const reset = ( skipCheck = true ) => {
|
||||
if ( !skipCheck && !Object.keys( offering.value ).includes( selectedBar.value ) ) return;
|
||||
|
||||
if ( cashinInDepot && enableDepotReminder.value ) alert( 'Hand out chips for depot' );
|
||||
|
||||
cashinInDepot = false;
|
||||
|
||||
const keys = Object.keys( offering.value[ selectedBar.value ].offering );
|
||||
|
||||
selection.value = {};
|
||||
|
||||
keys.forEach( val => {
|
||||
selection.value[ val ] = 0;
|
||||
} );
|
||||
};
|
||||
|
||||
const total = computed( () => {
|
||||
const keys = Object.keys( selection.value );
|
||||
|
||||
let totalPrice = 0;
|
||||
let totalDepot = 0;
|
||||
|
||||
for ( let i = 0; i < keys.length; i++ ) {
|
||||
const o = selection.value[ keys[ i ] ];
|
||||
|
||||
totalPrice += o * offering.value[ selectedBar.value ].offering[ keys[ i ] ].price;
|
||||
totalDepot += o * ( offering.value[ selectedBar.value ].offering[ keys[ i ] ].depot ?? 0 );
|
||||
}
|
||||
|
||||
if ( totalDepot > 0 ) {
|
||||
cashinInDepot = true;
|
||||
}
|
||||
|
||||
totalPrice += totalDepot;
|
||||
|
||||
return ( totalPrice / 100 ) + ( totalDepot ? ` (Depot = ${ totalDepot })` : '' );
|
||||
} );
|
||||
|
||||
const changeValue = ( id: string, amount: number ) => {
|
||||
selection.value[ id ] += amount;
|
||||
|
||||
if ( selection.value[ id ] < 0 ) {
|
||||
selection.value[ id ] = 0;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar-utility">
|
||||
<div style="margin: 0">
|
||||
<label> Depot chips reminder</label>
|
||||
<input v-model="enableDepotReminder" type="checkbox">
|
||||
</div>
|
||||
<h1 style="margin: 15px;">
|
||||
Bar utility
|
||||
</h1>
|
||||
<div>
|
||||
<label for="bar-select">Select bar </label>
|
||||
<select id="bar-select" v-model="selectedBar" @change="reset()">
|
||||
<option v-for="bar in Object.values( offering )" :key="bar.id" :value="bar.id">
|
||||
{{ bar.name }}
|
||||
</option>
|
||||
</select>
|
||||
<button @click="reset( false )">
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p>Check ages! (18+: {{ ages[ '18+' ] }}, 16-18: {{ ages[ '16-18' ] }})</p>
|
||||
<p v-if="Object.keys( offering ).includes( selectedBar )">
|
||||
Total: CHF {{ total }}
|
||||
</p>
|
||||
<table v-if="Object.keys( offering ).includes( selectedBar )" class="offering-wrapper">
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="offer in offering[ selectedBar ].offering"
|
||||
:key="offer.id"
|
||||
:class="[ 'offering', offer.showLine ? 'show-line' : '' ]"
|
||||
>
|
||||
<td>
|
||||
<p>
|
||||
{{ offer.name }} (CHF {{ offer.price / 100 }}{{
|
||||
offer.depot ? ' + ' + ( offer.depot / 100 ) : '' }})
|
||||
</p>
|
||||
</td>
|
||||
<td>
|
||||
<div>
|
||||
<button class="inc-dec" @click="changeValue( offer.id, 1 )">
|
||||
+
|
||||
</button>
|
||||
<p>{{ selection[ offer.id ] }}</p>
|
||||
<button class="inc-dec" @click="changeValue( offer.id, -1 )">
|
||||
-
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-if="Object.keys( offering ).includes( selectedBar )">
|
||||
Total: CHF {{ total }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bar-utility {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
>.offering-wrapper {
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 5vh;
|
||||
|
||||
.offering {
|
||||
&.show-line {
|
||||
>td {
|
||||
border-bottom: solid 1px black;
|
||||
}
|
||||
}
|
||||
|
||||
>td {
|
||||
padding: 5px;
|
||||
p {
|
||||
margin: 0;
|
||||
margin-right: 15px;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
>div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
|
||||
p {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
>.inc-dec {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
background: none;
|
||||
border: solid var( --primary-color ) 1px;
|
||||
border-radius: 20px;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 1.5rem;
|
||||
color: var( --primary-color );
|
||||
touch-action: manipulation;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="top-view">
|
||||
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
|
||||
<h1>MusicPlayer</h1>
|
||||
<p v-if="reasonForRedirectHere" style="color: red;">
|
||||
{{ reasons[ reasonForRedirectHere ] }}
|
||||
</p>
|
||||
<p v-if="!reasonForRedirectHere">
|
||||
<i>An Open Source, browser-based MusicPlayer with beautiful graphics</i>
|
||||
</p>
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="https://store.janishutz.com/product/com.janishutz.MusicPlayer" class="fancy-button" target="_blank">Subscribe</a>
|
||||
<a
|
||||
v-if="!reasonForRedirectHere"
|
||||
href="/"
|
||||
class="fancy-button"
|
||||
style="margin-left: 10px;"
|
||||
>Log in</a>
|
||||
<button
|
||||
v-if="reasonForRedirectHere"
|
||||
href="/"
|
||||
class="fancy-button"
|
||||
style="margin-left: 10px;"
|
||||
@click="logout()"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/simplePCBuilding/MusicPlayerV2"
|
||||
class="fancy-button"
|
||||
style="margin-left: 10px;"
|
||||
target="_blank"
|
||||
>GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Fully featured Music Player</h2>
|
||||
<p>All the features you'd expect a Music Player to have are also present here</p>
|
||||
|
||||
<h2>Apple Music integration</h2>
|
||||
<p>Use MusicPlayer in conjunction with Apple Music</p>
|
||||
|
||||
<h2>Share your playlist</h2>
|
||||
<p>
|
||||
You can share your playlist on a beautifully animated public page,
|
||||
so that other people can join in and view your playlist
|
||||
</p>
|
||||
|
||||
<h2>Fully browser based</h2>
|
||||
<p>No installation required when using MusicPlayer on <a href="https://music.janishutz.com">music.janishutz.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref, type Ref
|
||||
} from 'vue';
|
||||
|
||||
interface Reasons {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const reasons: Ref<Reasons> = ref( {
|
||||
'notOwned': 'Please subscribe to use MusicPlayer here, or download and install it manually from GitHub!',
|
||||
} );
|
||||
const reasonForRedirectHere = ref( sessionStorage.getItem( 'getRedirectionReason' ) );
|
||||
|
||||
sessionStorage.removeItem( 'getRedirectionReason' );
|
||||
|
||||
const logout = () => {
|
||||
// location.href = 'http://localhost:8080/logout?return=' + location.href;
|
||||
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 60vh;
|
||||
max-height: 90vw;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.top-view {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.promo-img {
|
||||
height: 100vh;
|
||||
width: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<div class="home-view">
|
||||
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
|
||||
<button
|
||||
:class="'fancy-button' + ( isTryingToSignIn ? ' fancy-button-inactive' : '' )"
|
||||
style="margin-top: 5vh;"
|
||||
title="Sign in or sign up with janishutz.com ID"
|
||||
@click="login()"
|
||||
>
|
||||
{{ isTryingToSignIn ? 'Signing you in...' : 'Login / Sign up' }}
|
||||
</button>
|
||||
<p style="width: 80%;">
|
||||
MusicPlayer is a browser based Music Player, that allows you to connect other devices,
|
||||
simply with another web-browser, where you can see the current playlist with sleek animations.
|
||||
You can log in using your Apple Music account or load a playlist from your local disk,
|
||||
simply by selecting the songs using a file picker.
|
||||
</p>
|
||||
<router-link to="/get" class="fancy-button">
|
||||
More information
|
||||
</router-link>
|
||||
<notificationsModule ref="notifications" location="bottomleft" size="bigger" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Make possible to install and use without account, if using FOSS version
|
||||
import {
|
||||
RouterLink
|
||||
} from 'vue-router';
|
||||
import notificationsModule from '@/components/notificationsModule.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import router from '@/router';
|
||||
import sdk from '@janishutz/login-sdk-browser';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/stores/userStore';
|
||||
|
||||
const notifications = ref( notificationsModule );
|
||||
const isTryingToSignIn = ref( true );
|
||||
|
||||
const login = () => {
|
||||
sdk.login();
|
||||
};
|
||||
|
||||
const store = useUserStore();
|
||||
|
||||
if ( store.isUserAuth ) {
|
||||
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
|
||||
localStorage.removeItem( 'redirect' );
|
||||
} else {
|
||||
sdk.verifyFull()
|
||||
.then( res => {
|
||||
if ( res ) {
|
||||
store.isUserAuth = true;
|
||||
|
||||
if ( localStorage.getItem( 'close-tab' ) ) {
|
||||
localStorage.removeItem( 'close-tab' );
|
||||
window.close();
|
||||
}
|
||||
|
||||
localStorage.setItem( 'login-ok', 'true' );
|
||||
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
|
||||
localStorage.removeItem( 'redirect' );
|
||||
} else {
|
||||
isTryingToSignIn.value = false;
|
||||
}
|
||||
} )
|
||||
.catch( e => {
|
||||
console.debug( e );
|
||||
isTryingToSignIn.value = false;
|
||||
} );
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 50vh;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,389 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="info">
|
||||
Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a>
|
||||
</div>
|
||||
<div class="remote-view">
|
||||
<div v-if="hasLoaded && !showCouldNotFindRoom" style="width: 100%">
|
||||
<div class="current-song-wrapper">
|
||||
<img
|
||||
v-if="playlist[ playingSong ] && playlist[ playingSong ].cover"
|
||||
id="current-image"
|
||||
:src="playlist[ playingSong ].cover"
|
||||
class="fancy-view-song-art"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<span v-else class="material-symbols-outlined fancy-view-song-art">music_note</span>
|
||||
<div class="current-song">
|
||||
<h1 style="margin-bottom: 5px;">
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
|
||||
</h1>
|
||||
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
|
||||
<p
|
||||
v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false"
|
||||
class="additional-info"
|
||||
>
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}
|
||||
</p>
|
||||
<progress id="progress" max="1000" :value="progressBar"></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-list-wrapper">
|
||||
<div v-for="song in songQueue" :key="song.id" class="song-list">
|
||||
<div class="song-details-wrapper">
|
||||
<h3>{{ song.title }}</h3>
|
||||
<p>{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="time-until">
|
||||
{{ getTimeUntil( song.id ) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <img :src="" alt=""> -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!hasLoaded && !showCouldNotFindRoom">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
<div v-else style="max-width: 80%;">
|
||||
<span class="material-symbols-outlined" style="font-size: 4rem;">wifi_off</span>
|
||||
<h1>Couldn't connect!</h1>
|
||||
<p>
|
||||
There does not appear to be a share with the specified name, or an error occurred when connecting.
|
||||
</p>
|
||||
<p>You may <a href="">reload</a> the page to try again!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SocketConnection from '@/scripts/connection';
|
||||
import type {
|
||||
Song
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
computed, ref, type Ref
|
||||
} from 'vue';
|
||||
|
||||
const isPlaying = ref( false );
|
||||
const playlist: Ref<Song[]> = ref( [] );
|
||||
const pos = ref( 0 );
|
||||
const playingSong = ref( 0 );
|
||||
const progressBar = ref( 0 );
|
||||
const hasLoaded = ref( false );
|
||||
const showCouldNotFindRoom = ref( false );
|
||||
const playbackStart = ref( 0 );
|
||||
|
||||
let timeTracker = 0;
|
||||
|
||||
const conn = new SocketConnection();
|
||||
|
||||
conn.connect().then( d => {
|
||||
playlist.value = d.playlist;
|
||||
isPlaying.value = d.playbackStatus;
|
||||
playingSong.value = d.playlistIndex;
|
||||
playbackStart.value = d.playbackStart;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
}
|
||||
|
||||
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
|
||||
progressBar.value = ( pos.value / ( playlist.value[ playingSong.value ]
|
||||
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
|
||||
hasLoaded.value = true;
|
||||
conn.registerListener( 'playlist', data => {
|
||||
playlist.value = data;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback', data => {
|
||||
isPlaying.value = data;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
} else {
|
||||
stopTimeTracker();
|
||||
}
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback-start', data => {
|
||||
playbackStart.value = data;
|
||||
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playlist-index', data => {
|
||||
playingSong.value = parseInt( data );
|
||||
} );
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
conn.registerListener( 'delete-share', _ => {
|
||||
alert( `This share was just deleted. It is no longer available.
|
||||
The page will reload automatically to try and re-establish connection!` );
|
||||
conn.disconnect();
|
||||
location.reload();
|
||||
} );
|
||||
} )
|
||||
.catch( e => {
|
||||
console.error( e );
|
||||
showCouldNotFindRoom.value = true;
|
||||
} );
|
||||
|
||||
const songQueue = computed( () => {
|
||||
let ret: Song[] = [];
|
||||
let pos = 0;
|
||||
|
||||
for ( let song in playlist.value ) {
|
||||
if ( pos >= playingSong.value ) {
|
||||
ret.push( playlist.value[ song ] );
|
||||
}
|
||||
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
return ret;
|
||||
} );
|
||||
const getTimeUntil = computed( () => {
|
||||
return ( song: string ) => {
|
||||
let timeRemaining = 0;
|
||||
|
||||
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
|
||||
if ( playlist.value[ i ].id == song ) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeRemaining += playlist.value[ i ].duration;
|
||||
}
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Currently playing';
|
||||
} else {
|
||||
return 'Playing in less than ' + Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min';
|
||||
}
|
||||
} else {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Plays next';
|
||||
} else {
|
||||
return 'Playing less than '
|
||||
+ Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min after starting to play';
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
const startTimeTracker = () => {
|
||||
try {
|
||||
clearInterval( timeTracker );
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
timeTracker = setInterval( () => {
|
||||
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
|
||||
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
|
||||
|
||||
if ( isNaN( progressBar.value ) ) {
|
||||
progressBar.value = 0;
|
||||
}
|
||||
|
||||
if ( playlist.value[ playingSong.value ].duration + 10 - pos.value < 0 ) {
|
||||
stopTimeTracker();
|
||||
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
|
||||
location.reload();
|
||||
}
|
||||
}, 100 );
|
||||
};
|
||||
|
||||
const stopTimeTracker = () => {
|
||||
clearInterval( timeTracker );
|
||||
};
|
||||
|
||||
document.addEventListener( 'visibilitychange', () => {
|
||||
if ( !document.hidden ) {
|
||||
if ( !conn.getStatus ) {
|
||||
stopTimeTracker();
|
||||
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
} );
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#themeSelector {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.remote-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-align: justify;
|
||||
background-color: rgb(2, 16, 61);
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loaded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 10vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
background-color: rgba( 0, 0, 0, 0.6 );
|
||||
}
|
||||
|
||||
.playing-symbols-wrapper {
|
||||
width: 4vw;
|
||||
height: 5vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.song-list-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-bottom: 5%;
|
||||
}
|
||||
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
margin: 2px;
|
||||
padding: 1vh;
|
||||
border: 1px white solid;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.song-details-wrapper {
|
||||
margin: 0;
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
width: 65%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 5vw !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-song-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-bottom: 2%;
|
||||
margin-top: 1%;
|
||||
}
|
||||
|
||||
.current-song {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-top: 1vh;
|
||||
padding: 1vh;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.fancy-view-song-art {
|
||||
height: 30vh;
|
||||
width: 30vh;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 30vh !important;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
#app {
|
||||
background-color: rgba( 0, 0, 0, 0 );
|
||||
}
|
||||
|
||||
#progress, #progress::-webkit-progress-bar {
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: rgb(82, 82, 82);
|
||||
width: 30vw;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
accent-color: white;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#progress::-moz-progress-bar {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#progress::-webkit-progress-value {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
right: 0.5%;
|
||||
top: 0.5%;
|
||||
padding: 0.5%;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.additional-info {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: fixed;
|
||||
font-size: 12px;
|
||||
transform: rotate(270deg);
|
||||
left: -150px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.time-until {
|
||||
width: 30%;
|
||||
text-align: end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,595 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<span
|
||||
v-if="isAntiTamperEnabled"
|
||||
class="anti-tamper material-symbols-outlined"
|
||||
@click="secureModeInfo( 'toggle' )"
|
||||
>lock</span>
|
||||
<div
|
||||
v-if="isShowingSecureModeInfo && isAntiTamperEnabled"
|
||||
class="anti-tamper-info"
|
||||
@click="secureModeInfo( 'hide' )"
|
||||
>
|
||||
Anti-Tamper is enabled. Leaving this window will cause a notification to be dispatched to the player!
|
||||
</div>
|
||||
<div class="info">
|
||||
Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a>
|
||||
</div>
|
||||
<div class="remote-view">
|
||||
<div v-if="hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
|
||||
<div class="current-song-wrapper">
|
||||
<img
|
||||
v-if="playlist[ playingSong ] && playlist[ playingSong ].cover"
|
||||
id="current-image"
|
||||
:src="playlist[ playingSong ].cover"
|
||||
class="fancy-view-song-art"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<span v-else class="material-symbols-outlined fancy-view-song-art">music_note</span>
|
||||
<div class="current-song">
|
||||
<h1 style="margin-bottom: 5px;">
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
|
||||
</h1>
|
||||
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
|
||||
<p
|
||||
v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false"
|
||||
class="additional-info"
|
||||
>
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}
|
||||
</p>
|
||||
<progress id="progress" max="1000" :value="progressBar"></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-selector-wrapper">
|
||||
<select v-model="visualizationSettings" @change="handleAnimationChange()">
|
||||
<option value="mic">
|
||||
Microphone (Mic access required)
|
||||
</option>
|
||||
<option value="off">
|
||||
No visualization except background
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="song-list-wrapper">
|
||||
<div v-for="song in songQueue" :key="song.id" class="song-list">
|
||||
<img v-if="song.cover" :src="song.cover" class="song-image">
|
||||
<span v-else class="material-symbols-outlined song-cover">music_note</span>
|
||||
<div
|
||||
v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying"
|
||||
class="playing-symbols"
|
||||
>
|
||||
<div class="playing-symbols-wrapper">
|
||||
<div id="bar-1" class="playing-bar"></div>
|
||||
<div id="bar-2" class="playing-bar"></div>
|
||||
<div id="bar-3" class="playing-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-details-wrapper">
|
||||
<h3>{{ song.title }}</h3>
|
||||
<p>{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="time-until">
|
||||
{{ getTimeUntil( song.id ) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <img :src="" alt=""> -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
<div v-else class="showcase-wrapper">
|
||||
<h1>Couldn't connect!</h1>
|
||||
<p>
|
||||
There does not appear to be a share with the specified name, or an error occurred when connecting.
|
||||
</p>
|
||||
<p>You may reload the page to try again!</p>
|
||||
</div>
|
||||
<div id="background" class="background">
|
||||
<div class="beat-manual"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SocketConnection from '@/scripts/connection';
|
||||
import type {
|
||||
Song
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
type Ref, computed, ref
|
||||
} from 'vue';
|
||||
import bizualizer from '@/scripts/bizualizer';
|
||||
|
||||
const isPlaying = ref( false );
|
||||
const playlist: Ref<Song[]> = ref( [] );
|
||||
const pos = ref( 0 );
|
||||
const playingSong = ref( 0 );
|
||||
const progressBar = ref( 0 );
|
||||
const hasLoaded = ref( false );
|
||||
const showCouldNotFindRoom = ref( false );
|
||||
const playbackStart = ref( 0 );
|
||||
|
||||
let timeTracker = 0;
|
||||
|
||||
const visualizationSettings = ref( 'mic' );
|
||||
const isAntiTamperEnabled = ref( false );
|
||||
const conn = new SocketConnection();
|
||||
|
||||
conn.connect().then( d => {
|
||||
playlist.value = d.playlist;
|
||||
isPlaying.value = d.playbackStatus;
|
||||
playingSong.value = d.playlistIndex;
|
||||
playbackStart.value = d.playbackStart;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
}
|
||||
|
||||
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
|
||||
progressBar.value
|
||||
= ( pos.value / ( playlist.value[ playingSong.value ]
|
||||
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
|
||||
hasLoaded.value = true;
|
||||
|
||||
if ( d.useAntiTamper ) {
|
||||
isAntiTamperEnabled.value = true;
|
||||
notifier();
|
||||
}
|
||||
|
||||
conn.registerListener( 'playlist', data => {
|
||||
playlist.value = data;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback', data => {
|
||||
isPlaying.value = data;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
} else {
|
||||
stopTimeTracker();
|
||||
}
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback-start', data => {
|
||||
playbackStart.value = data;
|
||||
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playlist-index', data => {
|
||||
playingSong.value = parseInt( data );
|
||||
setTimeout( () => {
|
||||
setBackground();
|
||||
}, 1000 );
|
||||
} );
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
conn.registerListener( 'delete-share', _ => {
|
||||
alert( 'This share was just deleted. It is no longer available. This page will reload automatically!' );
|
||||
conn.disconnect();
|
||||
location.reload();
|
||||
} );
|
||||
} )
|
||||
.catch( e => {
|
||||
console.error( e );
|
||||
showCouldNotFindRoom.value = true;
|
||||
} );
|
||||
|
||||
const songQueue = computed( () => {
|
||||
let ret: Song[] = [];
|
||||
let pos = 0;
|
||||
|
||||
for ( let song in playlist.value ) {
|
||||
if ( pos >= playingSong.value ) {
|
||||
ret.push( playlist.value[ song ] );
|
||||
}
|
||||
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
return ret;
|
||||
} );
|
||||
const getTimeUntil = computed( () => {
|
||||
return ( song: string ) => {
|
||||
let timeRemaining = 0;
|
||||
|
||||
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
|
||||
if ( playlist.value[ i ].id == song ) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeRemaining += playlist.value[ i ].duration;
|
||||
}
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Currently playing';
|
||||
} else {
|
||||
return 'Playing in less than ' + Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min';
|
||||
}
|
||||
} else {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Plays next';
|
||||
} else {
|
||||
return 'Playing less than '
|
||||
+ Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min after starting to play';
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
const startTimeTracker = () => {
|
||||
try {
|
||||
clearInterval( timeTracker );
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
setTimeout( () => {
|
||||
handleAnimationChange();
|
||||
setBackground();
|
||||
}, 1000 );
|
||||
timeTracker = setInterval( () => {
|
||||
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
|
||||
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
|
||||
|
||||
if ( isNaN( progressBar.value ) ) {
|
||||
progressBar.value = 0;
|
||||
}
|
||||
}, 100 );
|
||||
};
|
||||
|
||||
const stopTimeTracker = () => {
|
||||
clearInterval( timeTracker );
|
||||
|
||||
handleAnimationChange();
|
||||
};
|
||||
|
||||
const animateBeat = () => {
|
||||
$( '.beat-manual' ).stop();
|
||||
const duration = Math.ceil( 60 / 180 * 500 ) - 50;
|
||||
|
||||
$( '.beat-manual' ).fadeIn( 50 );
|
||||
setTimeout( () => {
|
||||
$( '.beat-manual' ).fadeOut( duration );
|
||||
setTimeout( () => {
|
||||
bizualizer.coolDown();
|
||||
$( '.beat-manual' ).stop();
|
||||
}, duration );
|
||||
}, 50 );
|
||||
};
|
||||
|
||||
const handleAnimationChange = () => {
|
||||
if ( visualizationSettings.value === 'mic' && isPlaying.value ) {
|
||||
bizualizer.subscribeToBeatUpdate( animateBeat );
|
||||
} else {
|
||||
bizualizer.unsubscribeFromBeatUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const setBackground = () => {
|
||||
bizualizer.createBackground().then( bg => {
|
||||
$( '#background' ).css( 'background', bg );
|
||||
} );
|
||||
};
|
||||
|
||||
const notifier = () => {
|
||||
Notification.requestPermission();
|
||||
|
||||
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
|
||||
|
||||
// Detect if window is currently in focus
|
||||
window.onblur = () => {
|
||||
sendNotification();
|
||||
};
|
||||
|
||||
// Detect if browser window becomes hidden (also with blur event)
|
||||
document.onvisibilitychange = () => {
|
||||
if ( document.visibilityState === 'hidden' ) {
|
||||
sendNotification();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const sendNotification = () => {
|
||||
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
|
||||
'body': 'Please return to the original webpage immediately!',
|
||||
'requireInteraction': true,
|
||||
} );
|
||||
|
||||
conn.emit( 'tampering', '' );
|
||||
};
|
||||
|
||||
const isShowingSecureModeInfo = ref( false );
|
||||
|
||||
const secureModeInfo = ( action: string ) => {
|
||||
if ( action === 'toggle' ) {
|
||||
isShowingSecureModeInfo.value = !isShowingSecureModeInfo.value;
|
||||
} else if ( action === 'show' ) {
|
||||
isShowingSecureModeInfo.value = true;
|
||||
} else {
|
||||
isShowingSecureModeInfo.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.anti-tamper {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
font-size: 2rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.anti-tamper-info {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
bottom: calc( 10px + 2rem );
|
||||
right: 5px;
|
||||
max-width: 20rem;
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.remote-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.showcase-wrapper {
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 10vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
background-color: rgba( 0, 0, 0, 0.6 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.playing-symbols-wrapper {
|
||||
width: 4vw;
|
||||
height: 5vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.playing-bar {
|
||||
height: 60%;
|
||||
background-color: white;
|
||||
width: 10%;
|
||||
border-radius: 50px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#bar-1 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#bar-2 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
#bar-3 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes music-playing {
|
||||
0% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
50% {
|
||||
transform: scaleY( 0.5 );
|
||||
}
|
||||
100% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
}
|
||||
|
||||
.song-list-wrapper {
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
margin: 2px;
|
||||
padding: 1vh;
|
||||
border: 1px white solid;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.song-details-wrapper {
|
||||
margin: 0;
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.song-list .song-image {
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 5vw;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 5vw !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-song-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
height: 55vh;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5%;
|
||||
margin-top: 0.25%;
|
||||
}
|
||||
|
||||
.current-song {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-top: 1vh;
|
||||
padding: 1vh;
|
||||
text-align: center;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.fancy-view-song-art {
|
||||
height: 30vh;
|
||||
width: 30vh;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 30vh !important;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
#app {
|
||||
background-color: rgba( 0, 0, 0, 0 );
|
||||
}
|
||||
|
||||
#progress, #progress::-webkit-progress-bar {
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: rgb(82, 82, 82);
|
||||
width: 30vw;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
accent-color: white;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#progress::-moz-progress-bar {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#progress::-webkit-progress-value {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
right: 0.5%;
|
||||
top: 0.5%;
|
||||
padding: 0.5%;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dancing-style {
|
||||
font-size: 250%;
|
||||
margin: 0;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: fixed;
|
||||
font-size: 12px;
|
||||
transform: rotate(270deg);
|
||||
left: -150px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 50%;
|
||||
z-index: 100;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.background {
|
||||
position: fixed;
|
||||
left: -50vw;
|
||||
width: 200vw;
|
||||
height: 200vw;
|
||||
top: -50vw;
|
||||
z-index: 1;
|
||||
filter: blur(10px);
|
||||
background: conic-gradient( blue, green, red, blue );
|
||||
animation: gradientAnim 10s infinite linear;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.beat, .beat-manual {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: rgba( 0, 0, 0, 0.1 );
|
||||
display: none;
|
||||
}
|
||||
|
||||
.beat {
|
||||
animation: beatAnim 0.6s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes beatAnim {
|
||||
0% {
|
||||
background-color: rgba( 0, 0, 0, 0.2 );
|
||||
}
|
||||
20% {
|
||||
background-color: rgba( 0, 0, 0, 0 );
|
||||
}
|
||||
100% {
|
||||
background-color: rgba( 0, 0, 0, 0.2 );
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientAnim {
|
||||
from {
|
||||
transform: rotate( 0deg );
|
||||
}
|
||||
to {
|
||||
transform: rotate( 360deg );
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "public/musickit.js"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node", "jquery"]
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { nodePolyfills } from 'vite-plugin-node-polyfills'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
nodePolyfills(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
}
|
||||
},
|
||||
server: {
|
||||
port: 8081
|
||||
}
|
||||
})
|
||||
@@ -1,43 +0,0 @@
|
||||
{
|
||||
"name": "musicplayer-v2-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "The backend for MusicPlayerV2",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/simplePCBuilding/MusicPlayerV2.git"
|
||||
},
|
||||
"author": "Janis Hutz",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"bugs": {
|
||||
"url": "https://github.com/simplePCBuilding/MusicPlayerV2/issues"
|
||||
},
|
||||
"homepage": "https://github.com/simplePCBuilding/MusicPlayerV2#readme",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"eslint-plugin-vue": "^10.2.0",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.35.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@janishutz/login-sdk-server": "^1.2.0",
|
||||
"@janishutz/login-sdk-server-stubs": "^1.0.0",
|
||||
"@janishutz/store-sdk": "^1.1.0",
|
||||
"@types/body-parser": "^1.19.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-mysql": "^0.4.2",
|
||||
"socket.io": "^4.7.5"
|
||||
}
|
||||
}
|
||||
@@ -1,279 +0,0 @@
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import cors from 'cors';
|
||||
import {
|
||||
createServer
|
||||
} from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
SocketData
|
||||
} from './definitions';
|
||||
|
||||
|
||||
// ┌ ┐
|
||||
// │ Handle FOSS vs paid version │
|
||||
// └ ┘
|
||||
const isFossVersion = false;
|
||||
|
||||
import storeSDK from '@janishutz/store-sdk';
|
||||
import sdk from '@janishutz/login-sdk-server';
|
||||
import sse from './sse';
|
||||
import socket from './socket';
|
||||
import logger from './logger';
|
||||
|
||||
// const isFossVersion = true;
|
||||
//
|
||||
// import storeSDK from './sdk/store-sdk-stub';
|
||||
// import sdk from '@janishutz/login-sdk-server-stubs';
|
||||
|
||||
|
||||
const corsOpts: cors.CorsOptions = {
|
||||
'credentials': true,
|
||||
'origin': ( origin, cb ) => {
|
||||
if ( isFossVersion ) cb( null, true );
|
||||
else cb( null, origin === 'https://music.janishutz.com' );
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const run = () => {
|
||||
const app = express();
|
||||
const httpServer = createServer( app );
|
||||
|
||||
if ( !isFossVersion ) {
|
||||
logger.info( '[ APP ] Starting in non-FOSS version' );
|
||||
|
||||
const storeConfig = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/store-sdk.config.secret.json'
|
||||
) ).toString() );
|
||||
|
||||
storeSDK.configure( storeConfig );
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/sdk.config.secret.json'
|
||||
) ).toString() );
|
||||
|
||||
// Load id.janishutz.com SDK and allow signing in
|
||||
sdk.setUp(
|
||||
{
|
||||
'prod': false,
|
||||
'service': {
|
||||
'serviceID': 'jh-music',
|
||||
'serviceToken': sdkConfig[ 'token' ]
|
||||
},
|
||||
'user-agent': sdkConfig[ 'ua' ],
|
||||
'sessionType': 'memory',
|
||||
'frontendURL': 'https://music.janishutz.com',
|
||||
'corsWhitelist': [ 'https://music.janishutz.com' ],
|
||||
'recheckTimeout': 300 * 1000,
|
||||
'advancedVerification': 'sdk',
|
||||
},
|
||||
app,
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/*
|
||||
Configuration of SSE or WebSocket
|
||||
*/
|
||||
const socketData: SocketData = {};
|
||||
|
||||
sse.useSSE( app, socketData, corsOpts, sdk.getSessionID, sdk.getSignedIn );
|
||||
socket.useWebSocket( httpServer, socketData );
|
||||
|
||||
|
||||
/*
|
||||
GENERAL ROUTES
|
||||
*/
|
||||
app.get( '/', ( _request: express.Request, response: express.Response ) => {
|
||||
response.send( 'Please visit <a href="https://music.janishutz.com">https://music.janishutz.com</a> to use this service' );
|
||||
} );
|
||||
|
||||
|
||||
|
||||
app.get(
|
||||
'/createRoomToken',
|
||||
cors( corsOpts ),
|
||||
sdk.loginCheck(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
const roomName = String( request.query.roomName ) ?? '';
|
||||
|
||||
if ( !socketData[ roomName ] ) {
|
||||
const roomToken = crypto.randomUUID();
|
||||
|
||||
socketData[ roomName ] = {
|
||||
'playbackStart': 0,
|
||||
'playbackStatus': false,
|
||||
'playlist': [],
|
||||
'playlistIndex': 0,
|
||||
'roomName': roomName,
|
||||
'roomToken': roomToken,
|
||||
'ownerUID': sdk.getUID( request ),
|
||||
'useAntiTamper': request.query.useAntiTamper === 'true'
|
||||
? true : false,
|
||||
};
|
||||
logger.debug( `Created room "${ roomName }"` );
|
||||
response.send( roomToken );
|
||||
} else {
|
||||
if (
|
||||
socketData[ roomName ].ownerUID
|
||||
=== sdk.getUID( request )
|
||||
) {
|
||||
response.send( socketData[ roomName ].roomToken );
|
||||
} else {
|
||||
response.status( 409 ).send( 'ERR_CONFLICT' );
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
app.get(
|
||||
'/getAppleMusicDevToken',
|
||||
cors( corsOpts ),
|
||||
sdk.loginCheck(),
|
||||
( req, res ) => {
|
||||
checkIfOwned( req )
|
||||
.then( owned => {
|
||||
if ( owned ) {
|
||||
// sign dev token
|
||||
const privateKey = fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/apple_private_key.p8'
|
||||
) ).toString();
|
||||
const config = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/apple-music-api.config.secret.json'
|
||||
) ).toString() );
|
||||
const now = new Date().getTime();
|
||||
const tomorrow = now + ( 24 * 3600 * 1000 );
|
||||
const jwtToken = jwt.sign( {
|
||||
'iss': config.teamID,
|
||||
'iat': Math.floor( now / 1000 ),
|
||||
'exp': Math.floor( tomorrow / 1000 ),
|
||||
}, privateKey, {
|
||||
'algorithm': 'ES256',
|
||||
'keyid': config.keyID
|
||||
} );
|
||||
|
||||
res.send( jwtToken );
|
||||
} else {
|
||||
res.status( 402 ).send( 'ERR_NOT_OWNED' );
|
||||
}
|
||||
} )
|
||||
.catch( e => {
|
||||
if ( e === 'ERR_NOT_OWNED' ) {
|
||||
res.status( 402 ).send( e );
|
||||
} else if ( e === 'ERR_AUTH_REQUIRED' ) {
|
||||
res.status( 401 ).send( e );
|
||||
} else {
|
||||
res.send( 500 ).send( e );
|
||||
}
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const ownedCache = {};
|
||||
|
||||
const checkIfOwned = ( request: express.Request ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const uid = sdk.getUID( request );
|
||||
|
||||
if ( ownedCache[ uid ] ) {
|
||||
resolve( ownedCache[ uid ] );
|
||||
} else {
|
||||
storeSDK.getSubscriptions( uid )
|
||||
.then( stat => {
|
||||
logger.log( 'Subscription check was successful' );
|
||||
const now = new Date().getTime();
|
||||
|
||||
for ( const sub in stat ) {
|
||||
if ( stat[ sub ].expires - now > 0
|
||||
&& (
|
||||
stat[ sub ].id
|
||||
=== 'com.janishutz.MusicPlayer.subscription'
|
||||
|| stat[ sub ].id
|
||||
=== 'com.janishutz.MusicPlayer.subscription-month'
|
||||
)
|
||||
) {
|
||||
ownedCache[ uid ] = true;
|
||||
resolve( true );
|
||||
}
|
||||
}
|
||||
|
||||
ownedCache[ uid ] = false;
|
||||
resolve( false );
|
||||
} )
|
||||
.catch( e => {
|
||||
logger.error( 'Subscription check unsuccessful with error', e );
|
||||
reject( 'ERR_NOT_OWNED' );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
app.get(
|
||||
'/checkUserStatus',
|
||||
cors( corsOpts ),
|
||||
sdk.loginCheck(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
checkIfOwned( request )
|
||||
.then( owned => {
|
||||
if ( owned ) {
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.status( 402 ).send( 'ERR_NOT_OWNED' );
|
||||
}
|
||||
} )
|
||||
.catch( e => {
|
||||
if ( e === 'ERR_NOT_OWNED' ) {
|
||||
response.status( 402 ).send( e );
|
||||
} else if ( e === 'ERR_AUTH_REQUIRED' ) {
|
||||
response.status( 401 ).send( e );
|
||||
} else {
|
||||
response.send( 500 ).send( e );
|
||||
}
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
app.use( ( request: express.Request, response: express.Response ) => {
|
||||
response.status( 404 ).send( 'ERR_NOT_FOUND: ' + request.path );
|
||||
} );
|
||||
|
||||
|
||||
const PORT = process.env.PORT || 8082;
|
||||
|
||||
httpServer.listen( PORT );
|
||||
};
|
||||
|
||||
export default {
|
||||
run
|
||||
};
|
||||
|
||||
23
backend/src/definitions.d.ts
vendored
23
backend/src/definitions.d.ts
vendored
@@ -1,23 +0,0 @@
|
||||
export interface Room {
|
||||
'playbackStatus': boolean;
|
||||
'playbackStart': number;
|
||||
'playlist': Song[];
|
||||
'playlistIndex': number;
|
||||
'roomName': string;
|
||||
'roomToken': string;
|
||||
'ownerUID': string;
|
||||
'useAntiTamper': boolean;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
'title': string;
|
||||
'artist': string;
|
||||
'duration': number;
|
||||
'cover': string;
|
||||
'additionalInfo'?: string;
|
||||
}
|
||||
|
||||
export interface SocketData {
|
||||
[key: string]: Room;
|
||||
}
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import http from 'node:http';
|
||||
import {
|
||||
Server
|
||||
} from 'socket.io';
|
||||
import type {
|
||||
SocketData,
|
||||
Song
|
||||
} from './definitions';
|
||||
|
||||
const useWebSocket = ( httpServer: http.Server, socketData: SocketData ) => {
|
||||
// Websocket for events
|
||||
const io = new Server( httpServer, {
|
||||
'cors': {
|
||||
'origin': true,
|
||||
'credentials': true,
|
||||
}
|
||||
} );
|
||||
|
||||
io.on( 'connection', socket => {
|
||||
socket.on( 'create-room', ( room: {
|
||||
'name': string,
|
||||
'token': string
|
||||
}, cb: ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => void ) => {
|
||||
if ( socketData[ room.name ] ) {
|
||||
if ( room.token === socketData[ room.name ].roomToken ) {
|
||||
socket.join( room.name );
|
||||
cb( {
|
||||
'status': true,
|
||||
'msg': 'ADDED_TO_ROOM'
|
||||
} );
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_TOKEN_INVALID'
|
||||
} );
|
||||
}
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_NAME_INVALID'
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'delete-room', ( room: {
|
||||
'name': string,
|
||||
'token': string
|
||||
}, cb: ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => void ) => {
|
||||
if ( socketData[ room.name ] ) {
|
||||
if ( room.token === socketData[ room.name ].roomToken ) {
|
||||
socket.leave( room.name );
|
||||
socket.to( room.name ).emit( 'delete-share', room.name );
|
||||
socketData[ room.name ] = undefined;
|
||||
cb( {
|
||||
'status': true,
|
||||
'msg': 'ROOM_DELETED'
|
||||
} );
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_TOKEN_INVALID'
|
||||
} );
|
||||
}
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_NAME_INVALID'
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'join-room', ( room: string, cb: ( res: {
|
||||
'status': boolean,
|
||||
'msg': string,
|
||||
'data'?: {
|
||||
'playbackStatus': boolean,
|
||||
'playbackStart': number,
|
||||
'playlist': Song[],
|
||||
'playlistIndex': number,
|
||||
'useAntiTamper': boolean
|
||||
}
|
||||
} ) => void ) => {
|
||||
if ( socketData[ room ] ) {
|
||||
socket.join( room );
|
||||
cb( {
|
||||
'data': {
|
||||
'playbackStart': socketData[ room ].playbackStart,
|
||||
'playbackStatus': socketData[ room ].playbackStatus,
|
||||
'playlist': socketData[ room ].playlist,
|
||||
'playlistIndex': socketData[ room ].playlistIndex,
|
||||
'useAntiTamper': socketData[ room ].useAntiTamper,
|
||||
},
|
||||
'msg': 'STATUS_OK',
|
||||
'status': true,
|
||||
} );
|
||||
} else {
|
||||
cb( {
|
||||
'msg': 'ERR_NO_ROOM_WITH_THIS_ID',
|
||||
'status': false,
|
||||
} );
|
||||
socket.disconnect();
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'tampering', ( data: {
|
||||
'msg': string,
|
||||
'roomName': string
|
||||
} ) => {
|
||||
if ( data.roomName ) {
|
||||
socket.to( data.roomName ).emit( 'tampering-msg', data.msg );
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playlist-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': Song[]
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
if ( socketData[ data.roomName ].playlist !== data.data ) {
|
||||
socketData[ data.roomName ].playlist = data.data;
|
||||
io.to( data.roomName ).emit( 'playlist', data.data );
|
||||
}
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playback-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': boolean
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
socketData[ data.roomName ].playbackStatus = data.data;
|
||||
io.to( data.roomName ).emit( 'playback', data.data );
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playlist-index-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': number
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
socketData[ data.roomName ].playlistIndex = data.data;
|
||||
io.to( data.roomName ).emit( 'playlist-index', data.data );
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playback-start-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': number
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
socketData[ data.roomName ].playbackStart = data.data;
|
||||
io.to( data.roomName ).emit( 'playback-start', data.data );
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
export default {
|
||||
useWebSocket
|
||||
};
|
||||
@@ -1,286 +0,0 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import {
|
||||
SocketData
|
||||
} from './definitions';
|
||||
import logger from './logger';
|
||||
|
||||
const useSSE = (
|
||||
app: express.Application,
|
||||
socketData: SocketData,
|
||||
corsOpts: cors.CorsOptions,
|
||||
getSessionID: ( request: express.Request ) => string,
|
||||
getSignedIn: ( request: express.Request ) => boolean
|
||||
) => {
|
||||
/*
|
||||
ROUTES FOR SERVER SENT EVENTS VERSION
|
||||
*/
|
||||
// Connected clients have their session ID as key
|
||||
interface SocketClientList {
|
||||
[key: string]: SocketClient;
|
||||
}
|
||||
|
||||
interface SocketClient {
|
||||
'response': express.Response;
|
||||
'room': string;
|
||||
}
|
||||
|
||||
interface ClientReferenceList {
|
||||
/**
|
||||
* Find all clients connected to one room
|
||||
*/
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
const importantClients: SocketClientList = {};
|
||||
const connectedClients: SocketClientList = {};
|
||||
const clientReference: ClientReferenceList = {};
|
||||
|
||||
app.get(
|
||||
'/socket/connection',
|
||||
cors( corsOpts ),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.query.room ) {
|
||||
if ( socketData[ String( request.query.room ) ] ) {
|
||||
response.writeHead( 200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
} );
|
||||
response.status( 200 );
|
||||
response.flushHeaders();
|
||||
response.write( `data: ${ JSON.stringify( {
|
||||
'type': 'basics',
|
||||
'data': socketData[ String( request.query.room ) ]
|
||||
} ) }\n\n` );
|
||||
const sid = getSessionID( request );
|
||||
|
||||
if ( getSignedIn( request ) ) {
|
||||
importantClients[ sid ] = {
|
||||
'response': response,
|
||||
'room': String( request.query.room )
|
||||
};
|
||||
}
|
||||
|
||||
connectedClients[ sid ] = {
|
||||
'response': response,
|
||||
'room': String( request.query.room )
|
||||
};
|
||||
|
||||
if ( !clientReference[ String( request.query.room ) ] ) {
|
||||
clientReference[ String( request.query.room ) ] = [];
|
||||
}
|
||||
|
||||
if ( !clientReference[ String( request.query.room ) ]
|
||||
.includes( sid ) ) {
|
||||
clientReference[ String( request.query.room ) ].push( sid );
|
||||
}
|
||||
|
||||
request.on( 'close', () => {
|
||||
try {
|
||||
importantClients[ sid ] = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( e ) { /* empty */ }
|
||||
|
||||
const cl = clientReference[ String( request.query.room ) ];
|
||||
|
||||
for ( const c in cl ) {
|
||||
if ( cl[ c ] === sid ) {
|
||||
try {
|
||||
cl.splice( parseInt( c ), 1 );
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( _ ) { /* empty */ }
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
connectedClients[ sid ] = undefined;
|
||||
} );
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/socket/getData',
|
||||
cors( corsOpts ),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.query.room ) {
|
||||
response.send( socketData[ String( request.query.room ) ] );
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/socket/joinRoom',
|
||||
cors( corsOpts ),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.query.room ) {
|
||||
if ( socketData[ String( request.query.room ) ] ) {
|
||||
logger.debug( `Room "${ request.query.room }" was joined` );
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.options( '/socket/update', cors( corsOpts ) );
|
||||
app.post(
|
||||
'/socket/update',
|
||||
cors( corsOpts ),
|
||||
bodyParser.json(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( socketData[ request.body.roomName ] ) {
|
||||
if ( request.body.event === 'tampering' ) {
|
||||
logger.debug( `Room "${
|
||||
request.query.roomName }" has new event: Tampering` );
|
||||
|
||||
const clients = clientReference[ request.body.roomName ];
|
||||
|
||||
if ( !clients ) {
|
||||
response.send( 'ERR_CANNOT_SEND' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const client in clients ) {
|
||||
if ( importantClients[ clients[ client ] ] ) {
|
||||
importantClients[ clients[ client ] ]
|
||||
.response.write( 'data: ' + JSON.stringify( {
|
||||
'type': 'tampering-msg',
|
||||
'data': true
|
||||
} ) + '\n\n' );
|
||||
}
|
||||
}
|
||||
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
if (
|
||||
socketData[ request.body.roomName ].roomToken
|
||||
=== request.body.roomToken
|
||||
) {
|
||||
let send = false;
|
||||
let update = '';
|
||||
|
||||
if ( request.body.event === 'playback-start-update' ) {
|
||||
send = true;
|
||||
update = 'playback-start';
|
||||
socketData[ request.body.roomName ]
|
||||
.playbackStart = request.body.data;
|
||||
} else if ( request.body.event === 'playback-update' ) {
|
||||
send = true;
|
||||
update = 'playback';
|
||||
socketData[ request.body.roomName ]
|
||||
.playbackStatus = request.body.data;
|
||||
} else if ( request.body.event === 'playlist-update' ) {
|
||||
send = true;
|
||||
update = 'playlist';
|
||||
socketData[ request.body.roomName ]
|
||||
.playlist = request.body.data;
|
||||
} else if ( request.body.event === 'playlist-index-update' ) {
|
||||
send = true;
|
||||
update = 'playlist-index';
|
||||
socketData[ request.body.roomName ]
|
||||
.playlistIndex = request.body.data;
|
||||
}
|
||||
|
||||
logger.debug( `Room "${
|
||||
request.query.roomName }" has new event: ${ update }` );
|
||||
|
||||
if ( send ) {
|
||||
const clients = clientReference[ request.body.roomName ];
|
||||
|
||||
if ( !clients ) {
|
||||
response.send( 'ERR_CANNOT_SEND' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const client in clients ) {
|
||||
if ( connectedClients[ clients[ client ] ] ) {
|
||||
connectedClients[ clients[ client ] ]
|
||||
.response.write( 'data: ' + JSON.stringify( {
|
||||
'type': update,
|
||||
'data': request.body.data
|
||||
} ) + '\n\n' );
|
||||
}
|
||||
}
|
||||
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_CANNOT_SEND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 403 ).send( 'ERR_UNAUTHORIZED' );
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_WRONG_REQUEST' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
app.options( '/socket/deleteRoom', cors( corsOpts ) );
|
||||
app.post(
|
||||
'/socket/deleteRoom',
|
||||
cors( corsOpts ),
|
||||
bodyParser.json(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.body.roomName ) {
|
||||
if ( socketData[ request.body.roomName ] ) {
|
||||
if (
|
||||
socketData[ request.body.roomName ].roomToken
|
||||
=== request.body.roomToken
|
||||
) {
|
||||
logger.debug( `Room "${
|
||||
request.query.roomName }" was deleted` );
|
||||
socketData[ request.body.roomName ] = undefined;
|
||||
const clients = clientReference[ request.body.roomName ];
|
||||
|
||||
if ( !clients ) {
|
||||
response.send( 'ok' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const client in clients ) {
|
||||
if ( connectedClients[ clients[ client ] ] ) {
|
||||
connectedClients[ clients[ client ] ]
|
||||
.response.write( 'data: ' + JSON.stringify( {
|
||||
'type': 'delete-share',
|
||||
'data': true
|
||||
} ) + '\n\n' );
|
||||
}
|
||||
}
|
||||
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.send( 403 ).send( 'ERR_UNAUTHORIZED' );
|
||||
}
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_NO_ROOM_NAME' );
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
useSSE
|
||||
};
|
||||
@@ -1,386 +0,0 @@
|
||||
/*
|
||||
* libreevent - db.js
|
||||
*
|
||||
* Created by Janis Hutz 03/26/2023, Licensed under the GPL V3 License
|
||||
* https://janishutz.com, development@janishutz.com
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import * as sqlDB from './mysqldb.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
declare let __dirname: string | undefined;
|
||||
|
||||
if ( typeof __dirname === 'undefined' ) {
|
||||
__dirname = path.resolve( path.dirname( '' ) );
|
||||
} else {
|
||||
__dirname = __dirname + '/../';
|
||||
}
|
||||
|
||||
const dbRef = {
|
||||
'user': 'music_users',
|
||||
'users': 'music_users',
|
||||
};
|
||||
const dbh = new sqlDB.SQLDB();
|
||||
|
||||
dbh.connect();
|
||||
|
||||
/**
|
||||
* Initialize database (create tables, etc)
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const initDB = (): undefined => {
|
||||
( async () => {
|
||||
logger.info( '[ DB ] Setting up...' );
|
||||
dbh.setupDB();
|
||||
logger.info( '[ DB ] Setting up complete!' );
|
||||
} )();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve data from the database
|
||||
* @param {string} db The name of the database
|
||||
* @param {string} column The name of the column of the data-table in which to search for the searchQuery
|
||||
* @param {string} searchQuery The query for the selected column
|
||||
* @returns {Promise<object>} Returns a promise that resolves to an object containing the results.
|
||||
*/
|
||||
const getDataSimple = ( db: string, column: string, searchQuery: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'getFilteredData',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( data => {
|
||||
resolve( data );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Use the SQL LeftJoin function to obtain data from DB.
|
||||
* @param {string} db DB name to get data from
|
||||
* @param {string} column The column in the DB in which to search for the searchQuery
|
||||
* @param {string} searchQuery The data to look for in the selected column
|
||||
* @param {string} secondTable The second table on which to perform the left join function
|
||||
* @param {object} columns The columns to return, list of objects: { 'db': TABLE NAME, 'column': COLUMN NAME })
|
||||
* @param {string} nameOfMatchingParam Which properties should be matched to get the data, e.g. order.user_id=users.id
|
||||
* @returns {Promise<Object | Error>} Returns all records from the db and all matching data specified with the matchingParam from the secondTable.
|
||||
*/
|
||||
const getDataWithLeftJoinFunction = ( db: string, column: string, searchQuery: string, secondTable: string, columns: object, nameOfMatchingParam: string ): Promise<object> => {
|
||||
/*
|
||||
LeftJoin (Select values in first table and return all corresponding values of second table):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.columns (The columns of both tables to be selected, list of objects: { 'db': TABLE NAME, 'column': COLUMN NAME })
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
*/
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const settings = {
|
||||
'command': 'LeftJoin',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery,
|
||||
'selection': '',
|
||||
'secondTable': dbRef[ secondTable ],
|
||||
'matchingParam': dbRef[ db ] + '.' + nameOfMatchingParam + '=' + dbRef[ secondTable ] + '.' + nameOfMatchingParam,
|
||||
};
|
||||
|
||||
for ( const el in columns ) {
|
||||
settings.selection += dbRef[ columns[ el ].db ] + '.' + columns[ el ].column + ',';
|
||||
}
|
||||
|
||||
settings.selection = settings.selection.slice( 0, settings.selection.length - 1 );
|
||||
dbh.query( settings, dbRef[ db ] ).then( data => {
|
||||
resolve( data );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all data from the selected database
|
||||
* @param {string} db The database of which all data should be retrieved
|
||||
* @returns {Promise<object>} Returns an object containing all data
|
||||
*/
|
||||
const getData = ( db: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'getAllData'
|
||||
}, dbRef[ db ] ).then( data => {
|
||||
resolve( data );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Write data to the database
|
||||
* @param {string} db The database in which the data should be written
|
||||
* @param {string} column The column in which to search for the data
|
||||
* @param {string} searchQuery The query with which to search
|
||||
* @param {string} data The data to write. Also include the column & searchQuery parameters, if they also need to be added
|
||||
* @returns {Promise<object>} Returns a promise that resolves to the interaction module return.
|
||||
*/
|
||||
const writeDataSimple = ( db: string, column: string, searchQuery: string, data: any ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'checkDataAvailability',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( res => {
|
||||
if ( res.length > 0 ) {
|
||||
dbh.query( {
|
||||
'command': 'updateData',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery,
|
||||
'newValues': data
|
||||
}, dbRef[ db ] ).then( dat => {
|
||||
resolve( dat );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} else {
|
||||
dbh.query( {
|
||||
'command': 'addData',
|
||||
'data': data
|
||||
}, dbRef[ db ] ).then( dat => {
|
||||
resolve( dat );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
}
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete data from the database
|
||||
* @param {string} db The database from which the data should be deleted
|
||||
* @param {string} column The column in which to search for the data
|
||||
* @param {string} searchQuery The query with which to search
|
||||
* @returns {Promise<object>} Returns a promise that resolves to the interaction module return.
|
||||
*/
|
||||
const deleteDataSimple = ( db: string, column: string, searchQuery: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'deleteData',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( dat => {
|
||||
resolve( dat );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the data is available in the database.
|
||||
* @param {string} db The database in which to check
|
||||
* @param {string} column The column in which to search for the data
|
||||
* @param {string} searchQuery The query with which to search
|
||||
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean (true = is available)
|
||||
*/
|
||||
const checkDataAvailability = ( db: string, column: string, searchQuery: string ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'checkDataAvailability',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( res => {
|
||||
if ( res.length > 0 ) {
|
||||
resolve( true );
|
||||
} else {
|
||||
resolve( false );
|
||||
}
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Load multiple JSON files at once
|
||||
* @param {Array<string>} files The files which to load
|
||||
* @returns {Promise<object>} Returns the data from all files
|
||||
*/
|
||||
const getJSONDataBatch = async ( files: Array<string> ): Promise<object> => {
|
||||
const allFiles = {};
|
||||
|
||||
for ( const file in files ) {
|
||||
try {
|
||||
allFiles[ files[ file ] ] = await getJSONData( files[ file ] );
|
||||
} catch ( err ) {
|
||||
allFiles[ files[ file ] ] = 'ERROR: ' + err;
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all data from a JSON file
|
||||
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
|
||||
* @returns {Promise<object>} The data that was loaded
|
||||
*/
|
||||
const getJSONData = ( file: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
if ( data.byteLength > 0 ) {
|
||||
resolve( JSON.parse( data.toString() ) ?? {} );
|
||||
} else {
|
||||
resolve( { } );
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Load some data from a JSON file
|
||||
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
|
||||
* @param {string} identifier The identifier of the element which should be loaded
|
||||
* @returns {Promise<object>} The data that was loaded
|
||||
*/
|
||||
const getJSONDataSimple = ( file: string, identifier: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
if ( data.byteLength > 0 ) {
|
||||
resolve( JSON.parse( data.toString() )[ identifier ] ?? {} );
|
||||
} else {
|
||||
resolve( { } );
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Get JSON data, but synchronous (blocking)
|
||||
* @param {string} file The file to be loaded (path relative to root)
|
||||
* @returns {object} Returns the JSON file
|
||||
*/
|
||||
const getJSONDataSync = ( file: string ): object => {
|
||||
return JSON.parse( fs.readFileSync( path.join( __dirname + '/' + file ) ).toString() );
|
||||
};
|
||||
|
||||
/**
|
||||
* Description
|
||||
* @param {any} db:string
|
||||
* @param {any} identifier:string
|
||||
* @param {any} values:any
|
||||
* @returns {any}
|
||||
*/
|
||||
const writeJSONDataSimple = ( db: string, identifier: string, values: any ) => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
let dat = {};
|
||||
|
||||
if ( data.byteLength > 0 ) {
|
||||
dat = JSON.parse( data.toString() ) ?? {};
|
||||
}
|
||||
|
||||
dat[ identifier ] = values;
|
||||
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
}
|
||||
|
||||
resolve( true );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Write data to a JSON file
|
||||
* @param {string} db The database to write into
|
||||
* @param {object} data The data to write
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const writeJSONData = ( db: string, data: object ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( data ), error => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
resolve( true );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete data from a JSON file
|
||||
* @param {string} db The file to delete from (just filename, has to be in "/data/" folder, no file extension)
|
||||
* @param {string} identifier The identifier of the element to delete
|
||||
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean
|
||||
*/
|
||||
const deleteJSONDataSimple = ( db: string, identifier: string ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
let dat = {};
|
||||
|
||||
if ( data.byteLength > 0 ) {
|
||||
dat = JSON.parse( data.toString() ) ?? {};
|
||||
}
|
||||
|
||||
delete dat[ identifier ];
|
||||
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
}
|
||||
|
||||
resolve( true );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
export default {
|
||||
initDB,
|
||||
checkDataAvailability,
|
||||
deleteDataSimple,
|
||||
deleteJSONDataSimple,
|
||||
getData,
|
||||
getDataSimple,
|
||||
getDataWithLeftJoinFunction,
|
||||
getJSONData,
|
||||
getJSONDataBatch,
|
||||
getJSONDataSimple,
|
||||
getJSONDataSync,
|
||||
writeDataSimple,
|
||||
writeJSONData,
|
||||
writeJSONDataSimple
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* libreevent - mysqldb.js
|
||||
*
|
||||
* Created by Janis Hutz 07/12/2023, Licensed under the GPL V3 License
|
||||
* https://janishutz.com, development@janishutz.com
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
import mysql from 'mysql';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
|
||||
declare let __dirname: string | undefined;
|
||||
|
||||
if ( typeof __dirname === 'undefined' ) {
|
||||
__dirname = path.resolve( path.dirname( '' ) );
|
||||
} else {
|
||||
__dirname = __dirname + '/../';
|
||||
}
|
||||
|
||||
// If the connection does not work for you, you will need to add your ip
|
||||
// to the whitelist of the database
|
||||
|
||||
class SQLConfig {
|
||||
|
||||
command: string;
|
||||
|
||||
property?: string;
|
||||
|
||||
searchQuery?: string;
|
||||
|
||||
selection?: string;
|
||||
|
||||
query?: string;
|
||||
|
||||
newValues?: object;
|
||||
|
||||
secondTable?: string;
|
||||
|
||||
matchingParam?: string;
|
||||
|
||||
data?: object;
|
||||
|
||||
}
|
||||
|
||||
class SQLDB {
|
||||
|
||||
sqlConnection: mysql.Connection;
|
||||
|
||||
isRecovering: boolean;
|
||||
|
||||
config: object;
|
||||
|
||||
constructor () {
|
||||
this.config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/db.config.secret.json' ) ) );
|
||||
this.sqlConnection = mysql.createConnection( this.config );
|
||||
this.isRecovering = false;
|
||||
}
|
||||
|
||||
connect () {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const self = this;
|
||||
|
||||
if ( this.isRecovering ) {
|
||||
logger.info( '[ SQL ] Attempting to recover from critical error' );
|
||||
this.sqlConnection = mysql.createConnection( this.config );
|
||||
this.isRecovering = false;
|
||||
}
|
||||
|
||||
this.sqlConnection.connect( err => {
|
||||
if ( err ) {
|
||||
logger.error( '[ SQL ]: An error ocurred whilst connecting: ' + err.stack );
|
||||
reject( err );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info( '[ SQL ] Connected to database successfully' );
|
||||
self.sqlConnection.on( 'error', err => {
|
||||
if ( err.code === 'ECONNRESET' ) {
|
||||
self.isRecovering = true;
|
||||
setTimeout( () => {
|
||||
self.disconnect();
|
||||
self.connect();
|
||||
}, 1000 );
|
||||
} else {
|
||||
logger.error( err );
|
||||
}
|
||||
} );
|
||||
resolve( 'connection' );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
disconnect ( ) {
|
||||
this.sqlConnection.end();
|
||||
}
|
||||
|
||||
async setupDB () {
|
||||
this.sqlConnection.query( 'SELECT @@default_storage_engine;', ( error, results ) => {
|
||||
if ( error ) throw error;
|
||||
|
||||
if ( results[ 0 ][ '@@default_storage_engine' ] !== 'InnoDB' ) throw 'DB HAS TO USE InnoDB!';
|
||||
} );
|
||||
this.sqlConnection.query( 'CREATE TABLE music_users ( account_id INT ( 10 ) NOT NULL AUTO_INCREMENT, email TINYTEXT NOT NULL, uid TINYTEXT, lang TINYTEXT, username TINYTEXT, settings VARCHAR( 5000 ), PRIMARY KEY ( account_id ) ) ENGINE=INNODB;', error => {
|
||||
if ( error ) if ( error.code !== 'ER_TABLE_EXISTS_ERROR' ) throw error;
|
||||
|
||||
return 'DONE';
|
||||
} );
|
||||
}
|
||||
|
||||
query ( operation: SQLConfig, table: string ): Promise<Array<object>> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
/*
|
||||
Possible operation.command values (all need the table argument of the method call):
|
||||
- getAllData: no additional instructions needed
|
||||
|
||||
- getFilteredData:
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- InnerJoin (Select values that match in both tables):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
|
||||
- LeftJoin (Select values in first table and return all corresponding values of second table):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
|
||||
- RightJoin (Select values in second table and return all corresponding values of first table):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
|
||||
- addData:
|
||||
- operation.data (key-value pair with all data as values and column to insert into as key)
|
||||
|
||||
- deleteData:
|
||||
- operation.property (the column to search for the value)
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- updateData:
|
||||
- operation.newValues (a object with keys being the column and value being the value to be inserted into that column, values are being
|
||||
sanitised by the function)
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- checkDataAvailability:
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- fullCustomCommand:
|
||||
- operation.query (the SQL instruction to be executed) --> NOTE: This command will not be sanitised, so use only with proper sanitisation!
|
||||
*/
|
||||
let command = '';
|
||||
|
||||
if ( operation.command === 'getAllData' ) {
|
||||
command = 'SELECT * FROM ' + table;
|
||||
} else if ( operation.command === 'getFilteredData' || operation.command === 'checkDataAvailability' ) {
|
||||
command = 'SELECT * FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
} else if ( operation.command === 'fullCustomCommand' ) {
|
||||
command = operation.query;
|
||||
} else if ( operation.command === 'addData' ) {
|
||||
let keys = '';
|
||||
let values = '';
|
||||
|
||||
for ( const key in operation.data ) {
|
||||
keys += String( key ) + ', ';
|
||||
values += this.sqlConnection.escape( String( operation.data[ key ] ) ) + ', ';
|
||||
}
|
||||
|
||||
command = 'INSERT INTO ' + table + ' (' + keys.slice( 0, keys.length - 2 ) + ') VALUES (' + values.slice( 0, values.length - 2 ) + ');';
|
||||
} else if ( operation.command === 'updateData' ) {
|
||||
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
|
||||
else {
|
||||
command = 'UPDATE ' + table + ' SET ';
|
||||
let updatedValues = '';
|
||||
|
||||
for ( const value in operation.newValues ) {
|
||||
updatedValues += value + ' = ' + this.sqlConnection.escape( String( operation.newValues[ value ] ) ) + ', ';
|
||||
}
|
||||
|
||||
command += updatedValues.slice( 0, updatedValues.length - 2 );
|
||||
command += ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
}
|
||||
} else if ( operation.command === 'deleteData' ) {
|
||||
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
|
||||
else {
|
||||
command = 'DELETE FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
}
|
||||
} else if ( operation.command === 'InnerJoin' ) {
|
||||
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' INNER JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
} else if ( operation.command === 'LeftJoin' ) {
|
||||
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' LEFT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
} else if ( operation.command === 'RightJoin' ) {
|
||||
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' RIGHT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
}
|
||||
|
||||
this.sqlConnection.query( command, ( error, results ) => {
|
||||
if ( error ) reject( error );
|
||||
|
||||
resolve( results );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
SQLConfig, SQLDB
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
import db from './db.js';
|
||||
// import hash from '../security/hash.js';
|
||||
|
||||
db.initDB();
|
||||
// setTimeout( () => {
|
||||
// console.log( 'Setting up admin account' );
|
||||
// hash.hashPassword( 'test' ).then( hash => {
|
||||
// db.writeDataSimple( 'admin', 'email', 'info@janishutz.com', { email: 'info@janishutz.com', pass: hash, two_fa: 'enhanced' } );
|
||||
// console.log( 'Complete!' );
|
||||
// } );
|
||||
// }, 5000 );
|
||||
|
||||
Reference in New Issue
Block a user