Compare commits

28 Commits

Author SHA1 Message Date
d63df5898b Fix remotes 2025-11-06 15:13:52 +01:00
bd18636141 Fix custom playlist loading error 2025-11-06 15:09:55 +01:00
51f0b5639a Fix authentication button glitch 2025-11-02 11:24:53 +01:00
6a6b06a994 Fix auth errors 2025-11-02 11:23:48 +01:00
e2e27e640f Remove trailing comma from json 2025-10-30 11:08:49 +01:00
ac575adedb Symbol change complete 2025-10-30 11:05:16 +01:00
d15366e572 More symbol error fixes 2025-10-30 11:03:47 +01:00
25cfc80925 Fix wrong symbols 2025-10-30 11:01:42 +01:00
952ef0ee85 Address vulnerability in vite 2025-10-30 10:58:17 +01:00
80c497b80a Change to using HTML symbols for light & dark mode for better compat 2025-10-30 10:56:58 +01:00
8778740454 Bar system, add note for age check 2025-10-13 14:03:04 +02:00
74075c2919 Finish up refactor 2025-09-28 16:01:39 +02:00
3e13a4ebf5 More fixes, sdk stubs need update for this to work 2025-09-28 14:59:05 +02:00
84b640ee5e Finish up migration, currently not setup for foss version 2025-09-28 14:33:13 +02:00
0315241d76 Integrate new account backend 2025-09-28 14:23:31 +02:00
6e93cfdf2c Prepare for new sdk 2025-09-15 11:17:17 +02:00
6b9d556e57 Update bar utility 2025-09-08 15:24:02 +02:00
025c7ed111 Add bar utility 2025-09-04 16:34:54 +02:00
057ea67a95 Update eslint config 2025-09-04 16:26:42 +02:00
16543fb577 Update deps 2025-09-04 16:13:24 +02:00
f0c538126d Small bugfix, security patch 2025-06-25 10:51:16 +02:00
b0a1f9a538 fix small bug 2024-11-21 15:42:05 +01:00
Janis Hutz
9fac37203d Update README.md
Remove not available yet note for hosted version
2024-10-23 15:54:59 +02:00
b10aeed1f8 fix queue index jumping when enabling shuffle 2024-10-19 15:18:34 +02:00
b8faeef189 actually fix it now 2024-10-19 15:12:53 +02:00
4ef98f80cf fix playback pos update on song change 2024-10-19 15:05:34 +02:00
c9f487b981 security updates 2024-10-19 14:29:36 +02:00
dc2327af33 fix player playback pos update 2024-10-19 14:24:22 +02:00
44 changed files with 3799 additions and 8292 deletions

1
.gitignore vendored
View File

@@ -4,3 +4,4 @@ node_modules
apple_private_key.p8
musicplayerv2-server.zip
dist
package-lock.json

View File

@@ -1,14 +0,0 @@
/* eslint-env node */
require('@rushstack/eslint-patch/modern-module-resolution')
module.exports = {
root: true,
'extends': [
'plugin:vue/vue3-essential',
'eslint:recommended',
'@vue/eslint-config-typescript'
],
parserOptions: {
ecmaVersion: 'latest'
}
}

View File

@@ -0,0 +1,714 @@
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,22 +1,25 @@
<!DOCTYPE html>
<html lang="en">
<head>
<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">
<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>
<script src="https://id.janishutz.com/sdk/sdk.min.js"></script>
<!-- <script src="http://localhost:8080/sdk/sdk.min.js"></script> -->
<title>MusicPlayer</title>
</head>
<body>
</head>
<body>
<noscript>This application requires JavaScript to work!</noscript>
<div id="app"></div>
<script>
localStorage.setItem( 'music-player-config', 'sse' );// Or 'ws'
localStorage.setItem('music-player-config', 'sse');// Or 'ws'
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -13,31 +13,34 @@
},
"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.2.0",
"music-metadata-browser": "^2.5.10",
"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.22.0",
"vite-plugin-node-polyfills": "^0.24.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@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": "^5.0.3",
"@vue/eslint-config-typescript": "^12.0.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"eslint-plugin-vue": "^10.5.0",
"npm-run-all2": "^6.1.1",
"sass-embedded": "^1.92.0",
"typescript": "~5.3.0",
"vite": "^5.0.11",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.4",
"vue-tsc": "^2.0.29"
}
}

View File

@@ -0,0 +1,19 @@
{
"ages": {
"below": "red",
"16-18": "",
"18+": ""
},
"offering": {
"test": {
"name": "Test drink",
"price": 700,
"id": "test"
},
"test-2": {
"name": "Test drink 2",
"price": 500,
"id": "test-2"
}
}
}

View File

@@ -1,23 +1,52 @@
<!--
* libreevent - App.vue
*
* Created by Janis Hutz 05/14/2023, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
-->
<template>
<div>
<button @click="changeTheme();" id="themeSelector" title="Toggle between light and dark mode"><span class="material-symbols-outlined" v-html="theme"></span></button>
<router-view v-slot="{ Component, route }" id="main-view">
<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"></component>
<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 );
@@ -178,33 +207,3 @@
background-position: 0px;
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { RouterView } from 'vue-router';
const theme = ref( 'light_mode' );
const changeTheme = () => {
if ( theme.value === 'dark_mode' ) {
document.documentElement.classList.remove( 'dark' );
document.documentElement.classList.add( 'light' );
localStorage.setItem( 'theme', 'light_mode' );
theme.value = 'light_mode';
} else if ( theme.value === 'light_mode' ) {
document.documentElement.classList.remove( 'light' );
document.documentElement.classList.add( 'dark' );
localStorage.setItem( 'theme', 'dark_mode' );
theme.value = 'dark_mode';
}
}
theme.value = localStorage.getItem( 'theme' ) ?? '';
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === 'dark_mode' ) {
document.documentElement.classList.add( 'dark' );
theme.value = 'dark_mode';
} else {
document.documentElement.classList.add( 'light' );
theme.value = 'light_mode';
}
</script>

View File

@@ -1,24 +1,33 @@
<template>
<div>
<h1>Library</h1>
<playlistsView :playlists="$props.playlists" @selected-playlist="( id ) => selectPlaylist( id )" :is-logged-in="$props.isLoggedIn"
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"></playlistsView>
<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';
import type {
ReadFile
} from '@/scripts/song';
const emits = defineEmits( [ 'selected-playlist', 'custom-playlist' ] );
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': {

View File

@@ -3,14 +3,18 @@
<div id="notifications">
<div class="message-box" :class="[ location, size ]" :style="'z-index: ' + ( messageType === 'hide' ? '-1' : '1000' )">
<div class="message-container" :class="messageType">
<button @click="handleNotifications();" class="close-notification"><span class="material-symbols-outlined close-notification-icon">close</span></button>
<span class="material-symbols-outlined types hide" v-if="messageType == 'hide'">question_mark</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'ok'" style="background-color: green;">done</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'error'" style="background-color: red;">close</span>
<span class="material-symbols-outlined types progress-spinner" v-else-if="messageType == 'progress'" style="background-color: blue;">progress_activity</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'info'" style="background-color: lightblue;">info</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'warning'" style="background-color: orangered;">warning</span>
<p class="message" @click="notificationAction()">{{ notifications[ currentDID ] ? notifications[ currentDID ].message : '' }}</p>
<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>
@@ -19,28 +23,30 @@
<script setup lang="ts">
import router from '@/router';
import { onUnmounted, ref, type Ref } from 'vue';
import {
type Ref, onUnmounted, ref
} from 'vue';
defineProps( {
location: {
type: String,
'location': {
'type': String,
'default': 'topleft',
},
size: {
type: String,
'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;
'message': string;
'showDuration': number;
'messageType': string;
'priority': string;
'id': number;
'redirect'?: string;
'openInNewTab'?: boolean;
}
interface NotificationList {
@@ -51,11 +57,17 @@
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 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 ) {
@@ -76,7 +88,9 @@
* @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 => {
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
@@ -94,14 +108,25 @@
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 };
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
@@ -113,7 +138,7 @@
if ( notifications.value[ id ] ) {
notifications.value[ id ].message = message;
}
}
};
/**
@@ -127,26 +152,31 @@
} 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 */ }
} 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();
@@ -158,22 +188,24 @@
} else {
try {
clearInterval( progressBar );
} catch (err) { /* empty */ }
} catch ( err ) { /* empty */ }
messageType.value = 'hide';
}
}
};
const progressBarHandler = () => {
currentTime.value = new Date().getTime();
}
};
onUnmounted( () => {
try {
clearInterval( progressBar );
} catch (err) { /* empty */ }
} catch ( err ) { /* empty */ }
try {
clearInterval( notificationTimeout );
} catch (err) { /* empty */ }
} catch ( err ) { /* empty */ }
} );
defineExpose( {

View File

@@ -4,51 +4,109 @@
<h3>WARNING!</h3>
<p>A client display is being tampered with!</p>
<p>A desktop notification with a warning has already been dispatched.</p>
<button @click="dismissNotification()" class="simple-button">Ok</button>
<button class="simple-button" @click="dismissNotification()">
Ok
</button>
<div class="flash"></div>
</div>
<div class="player">
<div :class="'main-player' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )">
<div :class="'song-name-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )" @click="controlUI( 'show' )">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo-player" v-if="coverArt === ''">
<img :src="coverArt" alt="MusicPlayer Logo" class="logo-player" v-else>
<img
v-if="coverArt === ''"
src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png"
alt="MusicPlayer Logo"
class="logo-player"
>
<img
v-else
:src="coverArt"
alt="MusicPlayer Logo"
class="logo-player"
>
<div class="name-time">
<p class="song-name">{{ currentlyPlayingSongName }} <i v-if="currentlyPlayingSongArtist">by {{ currentlyPlayingSongArtist }}</i></p>
<div class="playback" v-if="!isShowingFullScreenPlayer">
<p class="song-name">
{{ currentlyPlayingSongName }} <i v-if="currentlyPlayingSongArtist">by {{ currentlyPlayingSongArtist }}</i>
</p>
<div v-if="!isShowingFullScreenPlayer" class="playback">
<div class="playback-pos-wrapper">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p class="playback-pos">
{{ nicePlaybackPos }}
</p>
<p> / </p>
<p class="playback-duration">{{ niceDuration }}</p>
<p class="playback-duration">
{{ niceDuration }}
</p>
</div>
</div>
</div>
</div>
<div :class="'controls-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )" :style="playlist.length > 0 ? '' : 'pointer-events: none'">
<div class="main-controls">
<span class="material-symbols-outlined controls next-previous" @click="control( 'previous' )" id="previous" v-if="isShowingFullScreenPlayer">skip_previous</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'back' )" :style="'rotate: -' + 360 * clickCountBack + 'deg;'" v-if="isShowingFullScreenPlayer">replay_10</span>
<span class="material-symbols-outlined controls" v-if="isPlaying" @click="playPause()" id="play-pause">pause</span>
<span class="material-symbols-outlined controls" v-else @click="playPause()" id="play-pause">play_arrow</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'forward' )" :style="'rotate: ' + 360 * clickCountForward + 'deg;'" v-if="isShowingFullScreenPlayer">forward_10</span>
<span class="material-symbols-outlined controls next-previous" @click="control( 'next' )" id="next">skip_next</span>
<span
v-if="isShowingFullScreenPlayer"
id="previous"
class="material-symbols-outlined controls next-previous"
@click="control( 'previous' )"
>skip_previous</span>
<span
v-if="isShowingFullScreenPlayer"
class="material-symbols-outlined controls forward-back"
:style="'rotate: -' + 360 * clickCountBack + 'deg;'"
@click="control( 'back' )"
>replay_10</span>
<span
v-if="isPlaying"
id="play-pause"
class="material-symbols-outlined controls"
@click="playPause()"
>pause</span>
<span
v-else
id="play-pause"
class="material-symbols-outlined controls"
@click="playPause()"
>play_arrow</span>
<span
v-if="isShowingFullScreenPlayer"
class="material-symbols-outlined controls forward-back"
:style="'rotate: ' + 360 * clickCountForward + 'deg;'"
@click="control( 'forward' )"
>forward_10</span>
<span id="next" class="material-symbols-outlined controls next-previous" @click="control( 'next' )">skip_next</span>
</div>
<div class="slider-wrapper" v-if="isShowingFullScreenPlayer">
<div v-if="isShowingFullScreenPlayer" class="slider-wrapper">
<div class="slider-pb-pos">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p class="playback-duration" @click="toggleRemaining()" title="Toggle between remaining time and song duration">{{ niceDuration }}</p>
<p class="playback-pos">
{{ nicePlaybackPos }}
</p>
<p class="playback-duration" title="Toggle between remaining time and song duration" @click="toggleRemaining()">
{{ niceDuration }}
</p>
</div>
<sliderView :position="pos" :active="true" :duration="duration" name="main" @pos="( pos ) => goToPos( pos )"></sliderView>
<sliderView
:position="pos"
:active="true"
:duration="duration"
name="main"
@pos="( pos ) => goToPos( pos )"
/>
</div>
<div class="shuffle-repeat" v-if="isShowingFullScreenPlayer">
<span class="material-symbols-outlined controls" @click="control( 'repeat' )" style="margin-right: auto;">repeat{{ repeatMode }}</span>
<div v-if="isShowingFullScreenPlayer" class="shuffle-repeat">
<span class="material-symbols-outlined controls" style="margin-right: auto;" @click="control( 'repeat' )">repeat{{ repeatMode }}</span>
<div style="margin-right: auto; pointer-events: all;">
<span class="material-symbols-outlined controls" @click="control( 'start-share' )" title="Share your playlist on a public playlist page (opens a configuration window)" v-if="!isConnectedToNotifier">share</span>
<span
v-if="!isConnectedToNotifier"
class="material-symbols-outlined controls"
title="Share your playlist on a public playlist page (opens a configuration window)"
@click="control( 'start-share' )"
>share</span>
<div v-else>
<span class="material-symbols-outlined controls" @click="control( 'stop-share' )" title="Stop sharing your playlist on a public playlist page">close</span>
<span class="material-symbols-outlined controls" @click="control( 'show-share' )" title="Show information on the share, including URL to connect to">info</span>
<span class="material-symbols-outlined controls" title="Stop sharing your playlist on a public playlist page" @click="control( 'stop-share' )">close</span>
<span class="material-symbols-outlined controls" title="Show information on the share, including URL to connect to" @click="control( 'show-share' )">info</span>
</div>
</div>
<span class="material-symbols-outlined controls" @click="control( 'shuffle' )">shuffle{{ shuffleMode }}</span>
@@ -58,33 +116,49 @@
</div>
<div :class="'playlist-view' + ( isShowingFullScreenPlayer ? '' : ' hidden' )">
<span class="material-symbols-outlined close-fullscreen" @click="controlUI( 'hide' )">close</span>
<playlistView :playlist="playlist" class="pl-wrapper" :currently-playing="currentlyPlayingSongIndex" :is-playing="isPlaying" :pos="pos"
@control="( action ) => { control( action ) }" @play-song="( song ) => { playSong( song ) }"
@add-new-songs="( songs ) => addNewSongs( songs )" @playlist-reorder="( move ) => moveSong( move )"
<playlistView
:playlist="playlist"
class="pl-wrapper"
:currently-playing="currentlyPlayingSongIndex"
:is-playing="isPlaying"
:pos="pos"
:is-logged-into-apple-music="player.isLoggedIn"
@control="( action ) => { control( action ) }"
@play-song="( song ) => { playSong( song ) }"
@add-new-songs="( songs ) => addNewSongs( songs )"
@playlist-reorder="( move ) => moveSong( move )"
@add-new-songs-apple-music="( song ) => addNewSongFromObject( song )"
@delete-song="song => removeSongFromPlaylist( song )"
@clear-playlist="() => clearPlaylist()"
@send-additional-info="() => sendAdditionalInfo()"></playlistView>
@send-additional-info="() => sendAdditionalInfo()"
/>
</div>
<notificationsModule ref="notifications" location="bottomleft" size="bigger"></notificationsModule>
<popupModule @update="( data ) => popupReturnHandler( data )" ref="popup"></popupModule>
<audio src="" id="local-audio" controls="false"></audio>
<notificationsModule ref="notifications" location="bottomleft" size="bigger" />
<popupModule ref="popup" @update="( data ) => popupReturnHandler( data )" />
<audio id="local-audio" src="" controls="false"></audio>
</div>
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue';
import playlistView from '@/components/playlistView.vue';
import type {
ReadFile, Song, SongMove
} from '@/scripts/song';
import {
type Ref, ref
} from 'vue';
import MusicKitJSWrapper from '@/scripts/music-player';
import sliderView from './sliderView.vue';
import type { ReadFile, Song, SongMove } from '@/scripts/song';
import { parseBlob } from 'music-metadata-browser';
import notificationsModule from './notificationsModule.vue';
import { useUserStore } from '@/stores/userStore';
import NotificationHandler from '@/scripts/notificationHandler';
import notificationsModule from './notificationsModule.vue';
import {
parseBlob
} from 'music-metadata';
import playlistView from '@/components/playlistView.vue';
import popupModule from './popupModule.vue';
import sliderView from './sliderView.vue';
import {
useUserStore
} from '@/stores/userStore';
const isPlaying = ref( false );
const repeatMode = ref( '' );
@@ -100,7 +174,9 @@
const nicePlaybackPos = ref( '00:00' );
const niceDuration = ref( '00:00' );
const isShowingRemainingTime = ref( false );
let isShowingRemainingTimeBackend = false;
const currentlyPlayingSongArtist = ref( '' );
const pos = ref( 0 );
const duration = ref( 0 );
@@ -110,6 +186,7 @@
const popup = ref( popupModule );
const roomName = ref( '' );
const isShowingWarning = ref( false );
let currentlyOpenPopup = '';
let logoutErrorNotification = -1;
@@ -118,19 +195,24 @@
document.addEventListener( 'musicplayer:autherror', () => {
localStorage.setItem( 'close-tab', 'true' );
isConnectedToNotifier.value = false;
logoutErrorNotification = notifications.value.createNotification( 'You appear to have been logged out. Click to log in again!', 600, 'error', 'critical', '/', true );
logoutErrorNotification = notifications.value.createNotification(
'You appear to have been logged out. Click to log in again!', 600, 'error', 'critical', '/', true
);
} );
window.addEventListener( 'storage', () => {
if ( localStorage.getItem( 'login-ok' ) === 'true' ) {
notifications.value.cancelNotification( logoutErrorNotification );
notifications.value.createNotification( 'Logged in again. You will have to reconnect to the share!', 20, 'ok', 'normal' );
notifications.value.createNotification(
'Logged in again. You will have to reconnect to the share!', 20, 'ok', 'normal'
);
localStorage.removeItem( 'login-ok' );
}
} );
const playPause = () => {
isPlaying.value = !isPlaying.value;
if ( isPlaying.value ) {
player.control( 'play' );
startProgressTracker();
@@ -138,17 +220,17 @@
player.control( 'pause' );
stopProgressTracker();
}
}
};
const goToPos = ( position: number ) => {
player.goToPos( position );
pos.value = position;
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
};
const toggleRemaining = () => {
isShowingRemainingTime.value = !isShowingRemainingTime.value;
}
};
const control = ( action: string ) => {
if ( action === 'pause' ) {
@@ -184,10 +266,13 @@
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}
notificationHandler.emit( 'playlist-index-update', player.getQueueID() );
getDetails();
} else if ( action === 'forward' ) {
clickCountForward.value += 1;
if( player.control( 'skip-10' ) ) {
if ( player.control( 'skip-10' ) ) {
startProgressTracker();
} else {
pos.value = player.getPlaybackPos();
@@ -195,7 +280,8 @@
}
} else if ( action === 'back' ) {
clickCountBack.value += 1;
if( player.control( 'back-10' ) ) {
if ( player.control( 'back-10' ) ) {
startProgressTracker();
} else {
pos.value = player.getPlaybackPos();
@@ -217,19 +303,19 @@
startProgressTracker();
} else if ( action === 'start-share' ) {
popup.value.openPopup( {
title: 'Define a share name',
popupType: 'input',
subtitle: 'A share allows others to join your playlist and see the current song, the playback position and the upcoming songs. You can get the link to the page, once the share is set up. Please choose a name, which will then be part of the URL with which others can join the share. The anti tamper feature notifies you, whenever a user leaves the fancy view.',
data: [
'title': 'Define a share name',
'popupType': 'input',
'subtitle': 'A share allows others to join your playlist and see the current song, the playback position and the upcoming songs. You can get the link to the page, once the share is set up. Please choose a name, which will then be part of the URL with which others can join the share. The anti tamper feature notifies you, whenever a user leaves the fancy view.',
'data': [
{
name: 'Share Name',
dataType: 'text',
id: 'roomName'
'name': 'Share Name',
'dataType': 'text',
'id': 'roomName'
},
{
name: 'Use Anti-Tamper?',
dataType: 'checkbox',
id: 'useAntiTamper'
'name': 'Use Anti-Tamper?',
'dataType': 'checkbox',
'id': 'useAntiTamper'
}
]
} );
@@ -238,19 +324,21 @@
if ( confirm( 'Do you really want to stop sharing?' ) ) {
notificationHandler.disconnect();
isConnectedToNotifier.value = false;
notifications.value.createNotification( 'Disconnected successfully!', 5, 'ok', 'normal' );
notifications.value.createNotification(
'Disconnected successfully!', 5, 'ok', 'normal'
);
}
} else if ( action === 'show-share' ) {
popup.value.openPopup( {
title: 'Details on share',
subtitle: 'You are currently connected to share "' + roomName.value
'title': 'Details on share',
'subtitle': 'You are currently connected to share "' + roomName.value
+ '". \nYou can connect to it via <a href="https://music.janishutz.com/share/' + roomName.value + '" target="_blank">https://music.janishutz.com/share/' + roomName.value + '</a>'
+ '. \n\nYou can connect to the fancy showcase screen using this link: <a href="https://music.janishutz.com/fancy/' + roomName.value + '" target="_blank">https://music.janishutz.com/fancy/' + roomName.value + '</a>'
+ '. Be aware that this one will use significantly more system AND network resources, so only use that for a screen that is front and center, not for a QR code to have all people connect to.'
} );
currentlyOpenPopup = 'share-details';
}
}
};
const controlUI = ( action: string ) => {
@@ -262,28 +350,30 @@
isShowingFullScreenPlayer.value = false;
isShowingRemainingTimeBackend = isShowingRemainingTime.value;
isShowingRemainingTime.value = false;
try {
prepNiceDurationTime( player.getPlayingSong() );
} catch ( err ) { /* empty */ }
emits( 'playerStateChange', 'hide' );
}
}
};
const getPlaylists = ( cb: ( data: object ) => void ) => {
player.getUserPlaylists( cb );
}
};
const logIntoAppleMusic = () => {
player.logIn();
}
};
const getAuth = (): boolean[] => {
return player.getAuth();
}
};
const skipLogin = () => {
player.init();
}
};
const selectPlaylist = ( id: string ) => {
currentlyPlayingSongArtist.value = '';
@@ -297,20 +387,26 @@
notificationHandler.emit( 'playlist-update', playlist.value );
}, 2000 );
} );
}
};
const selectCustomPlaylist = async ( pl: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing playlist', 200, 'progress', 'normal' );
let n = notifications.value.createNotification(
'Analyzing playlist', 200, 'progress', 'normal'
);
playlist.value = [];
let plLoad: Song[] = [];
const plLoad: Song[] = [];
for ( let element in pl ) {
try {
plLoad.push( await fetchSongData( pl[ element ] ) );
} catch ( e ) {
console.error( e );
}
notifications.value.updateNotification( n, `Analyzing playlist (${element}/${pl.length})` );
notifications.value.updateNotification( n, `Analyzing playlist (${ element }/${ pl.length })` );
}
playlist.value = plLoad;
player.setPlaylist( playlist.value );
player.prepare( 0 );
@@ -321,51 +417,64 @@
notificationHandler.emit( 'playlist-update', playlist.value );
}, 2000 );
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'Playlist loaded', 10, 'ok', 'normal' );
}
notifications.value.createNotification(
'Playlist loaded', 10, 'ok', 'normal'
);
};
const fetchSongData = ( songDetails: ReadFile ): Promise<Song> => {
return new Promise( ( resolve, reject ) => {
fetch( songDetails.url ).then( res => {
fetch( songDetails.url )
.then( res => {
if ( res.status === 200 ) {
res.blob().then( blob => {
parseBlob( blob ).then( data => {
parseBlob( blob )
.then( data => {
try {
player.findSongOnAppleMusic( data.common.title ?? songDetails.filename.split( '.' )[ 0 ] ).then( d => {
player.findSongOnAppleMusic( data.common.title
?? songDetails.filename.split( '.' )[ 0 ] )
.then( d => {
let url = d.data.results.songs.data[ 0 ].attributes.artwork.url;
url = url.replace( '{w}', String( d.data.results.songs.data[ 0 ].attributes.artwork.width ) );
url = url.replace( '{h}', String( d.data.results.songs.data[ 0 ].attributes.artwork.height ) );
const song: Song = {
artist: data.common.artist ?? d.data.results.songs.data[ 0 ].attributes.artistName,
title: data.common.title ?? d.data.results.songs.data[ 0 ].attributes.name,
duration: data.format.duration ?? ( d.data.results.songs.data[ 0 ].attributes.durationInMillis / 1000 ),
id: songDetails.url,
origin: 'disk',
cover: url
}
'artist': d.data.results.songs.data[ 0 ].attributes.artistName,
'title': d.data.results.songs.data[ 0 ].attributes.name,
'duration': data.format.duration ?? ( d.data.results.songs.data[ 0 ].attributes.durationInMillis / 1000 ),
'id': songDetails.url,
'origin': 'disk',
'cover': url
};
resolve( song );
} ).catch( e => {
} )
.catch( e => {
console.error( e );
const song: Song = {
artist: data.common.artist ?? 'Unknown artist',
title: data.common.title ?? 'Unknown song title',
duration: data.format.duration ?? 1000,
id: songDetails.url,
origin: 'disk',
cover: ''
}
'artist': data.common.artist ?? 'Unknown artist',
'title': data.common.title ?? 'Unknown song title',
'duration': data.format.duration ?? 1000,
'id': songDetails.url,
'origin': 'disk',
'cover': ''
};
resolve( song );
} );
} catch ( err ) {
console.error( err );
alert( 'One of your songs was not loadable. (finalization-error)' )
alert( 'One of your songs was not loadable. (finalization-error)' );
reject( err );
}
} ).catch( e => {
} )
.catch( e => {
console.error( e );
alert( 'One of your songs was not loadable. (parser-error)' );
reject( e );
} );
} ).catch( e => {
} )
.catch( e => {
console.error( e );
alert( 'One of your songs was not loadable. (converter-error)' );
reject( e );
@@ -373,30 +482,35 @@
} else {
console.error( res.status );
alert( 'One of your songs was not loadable. (invalid-response-code)' );
reject( res.status );
}
} ).catch( e => {
} )
.catch( e => {
console.error( e );
alert( 'One of your songs was not loadable. (could-not-connect)' );
reject( e );
} );
} );
}
};
const getDetails = () => {
const details = player.getPlayingSong();
currentlyPlayingSongName.value = details.title;
coverArt.value = details.cover;
currentlyPlayingSongIndex.value = player.getQueueID();
playlist.value = player.getQueue();
currentlyPlayingSongArtist.value = details.artist;
}
};
const playSong = ( id: string ) => {
const p = player.getPlaylist();
currentlyPlayingSongArtist.value = '';
coverArt.value = '';
currentlyPlayingSongName.value = 'Loading...';
stopProgressTracker();
for ( const s in p ) {
if ( p[ s ].id === id ) {
player.prepare( parseInt( s ) );
@@ -404,23 +518,29 @@
break;
}
}
}
};
let progressTracker = 0;
let progressTracker: ReturnType<typeof setInterval> = setInterval( () => {}, 1000 );
clearInterval( progressTracker );
let hasReachedEnd = false;
let hasStarted = false;
const startProgressTracker = () => {
hasReachedEnd = false;
isPlaying.value = true;
let playingSong = player.getPlayingSong();
hasStarted = false;
pos.value = 0;
progressTracker = setInterval( () => {
pos.value = player.getPlaybackPos();
if ( pos.value > playingSong.duration - 1 && !hasReachedEnd ) {
stopProgressTracker();
hasReachedEnd = true;
if ( repeatMode.value === '_one_on' ) {
player.goToPos( 0 );
setTimeout( () => {
@@ -432,21 +552,30 @@
}
if ( pos.value > 0 && !hasStarted ) {
if ( player.getPlaying() ) {
setTimeout( () => {
getDetails();
playingSong = player.getPlayingSong();
pos.value = player.getPlaybackPos();
prepNiceDurationTime( playingSong );
notificationHandler.emit( 'playlist-index-update', currentlyPlayingSongIndex.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
hasStarted = true;
}, 2000 );
}
}
const minuteCount = Math.floor( pos.value / 60 );
nicePlaybackPos.value = minuteCount + ':';
if ( ( '' + minuteCount ).length === 1 ) {
nicePlaybackPos.value = '0' + minuteCount + ':';
}
const secondCount = Math.floor( pos.value - minuteCount * 60 );
if ( ( '' + secondCount ).length === 1 ) {
nicePlaybackPos.value += '0' + secondCount;
} else {
@@ -455,11 +584,15 @@
if ( isShowingRemainingTime.value ) {
const minuteCounts = Math.floor( ( playingSong.duration - pos.value ) / 60 );
niceDuration.value = '-' + String( minuteCounts ) + ':';
if ( ( '' + minuteCounts ).length === 1 ) {
niceDuration.value = '-0' + minuteCounts + ':';
}
const secondCounts = Math.floor( ( playingSong.duration - pos.value ) - minuteCounts * 60 );
if ( ( '' + secondCounts ).length === 1 ) {
niceDuration.value += '0' + secondCounts;
} else {
@@ -467,82 +600,101 @@
}
}
}, 100 );
}
};
const prepNiceDurationTime = ( playingSong: Song ) => {
duration.value = playingSong.duration;
const minuteCounts = Math.floor( ( playingSong.duration ) / 60 );
const minuteCounts = Math.floor( playingSong.duration / 60 );
niceDuration.value = String( minuteCounts ) + ':';
if ( ( '' + minuteCounts ).length === 1 ) {
niceDuration.value = '0' + minuteCounts + ':';
}
const secondCounts = Math.floor( ( playingSong.duration ) - minuteCounts * 60 );
const secondCounts = Math.floor( playingSong.duration - minuteCounts * 60 );
if ( ( '' + secondCounts ).length === 1 ) {
niceDuration.value += '0' + secondCounts;
} else {
niceDuration.value += secondCounts;
}
}
};
const stopProgressTracker = () => {
try {
clearInterval( progressTracker );
} catch ( _ ) { /* empty */ }
isPlaying.value = false;
notificationHandler.emit( 'playback-update', isPlaying.value );
}
};
const moveSong = ( move: SongMove ) => {
player.moveSong( move );
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}
};
const addNewSongs = async ( songs: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing new songs', 200, 'progress', 'normal' );
let n = notifications.value.createNotification(
'Analyzing new songs', 200, 'progress', 'normal'
);
playlist.value = player.getQueue();
for ( let element in songs ) {
try {
playlist.value.push( await fetchSongData( songs[ element ] ) );
} catch ( e ) {
console.error( e );
}
notifications.value.updateNotification( n, `Analyzing new songs (${element}/${songs.length})` );
notifications.value.updateNotification( n, `Analyzing new songs (${ element }/${ songs.length })` );
}
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
}
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'New songs added', 10, 'ok', 'normal' );
notifications.value.createNotification(
'New songs added', 10, 'ok', 'normal'
);
notificationHandler.emit( 'playlist-update', playlist.value );
}
};
const addNewSongFromObject = ( song: Song ) => {
playlist.value = player.getQueue();
playlist.value.push( song );
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
}
notificationHandler.emit( 'playlist-update', playlist.value );
}
};
const removeSongFromPlaylist = ( song: number ) => {
playlist.value = player.getQueue();
playlist.value.splice( song, 1 );
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
}
notificationHandler.emit( 'playlist-update', playlist.value );
}
};
const clearPlaylist = () => {
playlist.value = [];
@@ -555,18 +707,20 @@
coverArt.value = '';
pos.value = 0;
notificationHandler.emit( 'playlist-update', playlist.value );
}
};
const sendAdditionalInfo = () => {
notifications.value.createNotification( 'Additional song info transmitted', 5, 'ok', 'normal' );
notifications.value.createNotification(
'Additional song info transmitted', 5, 'ok', 'normal'
);
notificationHandler.emit( 'playlist-update', playlist.value );
}
};
emits( 'playerStateChange', isShowingFullScreenPlayer.value ? 'show' : 'hide' );
const userStore = useUserStore();
document.addEventListener( 'keydown', ( e ) => {
document.addEventListener( 'keydown', e => {
if ( !userStore.isUsingKeyboard ) {
if ( e.key === ' ' ) {
e.preventDefault();
@@ -583,7 +737,7 @@
const dismissNotification = () => {
isShowingWarning.value = false;
}
};
const popupReturnHandler = ( data: any ) => {
if ( currentlyOpenPopup === 'create-share' ) {
@@ -594,26 +748,35 @@
notificationHandler.emit( 'playback-update', isPlaying.value );
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
notificationHandler.emit( 'playlist-update', playlist.value );
notifications.value.createNotification( 'Joined share "' + data.roomName + '"!', 5, 'ok', 'normal' );
notifications.value.createNotification(
'Joined share "' + data.roomName + '"!', 5, 'ok', 'normal'
);
// eslint-disable-next-line @typescript-eslint/no-unused-vars
notificationHandler.registerListener( 'tampering-msg', ( _ ) => {
notificationHandler.registerListener( 'tampering-msg', _ => {
isShowingWarning.value = true;
} );
} ).catch( e => {
} )
.catch( e => {
if ( e === 'ERR_CONFLICT' ) {
notifications.value.createNotification( 'A share with this name exists already!', 5, 'error', 'normal' );
notifications.value.createNotification(
'A share with this name exists already!', 5, 'error', 'normal'
);
control( 'start-share' );
} else if ( e === 'ERR_UNAUTHORIZED' ) {
console.error( e );
localStorage.setItem( 'close-tab', 'true' );
logoutErrorNotification = notifications.value.createNotification( 'You appear to have been logged out. Click to log in again!', 20, 'error', 'normal', '/', true );
logoutErrorNotification = notifications.value.createNotification(
'You appear to have been logged out. Click to log in again!', 20, 'error', 'normal', '/', true
);
} else {
console.error( e );
notifications.value.createNotification( 'Could not create share!', 5, 'error', 'normal' );
notifications.value.createNotification(
'Could not create share!', 5, 'error', 'normal'
);
}
} );
}
}
};
window.addEventListener( 'beforeunload', async () => {
await notificationHandler.disconnect();

View File

@@ -1,6 +1,8 @@
<template>
<div class="playlists">
<h3 style="width: fit-content;">Your playlists</h3>
<h3 style="width: fit-content;">
Your playlists
</h3>
<div v-if="( $props.playlists ? $props.playlists.length < 1 : true ) && $props.isLoggedIn">
Loading...
<!-- TODO: Make prettier -->
@@ -8,12 +10,27 @@
<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 class="pl-loader-button" type="file" multiple="true" accept="audio/*" id="pl-loader"><br>
<button @click="loadPlaylistFromDisk()" class="pl-loader-button" id="load-button">Load</button>
<p v-if="!hasSelectedSongs">Please select at least one song to proceed!</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" v-bind:key="pl.id" class="playlist" @click="selectPlaylist( pl.id )">
<div
v-for="pl in $props.playlists"
:key="pl.id"
class="playlist"
@click="selectPlaylist( pl.id )"
>
{{ pl.attributes.name }}
</div>
</div>
@@ -21,8 +38,13 @@
</template>
<script setup lang="ts">
import type { ReadFile } from '@/scripts/song';
import { ref } from 'vue';
import type {
ReadFile
} from '@/scripts/song';
import {
ref
} from 'vue';
const hasSelectedSongs = ref( true );
defineProps( {
@@ -41,22 +63,31 @@
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 } );
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 emits = defineEmits( [
'selected-playlist',
'custom-playlist'
] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );
}
};
</script>
<style scoped>

View File

@@ -1,15 +1,26 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue';
import {
createApp
} from 'vue';
import {
createPinia
} from 'pinia';
import router from './router';
import sdk from '@janishutz/login-sdk-browser';
import App from './App.vue'
import router from './router'
const app = createApp(App)
const app = createApp( App );
app.use(createPinia())
app.use(router)
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')
app.mount( '#app' );

View File

@@ -50,6 +50,15 @@ const router = createRouter( {
'title': 'Fancy View'
}
},
{
path: '/tools/bar',
name: 'tool-bar',
component: () => import( '../views/BarView.vue' ),
meta: {
'authRequired': false,
'title': 'Bar utility'
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',

View File

@@ -1,9 +1,11 @@
import ColorThief from 'colorthief';
const colorThief = new ColorThief();
const getImageData = (): Promise<number[][]> => {
return new Promise( ( resolve ) => {
const img = ( document.getElementById( 'current-image' ) as HTMLImageElement );
return new Promise( resolve => {
const img = document.getElementById( 'current-image' ) as HTMLImageElement;
if ( img.complete ) {
resolve( colorThief.getPalette( img ) );
} else {
@@ -12,32 +14,39 @@ const getImageData = (): Promise<number[][]> => {
} );
}
} );
}
};
const createBackground = () => {
return new Promise( ( resolve ) => {
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 ] ) {
@@ -61,45 +70,56 @@ const createBackground = () => {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
resolve( outColours );
} );
} );
}
};
let callbackFun = () => {};
let callbackFun = () => {}
const subscribeToBeatUpdate = ( cb: () => void ) => {
callbackFun = cb;
micAudioHandler();
}
};
const unsubscribeFromBeatUpdate = () => {
callbackFun = () => {}
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 ) => {
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
@@ -115,25 +135,27 @@ const micAudioHandler = () => {
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,30 +1,33 @@
/*
* MusicPlayerV2 - notificationHandler.ts
*
* Created by Janis Hutz 06/26/2024, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
// These functions handle connections to the backend with socket.io
import { io, type Socket } from "socket.io-client";
import type { SSEMap } from "./song";
import {
io, type Socket
} 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,
'autoConnect': false,
} );
this.roomName = location.pathname.split( '/' )[ 2 ];
this.isConnected = false;
@@ -38,12 +41,17 @@ class SocketConnection {
* Create a room token and connect to
* @returns {Promise<string>}
*/
connect (): Promise<any> {
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: any } ) => {
this.socket.emit(
'join-room', this.roomName, ( res: {
'status': boolean,
'msg': string,
'data': unknown
} ) => {
if ( res.status === true ) {
this.isConnected = true;
resolve( res.data );
@@ -51,35 +59,46 @@ class SocketConnection {
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 => {
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
= 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!' );
}
console.log( '[ SSE Connection ] - '
+ new Date().toISOString() + ': Connection successfully established!' );
};
this.eventSource.onmessage = ( e ) => {
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.log( '[ SSE Connection ] - '
+ new Date().toISOString()
+ ': Reconnecting due to connection error!' );
// console.debug( e );
this.eventSource = undefined;
@@ -91,11 +110,16 @@ class SocketConnection {
}
};
} else {
console.log( '[ SSE Connection ] - ' + new Date().toISOString() + ': Could not connect due to error ' + res.status );
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.' );
} )
.catch( () => {
console.log( '[ SSE Connection ] - '
+ new Date().toISOString()
+ ': Could not connect due to error.' );
reject( 'ERR_ROOM_CONNECTING' );
} );
} else {
@@ -116,16 +140,23 @@ class SocketConnection {
* @param {any} data
* @returns {void}
*/
emit ( event: string, data: any ): void {
emit ( event: string, data: unknown ): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit( event, { 'roomName': this.roomName, 'data': data } );
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: {
'method': 'post',
'body': JSON.stringify( {
'event': event,
'roomName': this.roomName,
'data': data
} ),
'credentials': 'include',
'headers': {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
@@ -140,7 +171,7 @@ class SocketConnection {
* @param {( data: any ) => void} cb The callback function / listener function
* @returns {void}
*/
registerListener ( event: string, cb: ( data: any ) => void ): void {
registerListener ( event: string, cb: ( data: unknown ) => void ): void {
if ( this.useSocket ) {
if ( this.isConnected ) {
this.socket.on( event, cb );
@@ -171,9 +202,11 @@ class SocketConnection {
if ( this.eventSource ) {
return this.eventSource!.OPEN && this.isConnected;
}
return false;
}
}
}
export default SocketConnection;

View File

@@ -1,25 +1,39 @@
import type { SearchResult, Song, SongMove } from "./song";
import type {
SearchResult, Song, SongMove
} from './song';
interface Config {
devToken: string;
userToken: string;
'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 () {
@@ -27,8 +41,8 @@ class MusicKitJSWrapper {
this.playlist = [];
this.queue = [];
this.config = {
devToken: '',
userToken: '',
'devToken': '',
'userToken': '',
};
this.isShuffleEnabled = false;
this.repeatMode = 'off';
@@ -58,14 +72,16 @@ class MusicKitJSWrapper {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} ).catch( () => {
} )
.catch( () => {
this.hasEncounteredAuthError = true;
} );
} else {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} ).catch( () => {
} )
.catch( () => {
this.hasEncounteredAuthError = true;
} );
}
@@ -76,25 +92,29 @@ class MusicKitJSWrapper {
* @returns {void}
*/
init (): void {
fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', { credentials: 'include' } ).then( res => {
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'
'developerToken': token,
'app': {
'name': 'MusicPlayer',
'build': '3'
},
storefrontId: 'CH',
'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;
} );
} );
@@ -107,7 +127,10 @@ class MusicKitJSWrapper {
* @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 ];
return [
this.isLoggedIn,
this.hasEncounteredAuthError
];
}
/**
@@ -119,8 +142,8 @@ class MusicKitJSWrapper {
apiGetRequest ( url: string, callback: ( data: object ) => void ): void {
if ( this.config.devToken != '' && this.config.userToken != '' ) {
fetch( url, {
method: 'GET',
headers: {
'method': 'GET',
'headers': {
'Authorization': `Bearer ${ this.config.devToken }`,
'Music-User-Token': this.config.userToken
}
@@ -128,13 +151,19 @@ class MusicKitJSWrapper {
if ( res.status === 200 ) {
res.json().then( json => {
try {
callback( { 'status': 'ok', 'data': json } );
} catch( err ) { /* empty */}
callback( {
'status': 'ok',
'data': json
} );
} catch ( err ) { /* empty */ }
} );
} else {
try {
callback( { 'status': 'error', 'error': res.status } );
} catch( err ) { /* empty */}
callback( {
'status': 'error',
'error': res.status
} );
} catch ( err ) { /* empty */ }
}
} );
} else return;
@@ -152,31 +181,38 @@ class MusicKitJSWrapper {
setPlaylistByID ( id: string ): Promise<void> {
return new Promise( ( resolve, reject ) => {
this.musicKit.setQueue( { playlist: id } ).then( () => {
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
}
'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 => {
} )
.catch( err => {
console.error( err );
reject( err );
} );
@@ -192,18 +228,23 @@ class MusicKitJSWrapper {
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( () => {
this.musicKit.setQueue( {
'song': this.playlist[ this.playingSongID ].id
} ).then( () => {
setTimeout( () => {
this.control( 'play' );
}, 500 );
} ).catch( ( err ) => {
} )
.catch( err => {
console.log( err );
} );
} else {
@@ -213,6 +254,7 @@ class MusicKitJSWrapper {
this.control( 'play' );
}, 500 );
}
return true;
} else {
return false;
@@ -226,50 +268,63 @@ class MusicKitJSWrapper {
*/
control ( action: ControlAction ): boolean {
switch ( action ) {
case "play":
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":
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":
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":
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;
}
}
@@ -283,32 +338,42 @@ class MusicKitJSWrapper {
this.audioPlayer.currentTime = 0;
}
}
return false;
}
case "next":
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":
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;
}
}
@@ -317,15 +382,22 @@ class MusicKitJSWrapper {
setShuffle ( enabled: boolean ) {
this.isShuffleEnabled = enabled;
this.queue = [];
if ( enabled ) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const d = [];
for ( const el in this.playlist ) {
d.push( parseInt( el ) );
}
this.queue = d.map( value => ( { value, sort: Math.random() } ) )
this.queue = d.map( value => ( {
value,
'sort': Math.random()
} ) )
.sort( ( a, b ) => a.sort - b.sort )
.map( ( { value } ) => value );
.map( ( {
value
} ) => value );
this.queue.splice( this.queue.indexOf( this.playingSongID ), 1 );
this.queue.push( this.playingSongID );
this.queue.reverse();
@@ -334,6 +406,7 @@ class MusicKitJSWrapper {
this.queue.push( parseInt( song ) );
}
}
// Find current song ID in queue
for ( const el in this.queue ) {
if ( this.queue[ el ] === this.playingSongID ) {
@@ -359,29 +432,37 @@ class MusicKitJSWrapper {
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;
}
@@ -435,9 +516,11 @@ class MusicKitJSWrapper {
*/
getQueue (): Song[] {
const data = [];
for ( const el in this.queue ) {
data.push( this.playlist[ this.queue[ el ] ] );
}
return data;
}
@@ -449,6 +532,7 @@ class MusicKitJSWrapper {
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;
@@ -467,17 +551,21 @@ class MusicKitJSWrapper {
// TODO: Make storefront adjustable
return new Promise( ( resolve, reject ) => {
const queryParameters = {
term: ( searchTerm ),
types: [ 'songs' ],
'term': searchTerm,
'types': [ 'songs' ],
};
this.musicKit.api.music( `v1/catalog/ch/search`, queryParameters ).then( results => {
this.musicKit.api.music( 'v1/catalog/ch/search', queryParameters )
.then( results => {
resolve( results );
} ).catch( e => {
} )
.catch( e => {
console.error( e );
reject( e );
} );
} );
}
}
export default MusicKitJSWrapper;

View File

@@ -1,34 +1,41 @@
/*
* MusicPlayerV2 - notificationHandler.ts
*
* Created by Janis Hutz 06/26/2024, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
// These functions handle connections to the backend with socket.io
import { io, type Socket } from "socket.io-client"
import type { SSEMap } from "./song";
import {
io, type Socket
} 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,
'autoConnect': false,
} );
this.roomName = '';
this.roomToken = '';
@@ -50,28 +57,37 @@ class NotificationHandler {
*/
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 => {
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 } ) => {
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( );
} )
.catch( );
}
} );
} else if ( res.status === 409 ) {
@@ -90,9 +106,13 @@ class NotificationHandler {
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 => {
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 = new EventSource( localStorage.getItem( 'url' ) + '/socket/connection?room=' + this.roomName, {
'withCredentials': true
} );
this.eventSource.onopen = () => {
this.isConnected = true;
@@ -100,16 +120,17 @@ class NotificationHandler {
this.reconnectRetryCount = 0;
console.log( '[ SSE Connection ] - ' + new Date().toISOString() + ': Connection successfully established!' );
resolve();
}
};
this.eventSource.onmessage = ( e ) => {
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 ) => {
this.eventSource.onerror = e => {
if ( this.isConnected ) {
this.isConnected = false;
this.eventSource?.close();
@@ -125,13 +146,14 @@ class NotificationHandler {
}, 1000 * this.reconnectRetryCount );
}
};
} else if ( res.status === 403 || res.status === 401 || res.status === 404 ) {
} 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( () => {
} )
.catch( () => {
if ( !this.connectionWasSuccessful ) {
reject( 'ERR_ROOM_CONNECTING' );
} else {
@@ -169,9 +191,14 @@ class NotificationHandler {
emit ( event: string, data: any ): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit( event, { 'roomToken': this.roomToken, 'roomName': this.roomName, 'data': data } );
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 );
@@ -189,10 +216,15 @@ class NotificationHandler {
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: {
'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'
}
@@ -222,22 +254,32 @@ class NotificationHandler {
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.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: {
'method': 'post',
'body': JSON.stringify( {
'roomName': this.roomName,
'roomToken': this.roomToken
} ),
'credentials': 'include',
'headers': {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
@@ -247,8 +289,10 @@ class NotificationHandler {
} else {
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
}
return;
} ).catch( () => {
} )
.catch( () => {
return;
} );
}
@@ -258,6 +302,7 @@ class NotificationHandler {
getRoomName (): string {
return this.roomName;
}
}
export default NotificationHandler;

View File

@@ -4,90 +4,90 @@ export interface Song {
/**
* The ID. Either the apple music ID, or if from local disk, an ID starting in local_
*/
id: string;
'id': string;
/**
* Origin of the song
*/
origin: Origin;
'origin': Origin;
/**
* The cover image as a URL
*/
cover: string;
'cover': string;
/**
* The artist of the song
*/
artist: string;
'artist': string;
/**
* The name of the song
*/
title: string;
'title': string;
/**
* Duration of the song in milliseconds
*/
duration: number;
'duration': number;
/**
* (OPTIONAL) The genres this song belongs to. Can be displayed on the showcase screen, but requires settings there
*/
genres?: string[];
'genres'?: string[];
/**
* (OPTIONAL) This will be displayed in brackets on the showcase screens
*/
additionalInfo?: string;
'additionalInfo'?: string;
}
export interface SongTransmitted {
title: string;
artist: string;
duration: number;
cover: string;
additionalInfo?: string;
'title': string;
'artist': string;
'duration': number;
'cover': string;
'additionalInfo'?: string;
}
export interface ReadFile {
url: string;
filename: string;
'url': string;
'filename': string;
}
export interface SearchResult {
data: {
results: {
songs: {
data: AppleMusicSongData[],
href: string;
'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
'id': string,
'type': string;
'href': string;
'attributes': {
'albumName': string;
'artistName': string;
'artwork': {
'width': number,
'height': number,
'url': string
},
name: string;
genreNames: string[];
durationInMillis: number;
'name': string;
'genreNames': string[];
'durationInMillis': number;
}
}
export interface SongMove {
songID: string;
newPos: number;
'songID': string;
'newPos': number;
}
export interface SSEMap {

View File

@@ -1,32 +1,27 @@
/*
* LanguageSchoolHossegorBookingSystem - userStore.js
*
* Created by Janis Hutz 10/27/2023, Licensed under a proprietary License
* https://janishutz.com, development@janishutz.com
*
*
*/
import { defineStore } from 'pinia';
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': true, 'hasSubscribed': false, 'isUsingKeyboard': false, 'username': '', 'isFOSSVersion': false } ),
getters: {
getUserAuthenticated: ( state ) => state.isUserAuth,
getSubscriptionStatus: ( state ) => state.hasSubscribed,
'state': () => ( {
'isUserAuth': false,
'hasSubscribed': false,
'isUsingKeyboard': false,
'isFOSSVersion': false
} ),
'getters': {
'getUserAuthenticated': state => state.isUserAuth,
'getSubscriptionStatus': state => state.hasSubscribed,
},
actions: {
'actions': {
setUserAuth ( auth: boolean ) {
this.isUserAuth = auth;
},
setSubscriptionStatus ( status: boolean ) {
this.hasSubscribed = status;
},
setUsername ( username: string ) {
this.username = username;
},
setKeyboardUsageStatus ( status: boolean ) {
this.isUsingKeyboard = status;
}

View File

@@ -1,20 +1,40 @@
<template>
<div class="app-view">
<button id="logout" @click="logout()"><span class="material-symbols-outlined">logout</span></button>
<div class="loading-view" v-if="!hasFinishedLoading">
<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 class="home-view" v-else-if="hasFinishedLoading && isReady">
<libraryView class="library-view" :playlists="playlists" @selected-playlist="( id ) => { selectPlaylist( id ) }"
:is-logged-in="isLoggedIntoAppleMusic" @custom-playlist="( pl ) => selectCustomPlaylist( pl )"></libraryView>
<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>
<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 :class="'player-view' + ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"
ref="player"></playerView>
<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>
@@ -22,10 +42,16 @@
<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 {
ref
} from 'vue';
import type {
ReadFile
} from '@/scripts/song';
import router from '@/router';
import { useUserStore } from '@/stores/userStore';
import {
useUserStore
} from '@/stores/userStore';
const isLoggedIntoAppleMusic = ref( false );
const isReady = ref( false );
@@ -41,7 +67,7 @@
} else {
isShowingFullScreenPlayer.value = true;
}
}
};
let loginChecker = 0;
@@ -51,7 +77,7 @@
if ( player.value.getAuth()[ 0 ] ) {
isLoggedIntoAppleMusic.value = true;
isReady.value = true;
player.value.getPlaylists( ( data ) => {
player.value.getPlaylists( data => {
playlists.value = data.data.data;
} );
clearInterval( loginChecker );
@@ -60,25 +86,27 @@
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 => {
fetch( localStorage.getItem( 'url' ) + '/checkUserStatus', {
'credentials': 'include'
} ).then( res => {
if ( res.status === 200 ) {
res.text().then( text => {
if ( text === 'ok' ) {
@@ -90,7 +118,7 @@
router.push( '/get' );
}
} );
} else if ( res.status === 404 ) {
} else if ( res.status === 402 ) {
userStore.setSubscriptionStatus( false );
router.push( '/get' );
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
@@ -102,7 +130,7 @@
const logout = () => {
// location.href = 'http://localhost:8080/logout?return=' + location.href;
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
}
};
</script>
<style scoped>

View File

@@ -0,0 +1,163 @@
<script setup lang="ts">
import {
type Ref,
computed, ref
} from 'vue';
interface FullConfig {
'offering': BarConfig,
'ages': Ages
}
interface Ages {
'18+': string,
'16-18': string
}
interface BarConfig {
[id: string]: Offer;
}
interface Offer {
'name': string;
'price': number; // In cents
'id': string;
}
interface Selection {
[id: string]: number;
}
const ages: Ref<Ages> = ref( {
'18+': '',
'16-18': '',
'below': ''
} );
const offering: Ref<BarConfig> = ref( {} );
const selection: Ref<Selection> = ref( {} );
fetch( '/bar-config.json' ).then( res => {
if ( res.status === 200 ) {
res.json().then( json => {
const data: FullConfig = json;
offering.value = data.offering;
ages.value = data.ages;
reset();
} );
} else {
alert( 'Failed to load' );
}
} );
const reset = () => {
const keys = Object.keys( offering.value );
keys.forEach( val => {
selection.value[ val ] = 0;
} );
};
const total = computed( () => {
const keys = Object.keys( selection.value );
let totalPrice = 0;
for ( let i = 0; i < keys.length; i++ ) {
const o = selection.value[ keys[ i ] ];
totalPrice += o * offering.value[ keys[ i ] ].price;
}
return totalPrice / 100;
} );
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">
<h1>Bar utility</h1>
<p>Check ages! (18+: {{ ages[ '18+' ] }}, 16-18: {{ ages[ '16-18' ] }})</p>
<button @click="reset()">
Reset
</button>
<p>Total: CHF {{ total }}</p>
<table class="offering-wrapper">
<tbody>
<tr v-for="offer in offering" :key="offer.id" class="offering">
<td>
<p>{{ offer.name }} (CHF {{ offer.price / 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>
</div>
</template>
<style lang="scss" scoped>
.bar-utility {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
>.offering-wrapper {
.offering {
>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

@@ -3,13 +3,35 @@
<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>
<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 href="/" class="fancy-button" style="margin-left: 10px;" v-if="!reasonForRedirectHere">Log in</a>
<button href="/" class="fancy-button" style="margin-left: 10px;" v-if="reasonForRedirectHere" @click="logout()">Log out</button>
<a href="https://github.com/simplePCBuilding/MusicPlayerV2" class="fancy-button" style="margin-left: 10px;" target="_blank">GitHub</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>
@@ -20,7 +42,10 @@
<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>
<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>
@@ -29,7 +54,9 @@
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue';
import {
ref, type Ref
} from 'vue';
interface Reasons {
[key: string]: string;
@@ -39,12 +66,13 @@
'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>

View File

@@ -1,54 +1,48 @@
<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' : '' )" @click="login()"
style="margin-top: 5vh;" title="Sign in or sign up with janishutz.com ID" v-if="status"
>{{ isTryingToSignIn ? 'Signing you in...' : 'Login / Sign up' }}</button>
<p v-else>We are sorry, but we were unable to initialize the login services. Please reload the page if you wish to retry!</p>
<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"></notificationsModule>
<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 router from '@/router';
import { RouterLink } from 'vue-router';
import { useUserStore } from '@/stores/userStore';
import {
RouterLink
} from 'vue-router';
import notificationsModule from '@/components/notificationsModule.vue';
import { ref } from '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 );
interface JanishutzIDSDK {
setLoginSDKURL: ( url: string ) => undefined;
createSession: () => undefined;
verifySession: () => Promise<JHIDSessionStatus>
}
interface JHIDSessionStatus {
status: boolean;
username: string;
}
let sdk: JanishutzIDSDK;
const status = ref( true );
if ( typeof( JanishutzID ) !== 'undefined' ) {
sdk = JanishutzID();
sdk.setLoginSDKURL( localStorage.getItem( 'url' ) ?? '' );
} else {
setTimeout( () => {
notifications.value.createNotification( 'Unable to initialize account services!', 5, 'error' );
}, 1000 );
status.value = false;
}
const login = () => {
sdk.createSession();
}
sdk.login();
};
const store = useUserStore();
@@ -56,24 +50,28 @@
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
localStorage.removeItem( 'redirect' );
} else {
if ( typeof( sdk ) !== 'undefined' ) {
sdk.verifySession().then( res => {
if ( res.status ) {
sdk.verifyFull()
.then( res => {
if ( res ) {
store.isUserAuth = true;
store.username = res.username;
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>

View File

@@ -1,20 +1,35 @@
<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="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 ]" :src="playlist[ playingSong ].cover" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<img
v-if="playlist[ playingSong ]"
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>
<h1 style="margin-bottom: 5px;">
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
</h1>
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
<p class="additional-info" v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false">{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}</p>
<progress max="1000" id="progress" :value="progressBar"></progress>
<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" v-bind:key="song.id" class="song-list">
<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>
@@ -32,7 +47,9 @@
<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>
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>
@@ -41,8 +58,12 @@
<script setup lang="ts">
import SocketConnection from '@/scripts/connection';
import type { Song } from '@/scripts/song';
import { computed, ref, type Ref } from 'vue';
import type {
Song
} from '@/scripts/song';
import {
computed, ref, type Ref
} from 'vue';
const isPlaying = ref( false );
const playlist: Ref<Song[]> = ref( [] );
@@ -52,6 +73,7 @@
const hasLoaded = ref( false );
const showCouldNotFindRoom = ref( false );
const playbackStart = ref( 0 );
let timeTracker = 0;
const conn = new SocketConnection();
@@ -61,18 +83,22 @@
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;
progressBar.value = ( pos.value / ( playlist.value[ playingSong.value ]
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
hasLoaded.value = true;
conn.registerListener( 'playlist', ( data ) => {
conn.registerListener( 'playlist', data => {
playlist.value = data;
} );
conn.registerListener( 'playback', ( data ) => {
conn.registerListener( 'playback', data => {
isPlaying.value = data;
if ( isPlaying.value ) {
startTimeTracker();
} else {
@@ -80,22 +106,24 @@
}
} );
conn.registerListener( 'playback-start', ( data ) => {
conn.registerListener( 'playback-start', data => {
playbackStart.value = data;
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
} );
conn.registerListener( 'playlist-index', ( data ) => {
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.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 => {
} )
.catch( e => {
console.error( e );
showCouldNotFindRoom.value = true;
} );
@@ -103,49 +131,56 @@
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;
} );
// TODO: Handle disconnect from updater (=> have it disconnect)
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';
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';
}
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;
}
@@ -156,11 +191,11 @@
location.reload();
}
}, 100 );
}
};
const stopTimeTracker = () => {
clearInterval( timeTracker );
}
};
document.addEventListener( 'visibilitychange', () => {
if ( !document.hidden ) {

View File

@@ -1,34 +1,66 @@
<template>
<div>
<span class="anti-tamper material-symbols-outlined" v-if="isAntiTamperEnabled" @click="secureModeInfo( 'toggle' )">lock</span>
<div class="anti-tamper-info" v-if="isShowingSecureModeInfo && isAntiTamperEnabled" @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>
<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 ]" :src="playlist[ playingSong ].cover" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<img
v-if="playlist[ playingSong ]"
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>
<h1 style="margin-bottom: 5px;">
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
</h1>
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
<p class="additional-info" v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false">{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}</p>
<progress max="1000" id="progress" :value="progressBar"></progress>
<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>
<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" v-bind:key="song.id" class="song-list">
<div v-for="song in songQueue" :key="song.id" class="song-list">
<img :src="song.cover" class="song-image">
<div v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying" class="playing-symbols">
<div
v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying"
class="playing-symbols"
>
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
<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">
@@ -47,10 +79,12 @@
</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>
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 class="background" id="background">
<div id="background" class="background">
<div class="beat-manual"></div>
</div>
</div>
@@ -59,8 +93,12 @@
<script setup lang="ts">
import SocketConnection from '@/scripts/connection';
import type { Song } from '@/scripts/song';
import { computed, ref, type Ref } from 'vue';
import type {
Song
} from '@/scripts/song';
import {
computed, ref, type Ref
} from 'vue';
import bizualizer from '@/scripts/bizualizer';
const isPlaying = ref( false );
@@ -71,10 +109,11 @@
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 => {
@@ -82,22 +121,29 @@
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;
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 ) => {
conn.registerListener( 'playlist', data => {
playlist.value = data;
} );
conn.registerListener( 'playback', ( data ) => {
conn.registerListener( 'playback', data => {
isPlaying.value = data;
if ( isPlaying.value ) {
startTimeTracker();
} else {
@@ -105,12 +151,12 @@
}
} );
conn.registerListener( 'playback-start', ( data ) => {
conn.registerListener( 'playback-start', data => {
playbackStart.value = data;
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
} );
conn.registerListener( 'playlist-index', ( data ) => {
conn.registerListener( 'playlist-index', data => {
playingSong.value = parseInt( data );
setTimeout( () => {
setBackground();
@@ -118,12 +164,13 @@
} );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
conn.registerListener( 'delete-share', ( _ ) => {
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 => {
} )
.catch( e => {
console.error( e );
showCouldNotFindRoom.value = true;
} );
@@ -131,45 +178,50 @@
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;
} );
// TODO: Handle disconnect from updater (=> have it disconnect)
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';
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';
}
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( () => {
@@ -179,21 +231,23 @@
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 );
@@ -202,30 +256,31 @@
$( '.beat-manual' ).stop();
}, duration );
}, 50 );
}
};
const handleAnimationChange = () => {
if ( visualizationSettings.value === 'mic' && isPlaying.value ) {
bizualizer.subscribeToBeatUpdate( animateBeat );
} else {
bizualizer.unsubscribeFromBeatUpdate()
}
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 = () => {
@@ -233,18 +288,19 @@
sendNotification();
}
};
}
};
const sendNotification = () => {
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
body: 'Please return to the original webpage immediately!',
requireInteraction: true,
'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;
@@ -253,7 +309,7 @@
} else {
isShowingSecureModeInfo.value = false;
}
}
};
</script>
<style scoped>

View File

@@ -1,416 +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="content" id="app">
<div v-if="hasLoaded" style="width: 100%">
<div class="current-song-wrapper">
<span class="material-symbols-outlined fancy-view-song-art" v-if="!playingSong.hasCoverArt">music_note</span>
<img v-else-if="playingSong.hasCoverArt && playingSong.coverArtOrigin === 'api'" :src="playingSong.coverArtURL" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<img v-else :src="'/getSongCover?filename=' + playingSong.filename" class="fancy-view-song-art" id="current-image">
<div class="current-song">
<progress max="1000" id="progress" :value="progressBar"></progress>
<h1>{{ playingSong.title }}</h1>
<p class="dancing-style" v-if="playingSong.dancingStyle">{{ playingSong.dancingStyle }}</p>
<p>{{ playingSong.artist }}</p>
</div>
</div>
<div class="mode-selector-wrapper">
<select v-model="visualizationSettings" @change="setVisualization()">
<option value="mic">Microphone (Mic access required)</option>
<option value="bpm">BPM (might not be 100% accurate)</option>
<option value="off">No visualization except background</option>
</select>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" class="song-list">
<span class="material-symbols-outlined song-image" v-if="!song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying )">music_note</span>
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying ) && song.coverArtOrigin === 'api'" :src="song.coverArtURL" class="song-image">
<img v-else-if="song.hasCoverArt && ( playingSong.filename !== song.filename || isPlaying ) && song.coverArtOrigin !== 'api'" :src="'/getSongCover?filename=' + song.filename" class="song-image">
<div v-if="playingSong.filename === song.filename && isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
</div>
</div>
<span class="material-symbols-outlined pause-icon" v-if="!isPlaying && playingSong.filename === song.filename">pause</span>
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
<div class="time-until">
{{ getTimeUntil( song ) }}
</div>
</div>
<!-- <img :src="" alt=""> -->
</div>
</div>
<div v-else>
<h1>Loading...</h1>
</div>
<div class="background" id="background">
<div class="beat"></div>
<div class="beat-manual"></div>
</div>
</div>
<!-- TODO: Get ColorThief either from CDN or preferably as NPM module -->
<!-- <script src="https://cdnjs.cloudflare.com/ajax/libs/color-thief/2.3.0/color-thief.umd.js"></script> -->
</div>
</template>
<script setup lang="ts">
import type { Song } from '@/scripts/song';
import { computed, ref, type Ref } from 'vue';
import { ColorThief } from 'colorthief';
const hasLoaded = ref( false );
const songs: Ref<Song[]> = ref( [] );
const playingSong = ref( 0 );
const isPlaying = ref( false );
const pos = ref( 0 );
const colourPalette: string[] = [];
const progressBar = ref( 0 );
const timeTracker = ref( 0 );
const visualizationSettings = ref( 'mic' );
const micAnalyzer = ref( 0 );
const beatDetected = ref( false );
const colorThief = new ColorThief();
const songQueue = computed( () => {
let ret = [];
let pos = 0;
for ( let song in songs.value ) {
if ( pos >= playingSong.value ) {
ret.push( songs.value[ song ] );
}
pos += 1;
}
return ret;
} );
const getTimeUntil = computed( () => {
return ( song ) => {
let timeRemaining = 0;
for ( let i = this.queuePos; i < Object.keys( this.songs ).length - 1; i++ ) {
if ( this.songs[ i ] == song ) {
break;
}
timeRemaining += parseInt( this.songs[ i ].duration );
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - this.pos / 60 ) + 'min after starting to play';
}
}
}
} );
methods: {
startTimeTracker () {
this.timeTracker = setInterval( () => {
this.pos = ( new Date().getTime() - this.playingSong.startTime ) / 1000 + this.oldPos;
this.progressBar = ( this.pos / this.playingSong.duration ) * 1000;
if ( isNaN( this.progressBar ) ) {
this.progressBar = 0;
}
}, 100 );
},
stopTimeTracker () {
clearInterval( this.timeTracker );
this.oldPos = this.pos;
},
getImageData() {
return new Promise( ( resolve, reject ) => {
if ( this.playingSong.hasCoverArt ) {
setTimeout( () => {
const img = document.getElementById( 'current-image' );
if ( img.complete ) {
resolve( this.colorThief.getPalette( img ) );
} else {
img.addEventListener( 'load', () => {
resolve( this.colorThief.getPalette( img ) );
} );
}
}, 500 );
} else {
reject( 'no image' );
}
} );
},
connect() {
this.colorThief = new ColorThief();
let source = new EventSource( '/clientDisplayNotifier', { withCredentials: true } );
source.onmessage = ( e ) => {
let data;
try {
data = JSON.parse( e.data );
} catch ( err ) {
data = { 'type': e.data };
}
if ( data.type === 'basics' ) {
this.isPlaying = data.data.isPlaying ?? false;
this.playingSong = data.data.playingSong ?? {};
this.songs = data.data.songQueue ?? [];
this.pos = data.data.pos ?? 0;
this.oldPos = data.data.pos ?? 0;
this.progressBar = this.pos / this.playingSong.duration * 1000;
this.queuePos = data.data.queuePos ?? 0;
this.getImageData().then( palette => {
this.colourPalette = palette;
this.handleBackground();
} ).catch( () => {
this.colourPalette = [ { 'r': 255, 'g': 0, 'b': 0 }, { 'r': 0, 'g': 255, 'b': 0 }, { 'r': 0, 'g': 0, 'b': 255 } ];
this.handleBackground();
} );
} else if ( data.type === 'pos' ) {
this.pos = data.data;
this.oldPos = data.data;
this.progressBar = data.data / this.playingSong.duration * 1000;
} else if ( data.type === 'isPlaying' ) {
this.isPlaying = data.data;
this.handleBackground();
} else if ( data.type === 'songQueue' ) {
this.songs = data.data;
} else if ( data.type === 'playingSong' ) {
this.playingSong = data.data;
this.getImageData().then( palette => {
this.colourPalette = palette;
this.handleBackground();
} ).catch( () => {
this.colourPalette = [ [ 255, 0, 0 ], [ 0, 255, 0 ], [ 0, 0, 255 ] ];
this.handleBackground();
} );
} else if ( data.type === 'queuePos' ) {
this.queuePos = data.data;
}
};
source.onopen = () => {
this.isReconnecting = false;
this.hasLoaded = true;
};
let self = this;
source.addEventListener( 'error', function( e ) {
if ( e.eventPhase == EventSource.CLOSED ) source.close();
if ( e.target.readyState == EventSource.CLOSED ) {
console.log( 'disconnected' );
}
// TODO: Notify about disconnect
setTimeout( () => {
if ( !self.isReconnecting ) {
self.isReconnecting = true;
self.tryReconnect();
}
}, 1000 );
}, false );
},
tryReconnect() {
const int = setInterval( () => {
if ( !this.isReconnecting ) {
clearInterval( int );
} else {
connectToSSESource();
}
}, 1000 );
},
handleBackground() {
let colourDetails = [];
let colours = [];
let differentEnough = true;
if ( this.colourPalette[ 0 ] ) {
for ( let i in this.colourPalette ) {
for ( let colour in colourDetails ) {
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - this.colourPalette[ i ][ 0 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 1 ] - this.colourPalette[ i ][ 1 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 2 ] - this.colourPalette[ i ][ 2 ] ) / 255 ) / 3 * 100;
if ( colourDiff > 15 ) {
differentEnough = true;
}
}
if ( differentEnough ) {
colourDetails.push( this.colourPalette[ i ] );
colours.push( 'rgb(' + this.colourPalette[ i ][ 0 ] + ',' + this.colourPalette[ i ][ 1 ] + ',' + this.colourPalette[ 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 ( let i in colours ) {
outColours += colours[ i ] + ',';
}
} else {
for ( let i = 0; i < 10; i++ ) {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
$( '#background' ).css( 'background', outColours );
this.setVisualization();
},
setVisualization () {
if ( Object.keys( this.playingSong ).length > 0 ) {
if ( this.visualizationSettings === 'bpm' ) {
if ( this.playingSong.bpm && this.isPlaying ) {
$( '.beat' ).show();
$( '.beat' ).css( 'animation-duration', 60 / this.playingSong.bpm );
$( '.beat' ).css( 'animation-delay', this.pos % ( 60 / this.playingSong.bpm * this.pos ) + this.playingSong.bpmOffset - ( 60 / this.playingSong.bpm * this.pos / 2 ) );
} else {
$( '.beat' ).hide();
}
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
} else if ( this.visualizationSettings === 'off' ) {
$( '.beat' ).hide();
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
} else if ( this.visualizationSettings === 'mic' ) {
$( '.beat-manual' ).hide();
try {
clearInterval( this.micAnalyzer );
} catch ( err ) {}
this.micAudioHandler();
}
} else {
console.log( 'not playing yet' );
}
},
micAudioHandler () {
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array( bufferLength );
navigator.mediaDevices.getUserMedia( { audio: true } ).then( ( stream ) => {
const mic = audioContext.createMediaStreamSource( stream );
mic.connect( analyser );
analyser.getByteFrequencyData( dataArray );
let prevSpectrum = null;
let threshold = 10; // Adjust as needed
this.beatDetected = false;
this.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 = this.calculateSpectralFlux( prevSpectrum, currentSpectrum );
if ( flux > threshold && !this.beatDetected ) {
// Beat detected
this.beatDetected = true;
this.animateBeat();
}
}
prevSpectrum = currentSpectrum;
}, 20 );
} );
},
animateBeat () {
$( '.beat-manual' ).stop();
const duration = Math.ceil( 60 / ( this.playingSong.bpm ?? 180 ) * 500 ) - 50;
$( '.beat-manual' ).fadeIn( 50 );
setTimeout( () => {
$( '.beat-manual' ).fadeOut( duration );
setTimeout( () => {
$( '.beat-manual' ).stop();
this.beatDetected = false;
}, duration );
}, 50 );
},
calculateSpectralFlux( prevSpectrum, currentSpectrum ) {
let flux = 0;
for ( let i = 0; i < prevSpectrum.length; i++ ) {
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
flux += Math.max( 0, diff );
}
return flux;
},
notifier() {
if ( parseInt( this.lastDispatch ) + 5000 < new Date().getTime() ) {
}
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 = () => {
this.sendNotification( 'blur' );
}
// Detect if browser window becomes hidden (also with blur event)
document.onvisibilitychange = () => {
if ( document.visibilityState === 'hidden' ) {
this.sendNotification( 'visibility' );
}
};
},
sendNotification( notification ) {
let fetchOptions = {
method: 'post',
body: JSON.stringify( { 'type': notification } ),
headers: {
'Content-Type': 'application/json',
'charset': 'utf-8'
},
};
fetch( '/clientStatusUpdate', fetchOptions ).catch( err => {
console.error( err );
} );
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
body: 'Please return to the original webpage immediately!',
requireInteraction: true,
} )
}
},
mounted() {
this.connect();
this.notifier();
// if ( this.visualizationSettings === 'mic' ) {
// this.micAudioHandler();
// }
},
watch: {
isPlaying( value ) {
if ( value ) {
this.startTimeTracker();
} else {
this.stopTimeTracker();
}
}
}
} ).mount( '#app' );
</script>

View File

@@ -3,7 +3,6 @@
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
@@ -14,6 +13,6 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
"types": ["node", "jquery"]
}
}

View File

@@ -23,7 +23,7 @@
A music player, specifically created for displaying song information on multiple different displays that are connected to the same network, just from the browser.
The [hosted version](https://music.janishutz.com) of this MusicPlayer, which is fully set up for you will be subscription-based and can be paid for on my [store](https://store.janishutz.com/product/com.janishutz.MusicPlayer). Not available yet though!
The [hosted version](https://music.janishutz.com) of this MusicPlayer, which is fully set up for you is subscription-based and can be paid for on my [store](https://store.janishutz.com/product/com.janishutz).
<div id="donate" align="center">
<a href="https://store.janishutz.com/donate" target="_blank"><img src="https://store-cdn.janishutz.com/static/support-me.jpg" width="150px"></a>

702
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,702 @@
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': {
// 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',
'multiline'
],
'@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': 90,
'comments': 100,
'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,
}
],
},
},
);

1503
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -17,10 +17,17 @@
},
"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",
"typescript": "^5.4.5"
"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",
@@ -31,8 +38,6 @@
"express-session": "^1.18.0",
"jsonwebtoken": "^9.0.2",
"node-mysql": "^0.4.2",
"oauth-janishutz-client-server": "file:../../oauth/client/server/dist",
"socket.io": "^4.7.5",
"store.janishutz.com-sdk": "file:../../store/sdk/dist"
"socket.io": "^4.7.5"
}
}

View File

@@ -1,50 +0,0 @@
import db from './storage/db';
const createUser = ( uid: string, username: string, email: string ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
db.writeDataSimple( 'users', 'uid', uid, { 'uid': uid, 'username': username, 'email': email } ).then( () => {
resolve( true );
} ).catch( err => {
reject( err );
} );
} );
}
const saveUserData = ( uid: string, data: object ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
db.writeDataSimple( 'users', 'uid', uid, { 'data': data } ).then( () => {
resolve( true );
} ).catch( err => {
reject( err );
} );
} );
}
const checkUser = ( uid: string ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
db.checkDataAvailability( 'users', 'uid', uid ).then( res => {
resolve( res );
} ).catch( err => {
reject( err );
} )
} );
}
const getUserData = ( uid: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
db.getDataSimple( 'users', 'uid', uid ).then( data => {
resolve( data );
} ).catch( err => {
reject( err );
} );
} );
}
export default {
createUser,
saveUserData,
checkUser,
getUserData
}

View File

@@ -3,448 +3,277 @@ import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import cors from 'cors';
import account from './account';
import sdk from 'oauth-janishutz-client-server';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import {
createServer
} from 'node:http';
import crypto from 'node:crypto';
import type { Room, Song } from './definitions';
import storeSDK from 'store.janishutz.com-sdk';
import bodyParser from 'body-parser';
import {
SocketData
} from './definitions';
const isFossVersion = true;
declare let __dirname: string | undefined
if ( typeof( __dirname ) === 'undefined' ) {
__dirname = path.resolve( path.dirname( '' ) );
}
// ┌ ┐
// │ 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';
// 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' );
}
};
// TODO: Change config file, as well as in main.ts, index.html, oauth, if deploying there
// const sdkConfig = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/sdk.config.testing.json' ) ) );
const sdkConfig = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/sdk.config.secret.json' ) ) );
const run = () => {
let app = express();
app.use( cors( {
credentials: true,
origin: true
} ) );
if ( !isFossVersion ) {
// storeSDK.configure( JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/store-sdk.config.testing.json' ) ) ) );
storeSDK.configure( JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/store-sdk.config.secret.json' ) ) ) );
}
const app = express();
const httpServer = createServer( app );
if ( !isFossVersion ) {
console.error( '[ APP ] Starting in non-FOSS version' );
const storeConfig = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/config/store-sdk.config.secret.json'
) ).toString() );
console.error( storeConfig );
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.routes( app, ( uid: string ) => {
return new Promise( ( resolve, reject ) => {
account.checkUser( uid ).then( stat => {
resolve( stat );
} ).catch( e => {
reject( e );
} );
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 );
} );
},
( uid: string, email: string, username: string ) => {
return new Promise( ( resolve, reject ) => {
account.createUser( uid, username, email ).then( stat => {
resolve( stat );
} ).catch( e => {
reject( e );
() => {
return new Promise( resolve => {
resolve( true );
} );
} );
}, sdkConfig );
}
// Websocket for events
interface SocketData {
[key: string]: Room;
}
const socketData: SocketData = {};
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,
() => {
return new Promise( resolve => {
resolve( true );
} );
socket.disconnect();
}
},
() => {
return new Promise( resolve => {
resolve( true );
} );
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 );
}
}
} );
} );
/*
ROUTES FOR SERVER SENT EVENTS VERSION
Configuration of SSE or WebSocket
*/
// 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', ( 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 = sdk.getSessionID( request );
if ( sdk.checkAuth( 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;
} catch ( e ) { /* empty */ }
const cl = clientReference[ String( request.query.room ) ];
for ( let c in cl ) {
if ( cl[ c ] === sid ) {
cl.splice( parseInt( c ), 1 );
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', ( 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', ( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
if ( socketData[ String( request.query.room ) ] ) {
response.send( 'ok' );
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
} );
app.post( '/socket/update', bodyParser.json(), ( request: express.Request, response: express.Response ) => {
if ( socketData[ request.body.roomName ] ) {
if ( request.body.event === 'tampering' ) {
const clients = clientReference[ request.body.roomName ];
for ( let 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;
}
if ( send ) {
const clients = clientReference[ request.body.roomName ];
for ( let 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.post( '/socket/deleteRoom', 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 ) {
socketData[ request.body.roomName ] = undefined;
const clients = clientReference[ request.body.roomName ];
for ( let client in clients ) {
if ( connectedClients[ clients[ client ] ] ) {
connectedClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'type': 'delete-share', 'data': true } ) + '\n\n' );
}
}
} 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' );
}
} );
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 ) => {
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', ( request: express.Request, response: express.Response ) => {
if ( sdk.checkAuth( request ) ) {
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.getUserData( request ).uid,
useAntiTamper: request.query.useAntiTamper === 'true' ? true : false,
'playbackStart': 0,
'playbackStatus': false,
'playlist': [],
'playlistIndex': 0,
'roomName': roomName,
'roomToken': roomToken,
'ownerUID': sdk.getUID( request ),
'useAntiTamper': request.query.useAntiTamper === 'true'
? true : false,
};
response.send( roomToken );
} else {
if ( socketData[ roomName ].ownerUID === sdk.getUserData( request ).uid ) {
if (
socketData[ roomName ].ownerUID
=== sdk.getUID( request )
) {
response.send( socketData[ roomName ].roomToken );
} else {
response.status( 409 ).send( 'ERR_CONFLICT' );
}
}
} else {
response.status( 403 ).send( 'ERR_FORBIDDEN' );
}
} );
);
app.get( '/getAppleMusicDevToken', ( req, res ) => {
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();
// TODO: Remove secret
const config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/apple-music-api.config.secret.json' ) ) );
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 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
'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 );
}
} );
}
);
// TODO: Get user's subscriptions using store sdk
app.get( '/checkUserStatus', ( request: express.Request, response: express.Response ) => {
if ( sdk.checkAuth( request ) ) {
storeSDK.getSubscriptions( sdk.getUserData( request ).uid ).then( stat => {
let owned = false;
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 => {
console.error( 'Subscription check was successful' );
const now = new Date().getTime();
for ( let sub in stat ) {
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' ) ) {
owned = true;
&& (
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 => {
console.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.send( 'ERR_NOT_OWNED' );
response.status( 402 ).send( 'ERR_NOT_OWNED' );
}
} ).catch( e => {
console.error( e );
response.status( 404 ).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.status( 401 ).send( 'ERR_AUTH_REQUIRED' );
response.send( 500 ).send( e );
}
} );
}
);
app.use( ( request: express.Request, response: express.Response, next: express.NextFunction ) => {
response.status( 404 ).send( 'ERR_NOT_FOUND' );
// response.sendFile( path.join( __dirname + '' ) )
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,18 +1,23 @@
export interface Room {
playbackStatus: boolean;
playbackStart: number;
playlist: Song[];
playlistIndex: number;
roomName: string;
roomToken: string;
ownerUID: string;
useAntiTamper: boolean;
'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;
'title': string;
'artist': string;
'duration': number;
'cover': string;
'additionalInfo'?: string;
}
export interface SocketData {
[key: string]: Room;
}

View File

@@ -1,41 +0,0 @@
import express from 'express';
import expressSession from 'express-session';
import crypto from 'node:crypto';
// TODO: Use also express-session to make it work with getSessionID and session referencing
const checkAuth = ( request: express.Request ) => {
return true;
}
export interface AuthSDKConfig {
token: string;
name: string;
client: string;
backendURL: string;
failReturnURL: string;
useSecureCookie?: boolean;
}
declare module 'express-session' {
interface SessionData {
isAuth: boolean;
uid: string;
username: string;
email: string;
additionalData: object;
}
}
const getUserData = ( request: express.Request ) => {
if ( !request.session.uid ) {
request.session.uid = crypto.randomUUID();
request.session.username = 'FOSS-Version';
request.session.email = 'example@example.com';
}
return { 'email': request.session.email, 'username': request.session.username, 'uid': request.session.uid, 'id': request.session.id };
}
export default {
checkAuth,
getUserData
}

View File

@@ -1,11 +1,15 @@
const getSubscriptions = ( uid: string ) => {
return [ {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const getSubscriptions = ( _uid: string ) => {
return [
{
'id': 'com.janishutz.MusicPlayer.subscription',
'expires': new Date().getTime() + 200000,
'status': true
} ];
}
}
];
};
export default {
getSubscriptions,
}
};

178
backend/src/socket.ts Normal file
View File

@@ -0,0 +1,178 @@
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
};

252
backend/src/sse.ts Normal file
View File

@@ -0,0 +1,252 @@
import cors from 'cors';
import express from 'express';
import bodyParser from 'body-parser';
import {
SocketData
} from './definitions';
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 ) {
cl.splice( parseInt( c ), 1 );
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 ) ] ) {
response.send( 'ok' );
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 404 ).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' ) {
const clients = clientReference[ request.body.roomName ];
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;
}
if ( send ) {
const clients = clientReference[ request.body.roomName ];
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
) {
socketData[ request.body.roomName ] = undefined;
const clients = clientReference[ request.body.roomName ];
for ( const client in clients ) {
if ( connectedClients[ clients[ client ] ] ) {
connectedClients[ clients[ client ] ]
.response.write( 'data: ' + JSON.stringify( {
'type': 'delete-share',
'data': true
} ) + '\n\n' );
}
}
} 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

@@ -11,8 +11,9 @@ import path from 'path';
import fs from 'fs';
import * as sqlDB from './mysqldb.js';
declare let __dirname: string | undefined
if ( typeof( __dirname ) === 'undefined' ) {
declare let __dirname: string | undefined;
if ( typeof __dirname === 'undefined' ) {
__dirname = path.resolve( path.dirname( '' ) );
} else {
__dirname = __dirname + '/../';
@@ -22,9 +23,8 @@ const dbRef = {
'user': 'music_users',
'users': 'music_users',
};
const dbh = new sqlDB.SQLDB();
let dbh = new sqlDB.SQLDB();
dbh.connect();
/**
@@ -32,7 +32,7 @@ dbh.connect();
* @returns {undefined}
*/
const initDB = (): undefined => {
( async() => {
( async () => {
console.log( '[ DB ] Setting up...' );
dbh.setupDB();
console.log( '[ DB ] Setting up complete!' );
@@ -48,9 +48,14 @@ const initDB = (): undefined => {
*/
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 => {
dbh.query( {
'command': 'getFilteredData',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( data => {
resolve( data );
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
} );
@@ -66,7 +71,7 @@ const getDataSimple = ( db: string, column: string, searchQuery: string ): Promi
* @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> => {
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),
@@ -76,22 +81,24 @@ const getDataWithLeftJoinFunction = ( db: string, column: string, searchQuery: s
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
*/
return new Promise( ( resolve, reject ) => {
let settings = {
const settings = {
'command': 'LeftJoin',
'property': column,
'searchQuery': searchQuery,
'selection': '',
'secondTable': dbRef[ secondTable ],
'matchingParam': dbRef[ db ] + '.' + nameOfMatchingParam + '=' + dbRef[ secondTable ] + '.' + nameOfMatchingParam,
}
for ( let el in columns ) {
};
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 => {
} )
.catch( error => {
reject( error );
} );
} );
@@ -102,11 +109,14 @@ const getDataWithLeftJoinFunction = ( db: string, column: string, searchQuery: s
* @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> => {
const getData = ( db: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
dbh.query( { 'command': 'getAllData' }, dbRef[ db ] ).then( data => {
dbh.query( {
'command': 'getAllData'
}, dbRef[ db ] ).then( data => {
resolve( data );
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
} );
@@ -120,23 +130,38 @@ const getData = ( db: string ): Promise<Object> => {
* @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> => {
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 => {
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 => {
dbh.query( {
'command': 'updateData',
'property': column,
'searchQuery': searchQuery,
'newValues': data
}, dbRef[ db ] ).then( dat => {
resolve( dat );
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
} else {
dbh.query( { 'command': 'addData', 'data': data }, dbRef[ db ] ).then( dat => {
dbh.query( {
'command': 'addData',
'data': data
}, dbRef[ db ] ).then( dat => {
resolve( dat );
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
}
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
} );
@@ -151,9 +176,14 @@ const writeDataSimple = ( db: string, column: string, searchQuery: string, data:
*/
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 => {
dbh.query( {
'command': 'deleteData',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( dat => {
resolve( dat );
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
} );
@@ -168,13 +198,18 @@ const deleteDataSimple = ( db: string, column: string, searchQuery: string ): Pr
*/
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 => {
dbh.query( {
'command': 'checkDataAvailability',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( res => {
if ( res.length > 0 ) {
resolve( true );
} else {
resolve( false );
}
} ).catch( error => {
} )
.catch( error => {
reject( error );
} );
} );
@@ -186,16 +221,18 @@ const checkDataAvailability = ( db: string, column: string, searchQuery: string
* @returns {Promise<object>} Returns the data from all files
*/
const getJSONDataBatch = async ( files: Array<string> ): Promise<object> => {
let allFiles = {};
for ( let file in files ) {
const allFiles = {};
for ( const file in files ) {
try {
allFiles[ files[ file ] ] = await getJSONData( files[ file ] );
} catch( err ) {
} catch ( err ) {
allFiles[ files[ file ] ] = 'ERROR: ' + err;
}
}
return allFiles;
}
};
/**
* Load all data from a JSON file
@@ -245,7 +282,7 @@ const getJSONDataSimple = ( file: string, identifier: string ): Promise<object>
* @param {string} file The file to be loaded (path relative to root)
* @returns {object} Returns the JSON file
*/
const getJSONDataSync = ( file: string ): Object => {
const getJSONDataSync = ( file: string ): object => {
return JSON.parse( fs.readFileSync( path.join( __dirname + '/' + file ) ).toString() );
};
@@ -263,14 +300,17 @@ const writeJSONDataSimple = ( db: string, identifier: string, values: any ) => {
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 ) => {
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
}
resolve( true );
} );
}
@@ -286,7 +326,7 @@ const writeJSONDataSimple = ( db: string, identifier: string, values: any ) => {
*/
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 ) => {
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( data ), error => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
@@ -309,14 +349,17 @@ const deleteJSONDataSimple = ( db: string, identifier: string ): Promise<boolean
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 ) => {
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
}
resolve( true );
} );
}
@@ -324,7 +367,19 @@ const deleteJSONDataSimple = ( db: string, identifier: string ): Promise<boolean
} );
};
export default { initDB, checkDataAvailability, deleteDataSimple, deleteJSONDataSimple, getData,
getDataSimple, getDataWithLeftJoinFunction, getJSONData, getJSONDataBatch, getJSONDataSimple,
getJSONDataSync, writeDataSimple, writeJSONData, writeJSONDataSimple
export default {
initDB,
checkDataAvailability,
deleteDataSimple,
deleteJSONDataSimple,
getData,
getDataSimple,
getDataWithLeftJoinFunction,
getJSONData,
getJSONDataBatch,
getJSONDataSimple,
getJSONDataSync,
writeDataSimple,
writeJSONData,
writeJSONDataSimple
};

View File

@@ -11,8 +11,9 @@ import mysql from 'mysql';
import fs from 'fs';
import path from 'path';
declare let __dirname: string | undefined
if ( typeof( __dirname ) === 'undefined' ) {
declare let __dirname: string | undefined;
if ( typeof __dirname === 'undefined' ) {
__dirname = path.resolve( path.dirname( '' ) );
} else {
__dirname = __dirname + '/../';
@@ -22,21 +23,35 @@ if ( typeof( __dirname ) === 'undefined' ) {
// 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 );
@@ -46,19 +61,23 @@ class SQLDB {
connect () {
return new Promise( ( resolve, reject ) => {
const self = this;
if ( this.isRecovering ) {
console.log( '[ SQL ] Attempting to recover from critical error' );
this.sqlConnection = mysql.createConnection( this.config );
this.isRecovering = false;
}
this.sqlConnection.connect( ( err ) => {
this.sqlConnection.connect( err => {
if ( err ) {
console.error( '[ SQL ]: An error ocurred whilst connecting: ' + err.stack );
reject( err );
return;
}
console.log( '[ SQL ] Connected to database successfully' );
self.sqlConnection.on( 'error', ( err ) => {
self.sqlConnection.on( 'error', err => {
if ( err.code === 'ECONNRESET' ) {
self.isRecovering = true;
setTimeout( () => {
@@ -81,15 +100,17 @@ class SQLDB {
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 ) => {
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>> {
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):
@@ -141,6 +162,7 @@ class SQLDB {
- 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' ) {
@@ -150,19 +172,23 @@ class SQLDB {
} else if ( operation.command === 'addData' ) {
let keys = '';
let values = '';
for ( let key in operation.data ) {
for ( const key in operation.data ) {
keys += String( key ) + ', ';
values += this.sqlConnection.escape( String( operation.data[ 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 ( let value in operation.newValues ) {
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 );
}
@@ -178,12 +204,17 @@ class SQLDB {
} 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 };
export {
SQLConfig, SQLDB
};

View File

@@ -1,5 +1,5 @@
{
"name": "MusicPlayer",
"version": "3.0.0",
"private": false,
"private": false
}