Prepare for rewrite

This commit is contained in:
2026-01-08 12:42:42 +01:00
parent 576b6f9490
commit d7433e95a4
47 changed files with 0 additions and 8857 deletions

View File

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

View File

@@ -1,7 +0,0 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint"
]
}

View File

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

View File

@@ -1 +0,0 @@
/// <reference types="vite/client" />

View File

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

View File

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

View File

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

View File

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

View File

@@ -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( '&#9788;' );
const changeTheme = () => {
if ( theme.value === '&#9789;' ) {
document.documentElement.classList.remove( 'dark' );
document.documentElement.classList.add( 'light' );
localStorage.setItem( 'theme', '&#9788;' );
theme.value = '&#9788;';
} else if ( theme.value === '&#9788;' ) {
document.documentElement.classList.remove( 'light' );
document.documentElement.classList.add( 'dark' );
localStorage.setItem( 'theme', '&#9789;' );
theme.value = '&#9789;';
}
};
theme.value = localStorage.getItem( 'theme' ) ?? '';
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === '&#9789;' ) {
document.documentElement.classList.add( 'dark' );
theme.value = '&#9789;';
} else {
document.documentElement.classList.add( 'light' );
theme.value = '&#9788;';
}
</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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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/*"]
}
}
}

View File

@@ -1,11 +0,0 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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