1 Commits
Author SHA1 Message Date
Janis Hutz b8b825de4a Update README.md 2026-08-29 15:28:38 +00:00
109 changed files with 7562 additions and 2627 deletions
+30
View File
@@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"Vue.vscode-typescript-vue-plugin",
"dbaeumer.vscode-eslint"
]
}
+46
View File
@@ -0,0 +1,46 @@
# MusicPlayerV2-GUI
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```
View File
@@ -1,15 +1,15 @@
import vue from 'eslint-plugin-vue';
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import typescript from '@typescript-eslint/eslint-plugin';
import stylistic from '@stylistic/eslint-plugin'; import stylistic from '@stylistic/eslint-plugin';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import typescript from '@typescript-eslint/eslint-plugin';
import vue from 'eslint-plugin-vue';
const style = { const style = {
'plugins': { 'plugins': {
'@stylistic': stylistic, '@stylistic': stylistic,
'@stylistic/js': stylistic, '@stylistic/js': stylistic,
'@stylistic/ts': stylistic '@stylistic/ts': stylistic,
}, },
'files': [ 'files': [
'**/*.ts', '**/*.ts',
@@ -21,25 +21,20 @@ const style = {
], ],
'rules': { 'rules': {
'sort-imports': [ 'sort-imports': [
'warn', 'error',
{ {
'ignoreCase': false, "ignoreCase": false,
'ignoreDeclarationSort': false, "ignoreDeclarationSort": false,
'ignoreMemberSort': false, "ignoreMemberSort": false,
'memberSyntaxSortOrder': [ "memberSyntaxSortOrder": ["none", "all", "multiple", "single"],
'none', "allowSeparatedGroups": false
'all',
'multiple',
'single'
],
'allowSeparatedGroups': false
} }
], ],
// Formatting // Formatting
'@stylistic/array-bracket-newline': [ '@stylistic/array-bracket-newline': [
'error', 'error',
{ {
'multiline': false, 'multiline': true,
'minItems': 2 'minItems': 2
} }
], ],
@@ -50,8 +45,7 @@ const style = {
'@stylistic/array-element-newline': [ '@stylistic/array-element-newline': [
'error', 'error',
{ {
'consistent': false, 'multiline': true,
'multiline': false,
'minItems': 2 'minItems': 2
} }
], ],
@@ -72,14 +66,7 @@ const style = {
], ],
'@stylistic/brace-style': [ '@stylistic/brace-style': [
'error', 'error',
'1tbs', '1tbs'
{
'allowSingleLine': false
}
],
'@stylistic/comma-dangle': [
'error',
'never'
], ],
'@stylistic/comma-spacing': [ '@stylistic/comma-spacing': [
'error', 'error',
@@ -96,9 +83,9 @@ const style = {
'error', 'error',
'property' 'property'
], ],
'@stylistic/function-call-argument-newline': [ '@stylistic/eol-last': [
'error', 'error',
'consistent' 'always'
], ],
'@stylistic/function-call-spacing': [ '@stylistic/function-call-spacing': [
'error', 'error',
@@ -106,7 +93,13 @@ const style = {
], ],
'@stylistic/function-paren-newline': [ '@stylistic/function-paren-newline': [
'error', 'error',
'multiline-arguments' {
'minItems': 3
}
],
'@stylistic/function-call-argument-newline': [
'error',
'consistent'
], ],
'@stylistic/implicit-arrow-linebreak': [ '@stylistic/implicit-arrow-linebreak': [
'error', 'error',
@@ -116,10 +109,6 @@ const style = {
'error', 'error',
4 4
], ],
'@stylistic/indent-binary-ops': [
'error',
4
],
'@stylistic/key-spacing': [ '@stylistic/key-spacing': [
'error', 'error',
{ {
@@ -141,31 +130,18 @@ const style = {
'@stylistic/max-len': [ '@stylistic/max-len': [
'warn', 'warn',
{ {
'code': 140, 'code': 120,
'comments': 160, 'comments': 140,
'ignoreComments': false, 'ignoreComments': false,
'ignoreUrls': true, 'ignoreUrls': true,
'ignoreStrings': true, 'ignoreStrings': false
'ignoreTemplateLiterals': true,
'ignoreRegExpLiterals': true
} }
], ],
'@stylistic/max-statements-per-line': [
'error',
{
'max': 1
}
],
'@stylistic/multiline-ternary': [
'error',
'always-multiline'
],
'@stylistic/new-parens': [ '@stylistic/new-parens': [
'error', 'error',
'always' 'always'
], ],
'@stylistic/newline-per-chained-call': 'error', '@stylistic/newline-per-chained-call': [ 'error' ],
'@stylistic/no-confusing-arrow': 'error',
'@stylistic/no-extra-parens': [ '@stylistic/no-extra-parens': [
'error', 'error',
'all', 'all',
@@ -203,17 +179,17 @@ const style = {
'always' 'always'
], ],
'@stylistic/object-property-newline': 'error', '@stylistic/object-property-newline': 'error',
'@stylistic/one-var-declaration-per-line': 'error',
'@stylistic/operator-linebreak': [ '@stylistic/operator-linebreak': [
'error', 'error',
'before' 'before'
], ],
'@stylistic/one-var-declaration-per-line': 'error',
'@stylistic/padded-blocks': [ '@stylistic/padded-blocks': [
'error', 'error',
{ {
'blocks': 'never', 'blocks': 'never',
'classes': 'always', 'classes': 'always',
'switches': 'never' 'switches': 'never',
} }
], ],
@@ -597,7 +573,7 @@ const style = {
'blankLine': 'always', 'blankLine': 'always',
'prev': 'class', 'prev': 'class',
'next': '*' 'next': '*'
} },
], ],
'@stylistic/quote-props': [ '@stylistic/quote-props': [
'error', 'error',
@@ -649,23 +625,17 @@ const style = {
'error', 'error',
'always' 'always'
], ],
'@stylistic/switch-colon-spacing': 'error',
'@stylistic/template-curly-spacing': [ '@stylistic/template-curly-spacing': [
'error', 'error',
'always' 'always'
], ],
'@stylistic/template-tag-spacing': [ '@stylistic/switch-colon-spacing': 'error',
'error',
'always'
],
'@stylistic/type-generic-spacing': 'error',
'@stylistic/type-named-tuple-spacing': 'error',
'@stylistic/wrap-iife': [ '@stylistic/wrap-iife': [
'error', 'error',
'inside' 'inside'
], ],
'@stylistic/wrap-regex': 'error', '@stylistic/wrap-regex': 'error',
'@stylistic/ts/type-annotation-spacing': 'error' '@stylistic/ts/type-annotation-spacing': 'error',
} }
}; };
@@ -684,15 +654,15 @@ export default tseslint.config(
'ecmaVersion': 'latest', 'ecmaVersion': 'latest',
'globals': globals.browser, 'globals': globals.browser,
'parserOptions': { 'parserOptions': {
'parser': tseslint.parser 'parser': tseslint.parser,
} },
}, },
'plugins': { 'plugins': {
'vue': vue, 'vue': vue,
'@stylistic': stylistic, '@stylistic': stylistic,
'@stylistic/js': stylistic, '@stylistic/js': stylistic,
'@stylistic/ts': stylistic, '@stylistic/ts': stylistic,
'@typescript-eslint': typescript '@typescript-eslint': typescript,
}, },
'extends': [ 'extends': [
eslint.configs.recommended, eslint.configs.recommended,
@@ -736,9 +706,9 @@ export default tseslint.config(
'error', 'error',
{ {
'singleline': 3, 'singleline': 3,
'multiline': 1 'multiline': 1,
} }
] ],
} },
} },
); );
@@ -5,13 +5,20 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MusicPlayer</title> <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://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
<script src="https://static.janishutz.com/libs/jquery/jquery.min.js"></script>
<title>MusicPlayer</title>
</head> </head>
<body> <body>
<div id="app"></div>
<noscript>This application requires JavaScript to work!</noscript> <noscript>This application requires JavaScript to work!</noscript>
<div id="app"></div>
<script>
localStorage.setItem('music-player-config', 'sse');// Or 'ws'
</script>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
+46
View File
@@ -0,0 +1,46 @@
{
"name": "musicplayerv2-gui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@janishutz/login-sdk-browser": "^1.1.0",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@melloware/coloris": "^0.24.0",
"@rollup/plugin-inject": "^5.0.5",
"buffer": "^6.0.3",
"colorthief": "^2.6.0",
"music-metadata": "^11.9.0",
"musickit-typescript": "^1.2.4",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.5",
"vite-plugin-node-polyfills": "^0.24.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@stylistic/eslint-plugin": "^5.4.0",
"@tsconfig/node20": "^20.1.2",
"@types/jquery": "^3.5.33",
"@types/node": "^20.11.10",
"@vitejs/plugin-vue": "^6.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint-plugin-vue": "^10.5.0",
"npm-run-all2": "^6.1.1",
"sass-embedded": "^1.92.0",
"typescript": "~5.3.0",
"typescript-eslint": "^8.44.1",
"vite": "^7.1.4",
"vue-tsc": "^2.0.29"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -1,3 +1,17 @@
<template>
<div>
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="material-symbols-outlined" v-html="theme"></span>
</button>
<router-view id="main-view" v-slot="{ Component, route }">
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<script setup lang="ts"> <script setup lang="ts">
import { import {
RouterView RouterView
@@ -33,20 +47,6 @@
} }
</script> </script>
<template>
<div>
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="material-symbols-outlined" v-html="theme"></span>
</button>
<router-view id="main-view" v-slot="{ Component, route }">
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
<component :is="Component" />
</transition>
</router-view>
</div>
</template>
<style> <style>
body { body {
background-color: var( --background-color ); background-color: var( --background-color );
@@ -57,7 +57,7 @@
--secondary-color: white; --secondary-color: white;
--background-color: rgb(221, 221, 221); --background-color: rgb(221, 221, 221);
--nav-background: white; --nav-background: white;
--accent-color: #00457a; --hover-color: #00457a;
--popup-color: rgb(224, 224, 224); --popup-color: rgb(224, 224, 224);
--overlay-color: rgba(0, 0, 0, 0.7); --overlay-color: rgba(0, 0, 0, 0.7);
--PI: 3.14159265358979; --PI: 3.14159265358979;
@@ -74,7 +74,7 @@
--background-color: rgb(32, 32, 32); --background-color: rgb(32, 32, 32);
--nav-background: rgb(54, 54, 54); --nav-background: rgb(54, 54, 54);
--popup-color: rgb(58, 58, 58); --popup-color: rgb(58, 58, 58);
--accent-color: #007ddd; --hover-color: #007ddd;
--overlay-color: rgba(104, 104, 104, 0.575); --overlay-color: rgba(104, 104, 104, 0.575);
--gray-color: rgb(207, 207, 207); --gray-color: rgb(207, 207, 207);
--footer-background: rgb(53, 53, 53); --footer-background: rgb(53, 53, 53);
@@ -90,7 +90,7 @@
--background-color: rgb(32, 32, 32); --background-color: rgb(32, 32, 32);
--nav-background: rgb(54, 54, 54); --nav-background: rgb(54, 54, 54);
--popup-color: rgb(58, 58, 58); --popup-color: rgb(58, 58, 58);
--accent-color: #007ddd; --hover-color: #007ddd;
--overlay-color: rgba(104, 104, 104, 0.575); --overlay-color: rgba(104, 104, 104, 0.575);
--gray-color: rgb(207, 207, 207); --gray-color: rgb(207, 207, 207);
--footer-background: rgb(53, 53, 53); --footer-background: rgb(53, 53, 53);
@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
width="361px" height="361px" viewBox="0 0 361 361" style="enable-background:new 0 0 361 361;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
</style>
<g id="Layer_5">
</g>
<g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="180" y1="358.6047" x2="180" y2="7.7586">
<stop offset="0" style="stop-color:#FA233B"/>
<stop offset="1" style="stop-color:#FB5C74"/>
</linearGradient>
<path class="st0" d="M360,112.61c0-4.3,0-8.6-0.02-12.9c-0.02-3.62-0.06-7.24-0.16-10.86c-0.21-7.89-0.68-15.84-2.08-23.64
c-1.42-7.92-3.75-15.29-7.41-22.49c-3.6-7.07-8.3-13.53-13.91-19.14c-5.61-5.61-12.08-10.31-19.15-13.91
c-7.19-3.66-14.56-5.98-22.47-7.41c-7.8-1.4-15.76-1.87-23.65-2.08c-3.62-0.1-7.24-0.14-10.86-0.16C255.99,0,251.69,0,247.39,0
H112.61c-4.3,0-8.6,0-12.9,0.02c-3.62,0.02-7.24,0.06-10.86,0.16C80.96,0.4,73,0.86,65.2,2.27c-7.92,1.42-15.28,3.75-22.47,7.41
c-7.07,3.6-13.54,8.3-19.15,13.91c-5.61,5.61-10.31,12.07-13.91,19.14c-3.66,7.2-5.99,14.57-7.41,22.49
c-1.4,7.8-1.87,15.76-2.08,23.64c-0.1,3.62-0.14,7.24-0.16,10.86C0,104.01,0,108.31,0,112.61v134.77c0,4.3,0,8.6,0.02,12.9
c0.02,3.62,0.06,7.24,0.16,10.86c0.21,7.89,0.68,15.84,2.08,23.64c1.42,7.92,3.75,15.29,7.41,22.49c3.6,7.07,8.3,13.53,13.91,19.14
c5.61,5.61,12.08,10.31,19.15,13.91c7.19,3.66,14.56,5.98,22.47,7.41c7.8,1.4,15.76,1.87,23.65,2.08c3.62,0.1,7.24,0.14,10.86,0.16
c4.3,0.03,8.6,0.02,12.9,0.02h134.77c4.3,0,8.6,0,12.9-0.02c3.62-0.02,7.24-0.06,10.86-0.16c7.89-0.21,15.85-0.68,23.65-2.08
c7.92-1.42,15.28-3.75,22.47-7.41c7.07-3.6,13.54-8.3,19.15-13.91c5.61-5.61,10.31-12.07,13.91-19.14
c3.66-7.2,5.99-14.57,7.41-22.49c1.4-7.8,1.87-15.76,2.08-23.64c0.1-3.62,0.14-7.24,0.16-10.86c0.03-4.3,0.02-8.6,0.02-12.9V112.61
z"/>
</g>
<g id="Glyph_2_">
<g>
<path class="st1" d="M254.5,55c-0.87,0.08-8.6,1.45-9.53,1.64l-107,21.59l-0.04,0.01c-2.79,0.59-4.98,1.58-6.67,3
c-2.04,1.71-3.17,4.13-3.6,6.95c-0.09,0.6-0.24,1.82-0.24,3.62c0,0,0,109.32,0,133.92c0,3.13-0.25,6.17-2.37,8.76
c-2.12,2.59-4.74,3.37-7.81,3.99c-2.33,0.47-4.66,0.94-6.99,1.41c-8.84,1.78-14.59,2.99-19.8,5.01
c-4.98,1.93-8.71,4.39-11.68,7.51c-5.89,6.17-8.28,14.54-7.46,22.38c0.7,6.69,3.71,13.09,8.88,17.82
c3.49,3.2,7.85,5.63,12.99,6.66c5.33,1.07,11.01,0.7,19.31-0.98c4.42-0.89,8.56-2.28,12.5-4.61c3.9-2.3,7.24-5.37,9.85-9.11
c2.62-3.75,4.31-7.92,5.24-12.35c0.96-4.57,1.19-8.7,1.19-13.26l0-116.15c0-6.22,1.76-7.86,6.78-9.08c0,0,88.94-17.94,93.09-18.75
c5.79-1.11,8.52,0.54,8.52,6.61l0,79.29c0,3.14-0.03,6.32-2.17,8.92c-2.12,2.59-4.74,3.37-7.81,3.99
c-2.33,0.47-4.66,0.94-6.99,1.41c-8.84,1.78-14.59,2.99-19.8,5.01c-4.98,1.93-8.71,4.39-11.68,7.51
c-5.89,6.17-8.49,14.54-7.67,22.38c0.7,6.69,3.92,13.09,9.09,17.82c3.49,3.2,7.85,5.56,12.99,6.6c5.33,1.07,11.01,0.69,19.31-0.98
c4.42-0.89,8.56-2.22,12.5-4.55c3.9-2.3,7.24-5.37,9.85-9.11c2.62-3.75,4.31-7.92,5.24-12.35c0.96-4.57,1-8.7,1-13.26V64.46
C263.54,58.3,260.29,54.5,254.5,55z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

After

Width:  |  Height:  |  Size: 276 B

@@ -0,0 +1,44 @@
<template>
<div>
<h1>Library</h1>
<playlistsView
:playlists="$props.playlists"
:is-logged-in="$props.isLoggedIn"
@selected-playlist="( id ) => selectPlaylist( id )"
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"
/>
</div>
</template>
<script setup lang="ts">
import playlistsView from '@/components/playlistsView.vue';
import type {
ReadFile
} from '@/scripts/song';
const emits = defineEmits( [
'selected-playlist',
'custom-playlist'
] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );
};
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
emits( 'custom-playlist', playlist );
};
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
},
'isLoggedIn': {
'default': false,
'type': Boolean,
'required': true,
}
} );
</script>
@@ -0,0 +1,416 @@
<!-- eslint-disable no-undef -->
<template>
<div id="notifications">
<div class="message-box" :class="[ location, size ]" :style="'z-index: ' + ( messageType === 'hide' ? '-1' : '1000' )">
<div class="message-container" :class="messageType">
<button class="close-notification" @click="handleNotifications();">
<span class="material-symbols-outlined close-notification-icon">close</span>
</button>
<span v-if="messageType == 'hide'" class="material-symbols-outlined types hide">question_mark</span>
<span v-else-if="messageType == 'ok'" class="material-symbols-outlined types" style="background-color: green;">done</span>
<span v-else-if="messageType == 'error'" class="material-symbols-outlined types" style="background-color: red;">close</span>
<span v-else-if="messageType == 'progress'" class="material-symbols-outlined types progress-spinner" style="background-color: blue;">progress_activity</span>
<span v-else-if="messageType == 'info'" class="material-symbols-outlined types" style="background-color: lightblue;">info</span>
<span v-else-if="messageType == 'warning'" class="material-symbols-outlined types" style="background-color: orangered;">warning</span>
<p class="message" @click="notificationAction()">
{{ notifications[ currentDID ] ? notifications[ currentDID ].message : '' }}
</p>
<div :class="'countdown countdown-' + messageType" :style="'width: ' + ( 100 - ( currentTime - notificationDisplayStartTime ) / ( notifications[ currentDID ] ? notifications[ currentDID ].showDuration : 1 ) / 10 ) + '%'"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import router from '@/router';
import {
type Ref, onUnmounted, ref
} from 'vue';
defineProps( {
'location': {
'type': String,
'default': 'topleft',
},
'size': {
'type': String,
'default': 'default',
}
// Size options: small, default (default option), big, bigger, huge
} );
interface Notification {
'message': string;
'showDuration': number;
'messageType': string;
'priority': string;
'id': number;
'redirect'?: string;
'openInNewTab'?: boolean;
}
interface NotificationList {
[ key: string ]: Notification
}
const notifications: Ref<NotificationList> = ref( {} );
const queue: Ref<number[]> = ref( [] );
const currentDID: Ref<number> = ref( 0 );
const messageType: Ref<string> = ref( 'hide' );
const currentID = ref( {
'critical': 0,
'medium': 1000,
'low': 10000
} );
const notificationDisplayStartTime: Ref<number> = ref( 0 );
const currentTime: Ref<number> = ref( 0 );
let progressBar = 0;
let notificationTimeout = 0;
const notificationAction = () => {
if ( notifications.value[ currentDID.value ] ) {
if ( notifications.value[ currentDID.value ].redirect ) {
if ( notifications.value[ currentDID.value ].openInNewTab ) {
window.open( notifications.value[ currentDID.value ].redirect ?? '' );
} else {
router.push( notifications.value[ currentDID.value ].redirect ?? '' );
}
}
}
};
/**
* Create a notification that will be displayed using the internal notification scheduler
* @param {string} message The message to show. Can only be plain text (no HTML)
* @param {number} showDuration The duration in seconds for which to show the notification
* @param {string} msgType Type of notification to show. Will dictate how it looks: 'ok', 'error', 'info', 'warn', 'progress'
* @param {string} priority The priority of the message: 'low', 'normal', 'critical'
* @returns {number}
*/
const createNotification = (
message: string, showDuration: number, msgType: string, priority: string, redirect?: string, openInNewTab?: boolean
): number => {
/*
Takes a notification options array that contains: message, showDuration (in seconds), msgType (ok, error, progress, info) and priority (low, normal, critical).
Returns a notification ID which can be used to cancel the notification. The component will throttle notifications and display
one at a time and prioritize messages with higher priority. Use vue refs to access these methods.
*/
let id = 0;
if ( priority === 'critical' ) {
currentID.value[ 'critical' ] += 1;
id = currentID.value[ 'critical' ];
} else if ( priority === 'normal' ) {
currentID.value[ 'medium' ] += 1;
id = currentID.value[ 'medium' ];
} else if ( priority === 'low' ) {
currentID.value[ 'low' ] += 1;
id = currentID.value[ 'low' ];
}
notifications.value[ id ] = {
'message': message,
'showDuration': showDuration,
'messageType': msgType,
'priority': priority,
'id': id,
'redirect': redirect,
'openInNewTab': openInNewTab
};
queue.value.push( id );
console.log( 'scheduled notification: ' + id + ' (' + message + ')' );
if ( ( new Date().getTime() - notificationDisplayStartTime.value ) / 1000 >= ( notifications.value[ currentDID.value ] ? notifications.value[ currentDID.value ].showDuration : 0 ) || messageType.value === 'hide' ) {
handleNotifications();
}
return id;
};
/**
* Update a notification's message after creating it
* @param {number} id The notification ID returned by createNotification
* @param {string} message The new message
* @returns {void}
*/
const updateNotification = ( id: number, message: string ): void => {
if ( notifications.value[ id ] ) {
notifications.value[ id ].message = message;
}
};
/**
* Delete a previously created notification
* @param {string} id The notification ID returned by createNotification
* @returns {undefined}
*/
const cancelNotification = ( id: number ): undefined => {
try {
delete notifications.value[ id ];
} catch ( error ) {
console.log( 'notification to be deleted is nonexistent or currently being displayed' );
}
try {
queue.value.splice( queue.value.indexOf( id ), 1 );
} catch {
console.debug( 'queue empty' );
}
if ( currentDID.value == id ) {
try {
clearTimeout( notificationTimeout );
} catch ( err ) { /* empty */ }
handleNotifications();
}
};
const handleNotifications = () => {
notificationDisplayStartTime.value = new Date().getTime();
queue.value.sort();
if ( queue.value.length > 0 ) {
if ( currentDID.value !== 0 ) {
delete notifications.value[ currentDID.value ];
}
currentDID.value = notifications.value[ queue.value[ 0 ] ][ 'id' ];
messageType.value = notifications.value[ queue.value[ 0 ] ].messageType;
queue.value.reverse();
queue.value.pop();
progressBar = setInterval( progressBarHandler, 25 );
notificationTimeout = setTimeout( () => {
handleNotifications();
}, notifications.value[ currentDID.value ].showDuration * 1000 );
} else {
try {
clearInterval( progressBar );
} catch ( err ) { /* empty */ }
messageType.value = 'hide';
}
};
const progressBarHandler = () => {
currentTime.value = new Date().getTime();
};
onUnmounted( () => {
try {
clearInterval( progressBar );
} catch ( err ) { /* empty */ }
try {
clearInterval( notificationTimeout );
} catch ( err ) { /* empty */ }
} );
defineExpose( {
createNotification,
cancelNotification,
updateNotification
} );
</script>
<style scoped>
.message-box {
position: fixed;
z-index: -100;
color: white;
transition: all 0.5s;
width: 95vw;
right: 2.5vw;
top: 1vh;
height: 10vh;
}
.close-notification {
position: absolute;
top: 5px;
right: 5px;
background: none;
color: white;
border: none;
cursor: pointer;
padding: 0;
margin: 0;
}
.close-notification-icon {
font-size: 1.75rem;
}
.countdown {
position: absolute;
bottom: 0;
left: 0;
height: 5px;
}
.message-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
width: 100%;
opacity: 1;
transition: all 0.5s;
cursor: default;
}
.types {
color: white;
border-radius: 100%;
margin-right: auto;
margin-left: 5%;
padding: 1.5%;
font-size: 200%;
}
.message {
margin-right: calc( 5% + 30px );
text-align: end;
height: 90%;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
.ok {
background-color: rgb(1, 71, 1);
}
.countdown-ok {
background-color: green;
}
.error {
background-color: rgb(114, 1, 1);
}
.countdown-error {
background-color: red;
}
.info {
background-color: rgb(44, 112, 151);
}
.countdown-info {
background-color: blue;
}
.warning {
background-color: orange;
}
.countdown-warning {
background-color: orangered;
}
.hide {
opacity: 0;
}
.progress {
z-index: 100;
background-color: rgb(0, 0, 99);
}
.countdown-ok {
background: none;
}
.progress-spinner {
animation: spin 2s infinite linear;
}
@keyframes spin {
from {
transform: rotate( 0deg );
}
to {
transform: rotate( 720deg );
}
}
@media only screen and (min-width: 750px) {
.default {
height: 10vh;
width: 32vw;
}
.small {
height: 7vh;
width: 27vw;
}
.big {
height: 12vh;
width: 38vw;
}
.bigger {
height: 15vh;
width: 43vw;
}
.huge {
height: 20vh;
width: 50vw;
}
.topleft {
top: 3vh;
left: 0.5vw;
}
.topright {
top: 3vh;
right: 0.5vw;
}
.bottomright {
bottom: 3vh;
right: 0.5vw;
}
.bottomleft {
bottom: 3vh;
right: 0.5vw;
}
}
@media only screen and (min-width: 1500px) {
.default {
height: 10vh;
width: 15vw;
}
.small {
height: 7vh;
width: 11vw;
}
.big {
height: 12vh;
width: 17vw;
}
.bigger {
height: 15vh;
width: 20vw;
}
.huge {
height: 20vh;
width: 25vw;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,473 @@
<template>
<div>
<h1>Queue</h1>
<input
id="more-songs"
type="file"
multiple
accept="audio/*"
class="small-buttons"
>
<button class="small-buttons" title="Load selected files" @click="addNewSongs()">
<span class="material-symbols-outlined">upload</span>
</button>
<button
v-if="$props.isLoggedIntoAppleMusic"
class="small-buttons"
title="Search Apple Music for the song"
@click="openSearch()"
>
<span class="material-symbols-outlined">search</span>
</button>
<button class="small-buttons" title="Clear the playlist" @click="clearPlaylist()">
<span class="material-symbols-outlined">delete</span>
</button>
<button title="Transmit additional information" class="small-buttons" @click="sendAdditionalInfo()">
<span class="material-symbols-outlined">send</span>
</button>
<p v-if="!hasSelectedSongs">
Please select at least one song to proceed
</p>
<div id="pl-box" class="playlist-box">
<!-- TODO: Allow editing additionalInfo. Think also how to make it persist over reloads... Export to JSON and then best-guess add them? Very easy for Apple Music 'cause ID, but how for local songs? Maybe using retrieved ID from Apple Music? -->
<!-- TODO: Handle long AppleMusic Playlists, as AppleMusic doesn't automatically load all songs of a playlist -->
<div
v-for="song in computedPlaylist"
:key="song.id"
class="song"
:class="( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && isPlaying ? 'playing' : ' not-playing' )
+ ( ( !isPlaying && ( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) ) ) ? ' active-song' : '' )"
>
<img
v-if="song.cover"
:src="song.cover"
alt="Song cover"
class="song-cover"
>
<span v-else class="material-symbols-outlined song-cover">music_note</span>
<div v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && $props.isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div id="bar-1" class="playing-bar"></div>
<div id="bar-2" class="playing-bar"></div>
<div id="bar-3" class="playing-bar"></div>
</div>
</div>
<span v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' )" class="material-symbols-outlined play-icon" @click="control( 'play' )">play_arrow</span>
<span v-else class="material-symbols-outlined play-icon" @click="play( song.id )">play_arrow</span>
<span class="material-symbols-outlined pause-icon" @click="control( 'pause' )">pause</span>
<span
v-if="canBeMoved( 'up', song.id )"
class="material-symbols-outlined move-icon"
title="Move song up"
@click="moveSong( song.id, 'up' )"
>arrow_upward</span>
<span
v-if="canBeMoved( 'down', song.id )"
class="material-symbols-outlined move-icon"
title="Move song down"
@click="moveSong( song.id, 'down' )"
>arrow_downward</span>
<h3 class="song-title">
{{ song.title }}
</h3>
<div>
<input
v-model="song.additionalInfo"
type="text"
placeholder="Additional information for remote display"
title="Additional information for remote display"
@focusin="kbControl( 'on' )"
@focusout="kbControl( 'off' )"
>
<p class="playing-in">
{{ getTimeUntil( song ) }}
</p>
</div>
<button
v-if="canBeMoved( 'down', song.id ) || canBeMoved( 'up', song.id )"
class="small-buttons"
title="Remove this song from the queue"
@click="deleteSong( song.id )"
>
<span class="material-symbols-outlined">delete</span>
</button>
</div>
</div>
<searchView ref="search" @selected-song="( song ) => { addNewSongsAppleMusic( song ) }" />
</div>
</template>
<script setup lang="ts">
// TODO: Add logout button
import type {
AppleMusicSongData, ReadFile, Song
} from '@/scripts/song';
import {
computed, ref
} from 'vue';
import searchView from './searchView.vue';
import {
useUserStore
} from '@/stores/userStore';
const userStore = useUserStore();
const search = ref( searchView );
const props = defineProps( {
'playlist': {
'default': [],
'required': true,
'type': Array<Song>
},
'currentlyPlaying': {
'default': 0,
'required': true,
'type': Number,
},
'isPlaying': {
'default': true,
'required': true,
'type': Boolean,
},
'pos': {
'default': 0,
'required': false,
'type': Number,
},
'isLoggedIntoAppleMusic': {
'default': false,
'required': true,
'type': Boolean,
}
} );
const hasSelectedSongs = ref( true );
const computedPlaylist = computed( () => {
let pl: Song[] = [];
// ( document.getElementById( 'pl-box' ) as HTMLDivElement ).scrollTo( { behavior: 'smooth', top: 0 } );
for ( let i = props.currentlyPlaying; i < props.playlist.length; i++ ) {
pl.push( props.playlist[ i ] );
}
return pl;
} );
const kbControl = ( action: string ) => {
if ( action === 'off' ) {
userStore.setKeyboardUsageStatus( false );
} else {
userStore.setKeyboardUsageStatus( true );
}
};
const openSearch = () => {
if ( search.value ) {
search.value.controlSearch( 'show' );
}
};
const canBeMoved = computed( () => {
return ( direction: movementDirection, songID: string ): boolean => {
let id = 0;
for ( let song in props.playlist ) {
if ( props.playlist[ song ].id === songID ) {
id = parseInt( song );
break;
}
}
if ( direction === 'up' ) {
if ( props.currentlyPlaying + 1 === id || props.currentlyPlaying === id ) {
return false;
}
return true;
} else {
if ( id === props.playlist.length - 1 || props.currentlyPlaying === id ) {
return false;
}
return true;
}
};
} );
const getTimeUntil = computed( () => {
return ( song: Song ) => {
let timeRemaining = 0;
for ( let i = props.currentlyPlaying; i < Object.keys( props.playlist ).length; i++ ) {
if ( props.playlist[ i ] == song ) {
break;
}
timeRemaining += props.playlist[ i ].duration;
}
if ( props.isPlaying ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min after starting to play';
}
}
};
} );
const deleteSong = ( songID: string ) => {
for ( const song in props.playlist ) {
if ( props.playlist[ song ].id === songID ) {
emits( 'delete-song', parseInt( song ) );
}
}
};
const clearPlaylist = () => {
emits( 'clear-playlist', '' );
};
const control = ( action: string ) => {
emits( 'control', action );
};
const play = ( song: string ) => {
emits( 'play-song', song );
};
const addNewSongs = () => {
const fileURLList: ReadFile[] = [];
const allFiles = ( document.getElementById( 'more-songs' ) as HTMLInputElement ).files ?? [];
if ( allFiles.length > 0 ) {
hasSelectedSongs.value = true;
for ( let file = 0; file < allFiles.length; file++ ) {
fileURLList.push( {
'url': URL.createObjectURL( allFiles[ file ] ),
'filename': allFiles[ file ].name
} );
}
emits( 'add-new-songs', fileURLList );
} else {
hasSelectedSongs.value = false;
}
};
const addNewSongsAppleMusic = ( songData: AppleMusicSongData ) => {
const song: Song = {
'artist': songData.attributes.artistName,
'cover': songData.attributes.artwork.url.replace( '{w}', String( songData.attributes.artwork.width ) ).replace( '{h}', String( songData.attributes.artwork.height ) ),
'duration': songData.attributes.durationInMillis / 1000,
'id': songData.id,
'origin': 'apple-music',
'title': songData.attributes.name
};
emits( 'add-new-songs-apple-music', song );
};
type movementDirection = 'up' | 'down';
const moveSong = ( songID: string, direction: movementDirection ) => {
let newSongPos = 0;
let hasFoundSongToMove = false;
for ( let el in props.playlist ) {
if ( props.playlist[ el ].id === songID ) {
const currPos = parseInt( el );
newSongPos = currPos + ( direction === 'up' ? -1 : 1 );
hasFoundSongToMove = true;
break;
}
}
if ( hasFoundSongToMove ) {
emits( 'playlist-reorder', {
'songID': songID,
'newPos': newSongPos
} );
}
};
const sendAdditionalInfo = () => {
emits( 'send-additional-info' );
};
const emits = defineEmits( [
'play-song',
'control',
'playlist-reorder',
'add-new-songs',
'add-new-songs-apple-music',
'delete-song',
'clear-playlist',
'send-additional-info'
] );
</script>
<style scoped>
.playlist-box {
height: calc( 100% - 150px );
width: 100%;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction: column;
}
.song {
border: solid var( --primary-color ) 1px;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
position: relative;
}
.song .song-cover {
width: 6rem;
height: 6rem;
object-fit: cover;
object-position: center;
font-size: 6rem;
}
.song img.song-cover {
font-size: unset;
}
.song-title {
margin-left: 10px;
margin-right: auto;
}
.playing-symbols {
position: absolute;
left: 1vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
margin: 0;
width: 6rem;
height: 6rem;
background-color: rgba( 0, 0, 0, 0.6 );
}
.playing-symbols-wrapper {
width: 5rem;
height: 6rem;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.playing-bar {
height: 60%;
background-color: white;
width: 10%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY( 1 );
}
50% {
transform: scaleY( 0.5 );
}
100% {
transform: scaleY( 1 );
}
}
.play-icon, .pause-icon {
display: none;
width: 6rem;
height: 6rem;
object-fit: cover;
object-position: center;
font-size: 6rem;
cursor: pointer;
user-select: none;
}
.playing:hover .pause-icon {
display: block;
}
.playing:hover .playing-symbols {
display: none;
}
.song:hover .song-cover {
display: none;
}
.not-playing:hover .play-icon {
display: block;
}
.active-song .pause-icon {
display: block;
}
.active-song.not-playing .song-cover {
display: none;
}
.active-song .song-image, .active-song:hover .pause-icon {
display: none;
}
.move-icon {
font-size: 1.5rem;
cursor: pointer;
user-select: none;
}
.small-buttons {
margin-bottom: 10px;
font-size: 1rem;
background: none;
border: none;
cursor: pointer;
}
.small-buttons .material-symbols-outlined {
font-size: 1.5rem;
color: var( --primary-color );
transition: all 0.5s;
}
.small-buttons:hover .material-symbols-outlined {
transform: scale(1.1);
}
</style>
@@ -0,0 +1,140 @@
<template>
<div class="playlists">
<h3 style="width: fit-content;">
Your playlists
</h3>
<div v-if="( $props.playlists ? $props.playlists.length < 1 : true ) && $props.isLoggedIn">
Loading...
<!-- TODO: Make prettier -->
</div>
<div v-else-if="!$props.isLoggedIn" class="not-logged-in">
<p>
You are not logged into Apple Music. We therefore can't show you your playlists.
<a href="" title="Refreshes the page, allowing you to log in">Change that</a>
</p>
<p>Use the button below to load songs from your local disk</p>
<input
id="pl-loader"
class="pl-loader-button"
type="file"
multiple="true"
accept="audio/*"
><br>
<button id="load-button" class="pl-loader-button" @click="loadPlaylistFromDisk()">
Load
</button>
<p v-if="!hasSelectedSongs">
Please select at least one song to proceed!
</p>
</div>
<div class="playlist-wrapper">
<div
v-for="pl in $props.playlists"
:key="pl.id"
class="playlist"
@click="selectPlaylist( pl.id )"
>
{{ pl.attributes.name }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type {
ReadFile
} from '@/scripts/song';
import {
ref
} from 'vue';
const hasSelectedSongs = ref( true );
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
},
'isLoggedIn': {
'default': false,
'type': Boolean,
'required': true,
}
} );
const loadPlaylistFromDisk = () => {
const fileURLList: ReadFile[] = [];
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
if ( allFiles.length > 0 ) {
hasSelectedSongs.value = true;
for ( let file = 0; file < allFiles.length; file++ ) {
fileURLList.push( {
'url': URL.createObjectURL( allFiles[ file ] ),
'filename': allFiles[ file ].name
} );
}
emits( 'custom-playlist', fileURLList );
} else {
hasSelectedSongs.value = false;
}
};
const emits = defineEmits( [
'selected-playlist',
'custom-playlist'
] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );
};
</script>
<style scoped>
.playlists {
width: 100%;
height: 75vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.playlist-wrapper {
width: 85%;
overflow-y: scroll;
overflow-x: hidden;
}
.playlist {
width: 100%;
padding: 15px;
border: solid var( --primary-color ) 1px;
border-radius: 5px;
margin: 1px;
cursor: pointer;
user-select: none;
}
.pl-loader-button {
background-color: white;
border: none;
padding: 10px;
border-radius: 5px;
margin: 5px;
font-size: 1rem;
cursor: pointer;
}
#load-button {
font-size: 1.5rem;
}
.not-logged-in {
width: 80%;
}
</style>
@@ -0,0 +1,304 @@
<template>
<div>
<div :class="'popup-backdrop' + ( isShowingPopup ? '' : ' hidden' )" :style="'transform-origin: ' + transformOriginVertical + ';'">
<div class="popup-main">
<span class="material-symbols-outlined close-icon" @click="closePopup()">close</span>
<h2>{{ popupContent.title }}</h2>
<div v-if="popupContent.popupType === 'information' || popupContent.popupType === 'confirmation'" class="popup-content">
<p v-html="popupContent.subtitle"></p>
</div>
<div v-else class="popup-content">
<p v-if="isShowingIncomplete" class="incomplete-message">{{ popupContent.incompleteMessage ? popupContent.incompleteMessage : 'Some entries are not filled out. Please fill them out to proceed.' }}</p>
<p v-html="popupContent.subtitle"></p>
<div v-for="content in popupContent.data" v-bind:key="content.id" class="popup-content-wrapper">
<div v-if="content.dataType === 'text'">
<label :for="'text-' + content.id">{{ content.name }}</label><br>
<input type="text" :id="'text-' + content.id" v-model="data[ content.id ]" class="input">
</div>
<div v-else-if="content.dataType === 'number'">
<label :for="'number-' + content.id">{{ content.name }}</label><br>
<input type="number" :id="'number-' + content.id" v-model="data[ content.id ]" class="input">
</div>
<div v-else-if="content.dataType === 'checkbox'">
<label :for="'checkbox-' + content.id">{{ content.name }}</label><br>
<label class="switch">
<input type="checkbox" v-model="data[ content.id ]">
<span class="slider round"></span>
</label>
</div>
<div v-else-if="content.dataType === 'textbox'">
<label :for="'textarea-' + content.id">{{ content.name }}</label><br>
<textarea :id="'textarea-' + content.id" v-model="data[ content.id ]" class="textarea"></textarea>
</div>
<div v-else-if="content.dataType === 'colour'">
<label :for="'colour-' + content.id">{{ content.name }}</label><br>
<input type="text" :id="'colour-' + content.id" v-model="data[ content.id ]" class="input">
</div>
</div>
</div>
<div style="margin-top: 20px;">
<button @click="closePopup()" style="margin-right: 10px;" class="fancy-button" v-if="popupContent.popupType === 'confirmation'">Cancel</button>
<button @click="closePopupReturn()" class="fancy-button">Ok</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
type PopupType = 'confirmation' | 'information' | 'input';
interface PopupData {
/**
* What to display to the user in front of the input field
*/
name: string;
/**
* The type of data to display
*/
dataType: 'text' | 'number' | 'checkbox' | 'textbox' | 'colour';
/**
* ID that is used for internal usage only. May only contain alphanumerical characters, as well as dashes and underscores
*/
id: string;
}
interface PopupContent {
/**
* The title shown in big letters at the top of the popup
*/
title: string;
/**
* (OPTIONAL) The subtitle shown to the user in normal sized letters below title
*/
subtitle?: string;
/**
* (OPTIONAL) The message to show, if the user has not filled out all fields
*/
incompleteMessage?: string;
/**
* The type of popup (i.e. what it is for)
*/
popupType: PopupType;
/**
* (REQUIRED ONLY when popupType === 'input') The input fields to show
*/
data?: PopupData[];
}
interface Data {
[key: string]: any;
}
import "@melloware/coloris/dist/coloris.css";
import { ref, type Ref } from 'vue';
import Coloris from '@melloware/coloris';
Coloris.init();
const isShowingPopup = ref( false );
const transformOriginVertical = ref( '50% 50%' );
const data: Ref<Data> = ref( {} );
const isShowingIncomplete = ref( false );
const popupContent: Ref<PopupContent> = ref( {
title: 'Undefined popup title',
popupType: 'information',
subtitle: 'This popup was not configured correctly during development. Please send a bug-report at <a href="https://support.janishutz.com/index.php?a=add&category=7" target="_blank">support.janishutz.com</a> and inform us about where exactly you encountered this popup! We are sorry for the inconvenience'
} );
const closePopup = () => {
isShowingPopup.value = false;
Coloris.close();
}
const closePopupReturn = () => {
for ( let el in popupContent.value.data ) {
if ( !data.value[ popupContent.value.data[ parseInt( el ) ].id ] && popupContent.value.data[ parseInt( el ) ].dataType !== 'checkbox' ) {
isShowingIncomplete.value = true;
return;
}
}
closePopup();
if ( popupContent.value.popupType === 'confirmation' ) {
emit( 'update', true );
} else {
emit( 'update', data.value );
}
}
const openPopup = ( popupConfig: PopupContent, transformOrigin?: string ) => {
if ( transformOrigin ) {
transformOriginVertical.value = transformOrigin;
} else {
transformOriginVertical.value = '50% 50%';
}
data.value = {};
for ( let el in popupConfig.data ) {
if ( !popupConfig.data[ parseInt( el ) ].id ) {
console.warn( '[ popup ] Missing ID for input with name "' + popupConfig.data[ parseInt( el ) ].name + '"!' );
} else if ( popupConfig.data[ parseInt( el ) ].dataType === 'colour' ) {
Coloris( { el: '#colour-' + popupConfig.data[ parseInt( el ) ].id } );
}
}
isShowingPopup.value = true;
popupContent.value = popupConfig;
for ( const el in popupContent.value.data ) {
if ( popupContent.value.data[ parseInt( el ) ].dataType === 'checkbox' ) {
data.value[ popupContent.value.data[ parseInt( el ) ].id ] = false;
}
}
}
defineExpose( {
openPopup,
} );
const emit = defineEmits( [ 'update' ] );
</script>
<style scoped>
.popup-backdrop {
width: 100vw;
height: 100vh;
position: fixed;
background-color: var( --overlay-color );
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
transition: all 0.5s;
transform: scale(1);
z-index: 99;
}
.incomplete-message {
color: red;
font-weight: 300;
font-style: italic;
margin-top: 0;
font-size: 0.8rem;
}
.hidden {
transform: scale(0);
}
.popup-main {
width: 40%;
height: 50%;
background-color: var( --secondary-color );
padding: 2.5%;
border-radius: 20px;
position: relative;
overflow-y: scroll;
display: block;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
font-size: 2rem;
cursor: pointer;
user-select: none;
}
.popup-content {
position: unset;
height: 60%;
overflow-y: scroll;
}
.textarea {
width: 80%;
resize: vertical;
min-height: 30px;
border-radius: 10px;
border: solid var( --primary-color ) 1px;
padding: 5px;
}
.input {
padding: 5px;
border-radius: 10px;
border: solid var( --primary-color ) 1px;
}
.popup-content-wrapper {
margin-bottom: 10px;
}
</style>
<style scoped>
/* https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_switch */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked + .slider {
background-color: #2196F3;
}
input:focus + .slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
</style>
@@ -0,0 +1,265 @@
<template>
<div>
<div id="search-bar" :class="showsSearch ? 'search-shown' : ''">
<div id="search-box-wrapper">
<input type="text" v-model="searchText" id="search-box" placeholder="Type to search..." @keyup="e => { keyHandler( e ) }">
<div class="symbol-wrapper" id="search-symbol-wrapper">
<span class="material-symbols-outlined search-symbol" @click="search()">search</span>
</div>
<div :class="'search-result-wrapper' + ( searchText.length > 0 ? ' show-search-results' : '' )">
<div v-for="result in searchResults" v-bind:key="result.id"
:class="'search-result' + ( selectedProduct === result.id ? ' prod-selected' : '' )"
@mouseenter="removeSelection()" @click="select( result )">
<div :style="'background-image: url(' + result.attributes.artwork.url.replace( '{w}', '500' ).replace( '{h}', '500' ) + ');'" class="search-product-image"></div>
<div class="search-product-name"><p><b>{{ result.attributes.name }}</b> <i>by {{ result.attributes.artistName }}</i></p></div>
</div>
<div v-if="searchResults.length === 0 && searchText.length < 3">
<p>No results to show</p>
</div>
<div v-else-if="searchText.length < 3">
<p>Enter at least three characters to start searching</p>
</div>
</div>
</div>
<div class="symbol-wrapper">
<span class="material-symbols-outlined search-symbol" @click="controlSearch( 'hide' )" id="close-icon">close</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MusicKitJSWrapper from '@/scripts/music-player';
import type { AppleMusicSongData } from '@/scripts/song';
import { useUserStore } from '@/stores/userStore';
import { ref, type Ref } from 'vue';
const showsSearch = ref( false );
const searchText = ref( '' );
const selectedProduct = ref( '' );
let selectedProductIndex = -1;
const player = new MusicKitJSWrapper();
const updateSearchResults = () => {
if ( searchText.value.length > 2 ) {
player.findSongOnAppleMusic( searchText.value ).then( data => {
searchResults.value = data.data.results.songs.data ?? [];
selectedProductIndex = -1;
selectedProduct.value = '';
} );
}
}
const searchResults: Ref<AppleMusicSongData[]> = ref( [] );
const userStore = useUserStore();
const controlSearch = ( action: string ) => {
if ( action === 'show' ) {
userStore.setKeyboardUsageStatus( true );
showsSearch.value = true;
setTimeout( () => {
const searchBox = document.getElementById( 'search-box' ) as HTMLInputElement;
searchBox.focus();
}, 500 );
} else if ( action === 'hide' ) {
userStore.setKeyboardUsageStatus( false );
showsSearch.value = false;
}
searchText.value = '';
removeSelection();
}
const removeSelection = () => {
selectedProduct.value = '';
selectedProductIndex = -1;
}
const keyHandler = ( e: KeyboardEvent ) => {
if ( e.key === 'Escape' ) {
controlSearch( 'hide' );
} else if ( e.key === 'Enter' ) {
e.preventDefault();
if ( selectedProductIndex >= 0 ) {
select( searchResults.value[ selectedProductIndex ] );
controlSearch( 'hide' );
} else {
search();
}
} else if ( e.key === 'ArrowDown' ) {
e.preventDefault();
if ( selectedProductIndex < searchResults.value.length - 1 ) {
selectedProductIndex += 1;
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
}
} else if ( e.key === 'ArrowUp' ) {
e.preventDefault();
if ( selectedProductIndex > 0 ) {
selectedProductIndex -= 1;
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
} else {
removeSelection();
}
} else {
updateSearchResults();
}
}
const select = ( song: AppleMusicSongData ) => {
emits( 'selected-song', song );
controlSearch( 'hide' );
}
const search = () => {
emits( 'selected-song', searchResults.value[ 0 ] );
controlSearch( 'hide' );
}
const emits = defineEmits( [ 'selected-song' ] );
defineExpose( {
controlSearch
} );
</script>
<style scoped>
#search-bar {
position: fixed;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
top: -15vh;
background-color: var( --accent-background );
height: 15vh;
width: 100vw;
left: 0;
transition: all 1s;
z-index: 50;
}
#search-bar.search-shown {
top: 0;
}
#search-box {
width: calc(100% - 20px);
padding: 10px;
font-size: 150%;
}
#search-box-wrapper {
width: 60vw;
position: relative;
display: block;
}
@media only screen and (min-width: 1000px) {
#search-box-wrapper {
width: 45vw;
}
}
@media only screen and (min-width: 1500px) {
#search-box-wrapper {
width: 30vw;
}
}
#search-symbol-wrapper {
position: absolute;
right: 10px;
top: 3px;
}
.symbol-wrapper {
display: flex;
height: 3rem;
width: 3rem;
align-items: center;
justify-content: center;
}
.search-symbol {
color: black;
padding: 0;
margin: 0;
font-size: 200%;
cursor: pointer;
transition: all 0.5s;
}
.search-symbol:hover {
font-size: 250%;
}
#close-icon {
margin-left: 5px;
color: var( --primary-color );
}
</style>
<style scoped>
.search-result-wrapper {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 2px;
padding-bottom: 5px;
left: 0;
text-decoration: none;
background-color: white;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
transform-origin: top;
transform: scaleY( 0 );
transition: all 0.5s;
color: black;
}
.show-search-results {
transform: scaleY( 1 );
}
.search-result {
padding: 3px;
display: flex;
flex-direction: row;
width: 98%;
text-decoration: none;
transition: all 0.5s;
cursor: pointer;
}
.search-result:hover, .prod-selected {
text-decoration: underline black;
}
.search-product-image {
height: 4rem;
width: 4rem;
background-position: center;
background-size: cover;
margin-right: 20px;
display: flex;
align-items: center;
flex-direction: row;
justify-content: center;
}
.search-product-name {
display: flex;
flex-direction: row;
align-items: center;
text-decoration: none;
color: black;
font-weight: bold;
padding: 0;
margin: 0;
width: calc( 100% - 4rem - 20px );
}
</style>
@@ -0,0 +1,145 @@
<template>
<div style="width: 100%; height: 100%;">
<progress :id="'progress-slider-' + name" class="progress-slider" :value="sliderProgress" max="1000" @mousedown="( e ) => { setPos( e ) }"
:class="active ? '' : 'slider-inactive'"></progress>
<div v-if="active" id="slider-knob" @mousedown="( e ) => { startMove( e ) }"
:style="'left: ' + ( originalPos + sliderPos ) + 'px;'">
<div id="slider-knob-style"></div>
</div>
<div v-else id="slider-knob" class="slider-inactive" style="left: 0;">
<div id="slider-knob-style"></div>
</div>
<div id="drag-support" @mousemove="e => { handleDrag( e ) }" @mouseup="() => { stopMove(); }"></div>
</div>
</template>
<style scoped>
.progress-slider {
width: 100%;
margin: 0;
position: absolute;
left: 0;
bottom: 0;
height: 5px;
cursor: pointer;
background-color: var( --slider-color );
}
.progress-slider::-webkit-progress-value {
background-color: var( --slider-color );
}
#slider-knob {
height: 20px;
width: 10px;
display: flex;
justify-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
left: 0;
z-index: 2;
cursor: grab;
}
#slider-knob-style {
background-color: var( --slider-color );
height: 15px;
width: 5px;
}
#drag-support {
display: none;
opacity: 0;
height: 100vh;
width: 100vw;
position: fixed;
top: 0;
left: 0;
z-index: 10;
cursor: grabbing;
}
.drag-support-active {
display: block !important;
}
.slider-inactive {
cursor: default !important;
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps( {
style: {
type: Object,
},
position: {
type: Number,
default: 0,
},
duration: {
type: Number,
default: 100
},
active: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '1',
}
} );
const offset = ref( 0 );
const isDragging = ref( false );
const sliderPos = ref( 0 );
const originalPos= ref( 0 );
const sliderProgress = ref( 0 );
const handleDrag = ( e: MouseEvent ) => {
if ( isDragging.value ) {
if ( 0 < originalPos.value + e.screenX - offset.value && originalPos.value + e.screenX - offset.value < ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) {
sliderPos.value = e.screenX - offset.value;
calcProgressPos();
}
}
}
const startMove = ( e: MouseEvent ) => {
offset.value = e.screenX;
isDragging.value = true;
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.add( 'drag-support-active' );
}
const stopMove = () => {
originalPos.value += sliderPos.value;
isDragging.value = false;
offset.value = 0;
sliderPos.value = 0;
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.remove( 'drag-support-active' );
calcPlaybackPos();
}
const setPos = ( e: MouseEvent ) => {
if ( props.active ) {
originalPos.value = e.offsetX;
calcProgressPos();
calcPlaybackPos();
}
}
const calcProgressPos = () => {
sliderProgress.value = Math.ceil( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * 1000 );
}
const calcPlaybackPos = () => {
emits( 'pos', Math.round( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * props.duration ) );
}
watch( () => props.position, () => {
if ( !isDragging.value ) {
sliderProgress.value = Math.ceil( props.position / props.duration * 1000 + 2 );
originalPos.value = Math.ceil( props.position / props.duration * ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).scrollWidth - 5 ) );
}
} )
const emits = defineEmits( [ 'pos' ] );
</script>
+26
View File
@@ -0,0 +1,26 @@
import App from './App.vue';
import {
createApp
} from 'vue';
import {
createPinia
} from 'pinia';
import router from './router';
import sdk from '@janishutz/login-sdk-browser';
const app = createApp( App );
app.use( createPinia() );
app.use( router );
// localStorage.setItem( 'url', 'http://localhost:8082' );
localStorage.setItem( 'url', 'https://music-api.janishutz.com' );
sdk.setUp(
'jh-music',
String( localStorage.getItem( 'url' ) ),
'/app',
false // Set to false for deploy to actual backend
);
app.mount( '#app' );
+96
View File
@@ -0,0 +1,96 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import { useUserStore } from '@/stores/userStore';
const router = createRouter( {
history: createWebHistory( import.meta.env.BASE_URL ),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: {
'authRequired': false,
'title': 'Login'
}
},
{
path: '/app',
name: 'app',
component: () => import( '../views/AppView.vue' ),
meta: {
'authRequired': true,
'title': 'App'
}
},
{
path: '/get',
name: 'get',
component: () => import( '../views/GetView.vue' ),
meta: {
'authRequired': false,
'title': 'Get'
}
},
{
path: '/share/:name',
name: 'share',
component: () => import( '../views/RemoteView.vue' ),
meta: {
'authRequired': false,
'title': 'Share'
}
},
{
path: '/fancy/:name',
name: 'fancy',
component: () => import( '../views/ShowcaseView.vue' ),
meta: {
'authRequired': false,
'title': 'Fancy View'
}
},
{
path: '/tools/bar',
name: 'tool-bar',
component: () => import( '../views/BarView.vue' ),
meta: {
'authRequired': false,
'title': 'Bar utility'
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import( '../views/404View.vue' ),
meta: {
title: '404 :: Page not found',
transition: 'scale',
}
},
]
} );
// router.beforeResolve( ( to, _from, next ) => {
// if ( to.name ) {
// NProgress.start();
// }
// next();
// } );
router.beforeEach( ( to ) => {
const userStore = useUserStore();
const isUserAuthenticated = userStore.getUserAuthenticated;
if ( !isUserAuthenticated && to.meta.authRequired ) {
localStorage.setItem( 'redirect', to.fullPath );
return { name: 'home' };
}
} );
router.afterEach( ( to ) => {
window.scrollTo( { top: 0, behavior: 'smooth' } );
document.title = to.meta.title ? to.meta.title + ' - MusicPlayer' : 'MusicPlayer';
// NProgress.done();
} );
export default router;
+161
View File
@@ -0,0 +1,161 @@
import ColorThief from 'colorthief';
const colorThief = new ColorThief();
const getImageData = (): Promise<number[][]> => {
return new Promise( resolve => {
const img = document.getElementById( 'current-image' ) as HTMLImageElement;
if ( img.complete ) {
resolve( colorThief.getPalette( img ) );
} else {
img.addEventListener( 'load', () => {
resolve( colorThief.getPalette( img ) );
} );
}
} );
};
const createBackground = () => {
return new Promise( resolve => {
getImageData().then( palette => {
const colourDetails: number[][] = [];
const colours: string[] = [];
let differentEnough = true;
if ( palette[ 0 ] ) {
for ( const i in palette ) {
for ( const colour in colourDetails ) {
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - palette[ i ][ 0 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 1 ] - palette[ i ][ 1 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 2 ] - palette[ i ][ 2 ] ) / 255 ) / 3 * 100;
if ( colourDiff > 15 ) {
differentEnough = true;
}
}
if ( differentEnough ) {
colourDetails.push( palette[ i ] );
colours.push( 'rgb(' + palette[ i ][ 0 ] + ',' + palette[ i ][ 1 ] + ',' + palette[ i ][ 2 ] + ')' );
}
differentEnough = false;
}
}
let outColours = 'conic-gradient(';
if ( colours.length < 3 ) {
for ( let i = 0; i < 3; i++ ) {
if ( colours[ i ] ) {
outColours += colours[ i ] + ',';
} else {
if ( i === 0 ) {
outColours += 'blue,';
} else if ( i === 1 ) {
outColours += 'green,';
} else if ( i === 2 ) {
outColours += 'red,';
}
}
}
} else if ( colours.length < 11 ) {
for ( const i in colours ) {
outColours += colours[ i ] + ',';
}
} else {
for ( let i = 0; i < 10; i++ ) {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
resolve( outColours );
} );
} );
};
let callbackFun = () => {};
const subscribeToBeatUpdate = ( cb: () => void ) => {
callbackFun = cb;
micAudioHandler();
};
const unsubscribeFromBeatUpdate = () => {
callbackFun = () => {};
try {
clearInterval( micAnalyzer );
} catch ( e ) { /* empty */ }
};
const coolDown = () => {
beatDetected = false;
};
let micAnalyzer = 0;
let beatDetected = false;
const micAudioHandler = () => {
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array( bufferLength );
beatDetected = false;
navigator.mediaDevices.getUserMedia( {
'audio': true
} ).then( stream => {
const mic = audioContext.createMediaStreamSource( stream );
mic.connect( analyser );
analyser.getByteFrequencyData( dataArray );
let prevSpectrum: number[] = [];
const threshold = 10; // Adjust as needed
micAnalyzer = setInterval( () => {
analyser.getByteFrequencyData( dataArray );
// Convert the frequency data to a numeric array
const currentSpectrum = Array.from( dataArray );
if ( prevSpectrum ) {
// Calculate the spectral flux
const flux = calculateSpectralFlux( prevSpectrum, currentSpectrum );
if ( flux > threshold && !beatDetected ) {
// Beat detected
beatDetected = true;
callbackFun();
}
}
prevSpectrum = currentSpectrum;
}, 60 / 180 * 250 );
} );
};
const calculateSpectralFlux = ( prevSpectrum: number[], currentSpectrum: number[] ) => {
let flux = 0;
for ( let i = 0; i < prevSpectrum.length; i++ ) {
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
flux += Math.max( 0, diff );
}
return flux;
};
export default {
createBackground,
subscribeToBeatUpdate,
unsubscribeFromBeatUpdate,
coolDown,
};
+212
View File
@@ -0,0 +1,212 @@
// These functions handle connections to the backend with socket.io
import {
type Socket, io
} from 'socket.io-client';
import type {
SSEMap
} from './song';
class SocketConnection {
socket: Socket;
roomName: string;
isConnected: boolean;
useSocket: boolean;
eventSource?: EventSource;
toBeListenedForItems: SSEMap;
reconnectRetryCount: number;
openConnectionsCount: number;
constructor () {
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
'autoConnect': false,
} );
this.roomName = location.pathname.split( '/' )[ 2 ];
this.isConnected = false;
this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws';
this.toBeListenedForItems = {};
this.reconnectRetryCount = 0;
this.openConnectionsCount = 0;
}
/**
* Create a room token and connect to
* @returns {Promise<string>}
*/
connect (): Promise<unknown> {
return new Promise( ( resolve, reject ) => {
if ( this.reconnectRetryCount < 5 ) {
if ( this.useSocket ) {
this.socket.connect();
this.socket.emit(
'join-room', this.roomName, ( res: {
'status': boolean,
'msg': string,
'data': unknown
} ) => {
if ( res.status === true ) {
this.isConnected = true;
resolve( res.data );
} else {
console.debug( res.msg );
reject( 'ERR_ROOM_CONNECTING' );
}
}
);
} else {
if ( this.openConnectionsCount < 1 && !this.isConnected ) {
this.openConnectionsCount += 1;
fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, {
'credentials': 'include'
} ).then( res => {
if ( res.status === 200 ) {
this.eventSource
= new EventSource( localStorage.getItem( 'url' )
+ '/socket/connection?room=' + this.roomName, {
'withCredentials': true
} );
this.eventSource.onopen = () => {
this.isConnected = true;
this.reconnectRetryCount = 0;
console.log( '[ SSE Connection ] - '
+ new Date().toISOString() + ': Connection successfully established!' );
};
this.eventSource.onmessage = e => {
const d = JSON.parse( e.data );
if ( this.toBeListenedForItems[ d.type ] ) {
this.toBeListenedForItems[ d.type ]( d.data );
} else if ( d.type === 'basics' ) {
resolve( d.data );
}
};
this.eventSource.onerror = () => {
if ( this.isConnected ) {
this.isConnected = false;
this.openConnectionsCount -= 1;
this.eventSource?.close();
console.log( '[ SSE Connection ] - '
+ new Date().toISOString()
+ ': Reconnecting due to connection error!' );
// console.debug( e );
this.eventSource = undefined;
this.reconnectRetryCount += 1;
setTimeout( () => {
this.connect();
}, 1000 * this.reconnectRetryCount );
}
};
} else {
console.log( '[ SSE Connection ] - '
+ new Date().toISOString()
+ ': Could not connect due to error ' + res.status );
reject( 'ERR_ROOM_CONNECTING' );
}
} )
.catch( () => {
console.log( '[ SSE Connection ] - '
+ new Date().toISOString()
+ ': Could not connect due to error.' );
reject( 'ERR_ROOM_CONNECTING' );
} );
} else {
console.log( '[ SSE Connection ]: Trimmed connections' );
reject( 'ERR_TOO_MANY_CONNECTIONS' );
}
}
} else {
alert( 'Could not reconnect to the share. Please reload the page to retry!' );
reject( 'ERR_ROOM_CONNECTING' );
}
} );
}
/**
* Emit an event
* @param {string} event The event to emit
* @param {any} data
* @returns {void}
*/
emit ( event: string, data: unknown ): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit( event, {
'roomName': this.roomName,
'data': data
} );
} else {
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
'method': 'post',
'body': JSON.stringify( {
'event': event,
'roomName': this.roomName,
'data': data
} ),
'credentials': 'include',
'headers': {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
} ).catch( () => {} );
}
}
}
/**
* Register a listener function for an event
* @param {string} event The event to listen for
* @param {( data: any ) => void} cb The callback function / listener function
* @returns {void}
*/
registerListener ( event: string, cb: ( data: unknown ) => void ): void {
if ( this.useSocket ) {
if ( this.isConnected ) {
this.socket.on( event, cb );
}
} else {
this.toBeListenedForItems[ event ] = cb;
}
}
/**
* Disconnect from the server
* @returns {any}
*/
disconnect (): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.disconnect();
} else {
this.eventSource!.close();
}
}
}
getStatus (): boolean {
if ( this.useSocket ) {
return true;
} else {
if ( this.eventSource ) {
return this.eventSource!.OPEN && this.isConnected;
}
return false;
}
}
}
export default SocketConnection;
@@ -0,0 +1,571 @@
import type {
SearchResult, Song, SongMove
} from './song';
interface Config {
'devToken': string;
'userToken': string;
}
type ControlAction = 'play' | 'pause' | 'next' | 'previous' | 'skip-10' | 'back-10';
type RepeatMode = 'off' | 'once' | 'all';
class MusicKitJSWrapper {
playingSongID: number;
playlist: Song[];
queue: number[];
config: Config;
musicKit: any;
isLoggedIn: boolean;
isPreparedToPlay: boolean;
repeatMode: RepeatMode;
isShuffleEnabled: boolean;
hasEncounteredAuthError: boolean;
queuePos: number;
audioPlayer: HTMLAudioElement;
constructor () {
this.playingSongID = 0;
this.playlist = [];
this.queue = [];
this.config = {
'devToken': '',
'userToken': '',
};
this.isShuffleEnabled = false;
this.repeatMode = 'off';
this.isPreparedToPlay = false;
this.isLoggedIn = false;
this.hasEncounteredAuthError = false;
this.queuePos = 0;
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
const self = this;
if ( !window.MusicKit ) {
document.addEventListener( 'musickitloaded', () => {
self.init();
} );
} else {
this.init();
}
}
/**
* Log a user into Apple Music. Will automatically initialize MusicKitJS, once user is logged in
* @returns {void}
*/
logIn (): void {
if ( !this.musicKit.isAuthorized ) {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} )
.catch( () => {
this.hasEncounteredAuthError = true;
} );
} else {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} )
.catch( () => {
this.hasEncounteredAuthError = true;
} );
}
}
/**
* Initialize MusicKitJS. Should not be called. Use logIn instead, which first tries to log the user in, then calls this method.
* @returns {void}
*/
init (): void {
fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', {
'credentials': 'include'
} ).then( res => {
if ( res.status === 200 ) {
res.text().then( token => {
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
// MusicKit global is now defined
MusicKit.configure( {
'developerToken': token,
'app': {
'name': 'MusicPlayer',
'build': '3'
},
'storefrontId': 'CH',
} ).then( () => {
this.config.devToken = token;
this.musicKit = MusicKit.getInstance();
if ( this.musicKit.isAuthorized ) {
this.isLoggedIn = true;
this.config.userToken = this.musicKit.musicUserToken;
}
this.musicKit.shuffleMode = MusicKit.PlayerShuffleMode.off;
} );
} );
}
} );
}
/**
* Get the authentication status of the user
* @returns {boolean[]} Returns an array, where the first element indicates login status, the second one, if an error was encountered
*/
getAuth (): boolean[] {
return [
this.isLoggedIn,
this.hasEncounteredAuthError
];
}
/**
* Request data from the Apple Music API
* @param {string} url The URL at the Apple Music API to call (including protocol and url)
* @param {( data: object ) => void} callback A callback function that takes the data and returns nothing
* @returns {void}
*/
apiGetRequest ( url: string, callback: ( data: object ) => void ): void {
if ( this.config.devToken != '' && this.config.userToken != '' ) {
fetch( url, {
'method': 'GET',
'headers': {
'Authorization': `Bearer ${ this.config.devToken }`,
'Music-User-Token': this.config.userToken
}
} ).then( res => {
if ( res.status === 200 ) {
res.json().then( json => {
try {
callback( {
'status': 'ok',
'data': json
} );
} catch ( err ) { /* empty */ }
} );
} else {
try {
callback( {
'status': 'error',
'error': res.status
} );
} catch ( err ) { /* empty */ }
}
} );
} else return;
}
/**
* Set the playlist to play
* @param {Song[]} playlist The playlist as an array of songs
* @returns {void}
*/
setPlaylist ( playlist: Song[] ): void {
this.playlist = playlist;
this.setShuffle( this.isShuffleEnabled );
}
setPlaylistByID ( id: string ): Promise<void> {
return new Promise( ( resolve, reject ) => {
this.musicKit.setQueue( {
'playlist': id
} ).then( () => {
const pl = this.musicKit.queue.items;
const songs: Song[] = [];
for ( const item in pl ) {
let url = pl[ item ].attributes.artwork.url;
url = url.replace( '{w}', pl[ item ].attributes.artwork.width );
url = url.replace( '{h}', pl[ item ].attributes.artwork.height );
const song: Song = {
'artist': pl[ item ].attributes.artistName,
'cover': url,
'duration': pl[ item ].attributes.durationInMillis / 1000,
'id': pl[ item ].id,
'origin': 'apple-music',
'title': pl[ item ].attributes.name,
'genres': pl[ item ].attributes.genreNames
};
songs.push( song );
}
this.playlist = songs;
this.setShuffle( this.isShuffleEnabled );
this.queuePos = 0;
this.playingSongID = this.queue[ 0 ];
this.prepare( this.playingSongID );
resolve();
} )
.catch( err => {
console.error( err );
reject( err );
} );
} );
}
/**
* Prepare a specific song in the queue for playing and start playing
* @param {number} playlistID The ID of the song in the playlist to prepare to play
* @returns {boolean} Returns true, if successful, false, if playlist is missing / empty. Set that first
*/
prepare ( playlistID: number ): boolean {
if ( this.playlist.length > 0 ) {
this.playingSongID = playlistID;
this.isPreparedToPlay = true;
for ( const el in this.queue ) {
if ( this.queue[ el ] === playlistID ) {
this.queuePos = parseInt( el );
break;
}
}
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.setQueue( {
'song': this.playlist[ this.playingSongID ].id
} ).then( () => {
setTimeout( () => {
this.control( 'play' );
}, 500 );
} )
.catch( err => {
console.log( err );
} );
} else {
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
this.audioPlayer.src = this.playlist[ this.playingSongID ].id;
setTimeout( () => {
this.control( 'play' );
}, 500 );
}
return true;
} else {
return false;
}
}
/**
* Control the player
* @param {ControlAction} action Action to take on the player
* @returns {boolean} returns a boolean indicating if there was a change in song.
*/
control ( action: ControlAction ): boolean {
switch ( action ) {
case 'play':
if ( this.isPreparedToPlay ) {
this.control( 'pause' );
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.play();
return false;
} else {
this.audioPlayer.play();
return false;
}
} else {
return false;
}
case 'pause':
if ( this.isPreparedToPlay ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.pause();
return false;
} else {
this.audioPlayer.pause();
return false;
}
} else {
return false;
}
case 'back-10':
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime > 10 ? this.musicKit.currentPlaybackTime - 10 : 0 );
return false;
} else {
this.audioPlayer.currentTime = this.audioPlayer.currentTime > 10 ? this.audioPlayer.currentTime - 10 : 0;
return false;
}
case 'skip-10':
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
if ( this.musicKit.currentPlaybackTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime + 10 );
return false;
} else {
if ( this.repeatMode !== 'once' ) {
this.control( 'next' );
return true;
} else {
this.musicKit.seekToTime( 0 );
return false;
}
}
} else {
if ( this.audioPlayer.currentTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
this.audioPlayer.currentTime = this.audioPlayer.currentTime + 10;
} else {
if ( this.repeatMode !== 'once' ) {
this.control( 'next' );
} else {
this.audioPlayer.currentTime = 0;
}
}
return false;
}
case 'next':
this.control( 'pause' );
if ( this.queuePos < this.queue.length - 1 ) {
this.queuePos += 1;
this.prepare( this.queue[ this.queuePos ] );
return true;
} else {
this.queuePos = 0;
if ( this.repeatMode !== 'all' ) {
this.control( 'pause' );
} else {
this.playingSongID = this.queue[ this.queuePos ];
this.prepare( this.queue[ this.queuePos ] );
}
return true;
}
case 'previous':
this.control( 'pause' );
if ( this.queuePos > 0 ) {
this.queuePos -= 1;
this.prepare( this.queue[ this.queuePos ] );
return true;
} else {
this.queuePos = this.queue.length - 1;
return true;
}
}
}
setShuffle ( enabled: boolean ) {
this.isShuffleEnabled = enabled;
this.queue = [];
if ( enabled ) {
const d = [];
for ( const el in this.playlist ) {
d.push( parseInt( el ) );
}
this.queue = d.map( value => ( {
value,
'sort': Math.random()
} ) )
.sort( ( a, b ) => a.sort - b.sort )
.map( ( {
value
} ) => value );
this.queue.splice( this.queue.indexOf( this.playingSongID ), 1 );
this.queue.push( this.playingSongID );
this.queue.reverse();
} else {
for ( const song in this.playlist ) {
this.queue.push( parseInt( song ) );
}
}
// Find current song ID in queue
for ( const el in this.queue ) {
if ( this.queue[ el ] === this.playingSongID ) {
this.queuePos = parseInt( el );
break;
}
}
}
setRepeatMode ( mode: RepeatMode ) {
this.repeatMode = mode;
}
goToPos ( pos: number ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( pos );
} else {
this.audioPlayer.currentTime = pos;
}
}
moveSong ( move: SongMove ) {
const newQueue = [];
const finishedQueue = [];
let songID = 0;
for ( const song in this.playlist ) {
if ( this.playlist[ song ].id === move.songID ) {
songID = parseInt( song );
break;
}
}
for ( const el in this.queue ) {
if ( this.queue[ el ] !== songID ) {
newQueue.push( this.queue[ el ] );
}
}
let hasBeenAdded = false;
for ( const el in newQueue ) {
if ( parseInt( el ) === move.newPos ) {
finishedQueue.push( songID );
hasBeenAdded = true;
}
finishedQueue.push( newQueue[ el ] );
}
if ( !hasBeenAdded ) {
finishedQueue.push( songID );
}
this.queue = finishedQueue;
}
/**
* Get the current position of the play heed. Will return in ms since start of the song
* @returns {number}
*/
getPlaybackPos (): number {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
return this.musicKit.currentPlaybackTime;
} else {
return this.audioPlayer.currentTime;
}
}
/**
* Get details on the currently playing song
* @returns {Song}
*/
getPlayingSong (): Song {
return this.playlist[ this.playingSongID ];
}
/**
* Get the playlist index of the currently playing song
* @returns {number}
*/
getPlayingSongID (): number {
return this.playingSongID;
}
/**
* Get the queue index of the currently playing song
* @returns {number}
*/
getQueueID (): number {
return this.queuePos;
}
/**
* Get the full playlist, as it is set currently, not ordered by queue settings, but as passed in originally
* @returns {Song[]}
*/
getPlaylist (): Song[] {
return this.playlist;
}
/**
* Same as getPlaylist, but returns a ordered playlist, by how it will play according to the queue.
* @returns {Song[]}
*/
getQueue (): Song[] {
const data = [];
for ( const el in this.queue ) {
data.push( this.playlist[ this.queue[ el ] ] );
}
return data;
}
/**
* Get all playlists the authenticated user has on Apple Music. Only available once the user has authenticated!
* @param {( data: object ) => void} cb The callback function called with the results from the API
* @returns {boolean} Returns true, if user is authenticated and request was started, false if not.
*/
getUserPlaylists ( cb: ( data: object ) => void ): boolean {
if ( this.isLoggedIn ) {
this.apiGetRequest( 'https://api.music.apple.com/v1/me/library/playlists', cb );
return true;
} else {
return false;
}
}
getPlaying ( ): boolean {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
return this.musicKit.isPlaying;
} else {
return !this.audioPlayer.paused;
}
}
findSongOnAppleMusic ( searchTerm: string ): Promise<SearchResult> {
// TODO: Make storefront adjustable
return new Promise( ( resolve, reject ) => {
const queryParameters = {
'term': searchTerm,
'types': [ 'songs' ],
};
this.musicKit.api.music( 'v1/catalog/ch/search', queryParameters )
.then( results => {
resolve( results );
} )
.catch( e => {
console.error( e );
reject( e );
} );
} );
}
}
export default MusicKitJSWrapper;
@@ -0,0 +1,315 @@
// These functions handle connections to the backend with socket.io
import {
type Socket, io
} from 'socket.io-client';
import type {
SSEMap
} from './song';
class NotificationHandler {
socket: Socket;
roomName: string;
roomToken: string;
isConnected: boolean;
useSocket: boolean;
eventSource?: EventSource;
toBeListenedForItems: SSEMap;
reconnectRetryCount: number;
lastEmitTimestamp: number;
openConnectionsCount: number;
pendingRequestCount: number;
connectionWasSuccessful: boolean;
constructor () {
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
'autoConnect': false,
} );
this.roomName = '';
this.roomToken = '';
this.isConnected = false;
this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws';
this.toBeListenedForItems = {};
this.reconnectRetryCount = 0;
this.lastEmitTimestamp = 0;
this.pendingRequestCount = 0;
this.openConnectionsCount = 0;
this.connectionWasSuccessful = false;
}
/**
* Create a room token and connect to
* @param {string} roomName
* @param {boolean} useAntiTamper
* @returns {Promise<string>}
*/
connect ( roomName: string, useAntiTamper: boolean ): Promise<void> {
return new Promise( ( resolve, reject ) => {
fetch( localStorage.getItem( 'url' ) + '/createRoomToken?roomName='
+ roomName + '&useAntiTamper=' + useAntiTamper, {
'credentials': 'include'
} ).then( res => {
if ( res.status === 200 ) {
res.text().then( text => {
this.roomToken = text;
this.roomName = roomName;
if ( this.useSocket ) {
this.socket.connect();
this.socket.emit(
'create-room', {
'name': this.roomName,
'token': this.roomToken
}, ( res: {
'status': boolean,
'msg': string
} ) => {
if ( res.status === true ) {
this.isConnected = true;
resolve();
} else {
reject( 'ERR_ROOM_CONNECTING' );
}
}
);
} else {
this.sseConnect().then( () => {
resolve();
} )
.catch( );
}
} );
} else if ( res.status === 409 ) {
reject( 'ERR_CONFLICT' );
} else if ( res.status === 403 || res.status === 401 ) {
reject( 'ERR_UNAUTHORIZED' );
} else {
reject( 'ERR_ROOM_CREATING' );
}
} );
} );
}
sseConnect (): Promise<void> {
return new Promise( ( resolve, reject ) => {
if ( this.reconnectRetryCount < 5 ) {
if ( this.openConnectionsCount < 1 && !this.isConnected ) {
this.openConnectionsCount += 1;
fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, {
'credentials': 'include'
} ).then( res => {
if ( res.status === 200 ) {
this.eventSource = new EventSource( localStorage.getItem( 'url' )
+ '/socket/connection?room=' + this.roomName, {
'withCredentials': true
} );
this.eventSource.onopen = () => {
this.isConnected = true;
this.connectionWasSuccessful = true;
this.reconnectRetryCount = 0;
console.log( '[ SSE Connection ] - '
+ new Date().toISOString() + ': Connection successfully established!' );
resolve();
};
this.eventSource.onmessage = e => {
const d = JSON.parse( e.data );
if ( this.toBeListenedForItems[ d.type ] ) {
this.toBeListenedForItems[ d.type ]( d.data );
}
};
this.eventSource.onerror = e => {
if ( this.isConnected ) {
this.isConnected = false;
this.eventSource?.close();
this.openConnectionsCount -= 1;
console.debug( '[ SSE Connection ] - Error encountered: ', e );
console.log( '[ SSE Connection ] - '
+ new Date().toISOString()
+ ': Reconnecting due to connection error!' );
this.eventSource = undefined;
this.reconnectRetryCount += 1;
setTimeout( () => {
this.sseConnect();
}, 1000 * this.reconnectRetryCount );
}
};
} else if ( res.status === 403 || res.status === 401
|| res.status === 404 || res.status === 402 ) {
document.dispatchEvent( new Event( 'musicplayer:autherror' ) );
reject( 'ERR_UNAUTHORIZED' );
} else {
reject( 'ERR_ROOM_CONNECTING_STATUS_CODE' );
}
} )
.catch( () => {
if ( !this.connectionWasSuccessful ) {
reject( 'ERR_ROOM_CONNECTING' );
} else {
this.openConnectionsCount -= 1;
console.log( '[ SSE Connection ] - ' + new Date().toISOString()
+ ': Reconnecting due to severe connection error!' );
this.eventSource = undefined;
this.reconnectRetryCount += 1;
setTimeout( () => {
this.sseConnect();
}, 1000 * this.reconnectRetryCount );
}
} );
} else {
resolve();
}
} else {
if ( confirm( 'Connection lost and it could not be reestablished. Please click ok to retry or press cancel to stop retrying. Your share will be deleted as a result thereof.' ) ) {
this.reconnectRetryCount = 0;
this.sseConnect();
} else {
this.disconnect();
}
}
} );
}
/**
* Emit an event
* @param {string} event The event to emit
* @param {any} data
* @returns {void}
*/
emit ( event: string, data: any ): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit( event, {
'roomToken': this.roomToken,
'roomName': this.roomName,
'data': data
} );
} else {
const now = new Date().getTime();
if ( this.lastEmitTimestamp < now - 250 ) {
this.lastEmitTimestamp = now;
this.sendEmitConventionally( event, data );
} else {
this.pendingRequestCount += 1;
setTimeout( () => {
this.pendingRequestCount = 0;
this.lastEmitTimestamp = now;
this.sendEmitConventionally( event, data );
}, 250 * this.pendingRequestCount );
}
}
}
}
sendEmitConventionally ( event: string, data: any ): void {
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
'method': 'post',
'body': JSON.stringify( {
'event': event,
'roomName': this.roomName,
'roomToken': this.roomToken,
'data': data
} ),
'credentials': 'include',
'headers': {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
} ).catch( () => {} );
}
/**
* Register a listener function for an event
* @param {string} event The event to listen for
* @param {( data: any ) => void} cb The callback function / listener function
* @returns {void}
*/
registerListener ( event: string, cb: ( data: any ) => void ): void {
if ( this.useSocket ) {
if ( this.isConnected ) {
this.socket.on( event, cb );
}
} else {
this.toBeListenedForItems[ event ] = cb;
}
}
/**
* Disconnect from the server
* @returns {any}
*/
async disconnect (): Promise<void> {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit(
'delete-room', {
'name': this.roomName,
'token': this.roomToken
}, ( res: {
'status': boolean,
'msg': string
} ) => {
this.socket.disconnect();
if ( !res.status ) {
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
}
return;
}
);
} else {
fetch( localStorage.getItem( 'url' ) + '/socket/deleteRoom', {
'method': 'post',
'body': JSON.stringify( {
'roomName': this.roomName,
'roomToken': this.roomToken
} ),
'credentials': 'include',
'headers': {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
} ).then( res => {
if ( res.status === 200 ) {
this.eventSource!.close();
} else {
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
}
return;
} )
.catch( () => {
return;
} );
}
}
}
getRoomName (): string {
return this.roomName;
}
}
export default NotificationHandler;
+95
View File
@@ -0,0 +1,95 @@
export type Origin = 'apple-music' | 'disk';
export interface Song {
/**
* The ID. Either the apple music ID, or if from local disk, an ID starting in local_
*/
'id': string;
/**
* Origin of the song
*/
'origin': Origin;
/**
* The cover image as a URL
*/
'cover': string;
/**
* The artist of the song
*/
'artist': string;
/**
* The name of the song
*/
'title': string;
/**
* Duration of the song in milliseconds
*/
'duration': number;
/**
* (OPTIONAL) The genres this song belongs to. Can be displayed on the showcase screen, but requires settings there
*/
'genres'?: string[];
/**
* (OPTIONAL) This will be displayed in brackets on the showcase screens
*/
'additionalInfo'?: string;
}
export interface SongTransmitted {
'title': string;
'artist': string;
'duration': number;
'cover': string;
'additionalInfo'?: string;
}
export interface ReadFile {
'url': string;
'filename': string;
}
export interface SearchResult {
'data': {
'results': {
'songs': {
'data': AppleMusicSongData[],
'href': string;
}
};
}
}
export interface AppleMusicSongData {
'id': string,
'type': string;
'href': string;
'attributes': {
'albumName': string;
'artistName': string;
'artwork': {
'width': number,
'height': number,
'url': string
},
'name': string;
'genreNames': string[];
'durationInMillis': number;
}
}
export interface SongMove {
'songID': string;
'newPos': number;
}
export interface SSEMap {
[key: string]: ( data: any ) => void;
}
+29
View File
@@ -0,0 +1,29 @@
import {
defineStore
} from 'pinia';
// FOSS-VERSION: To enable the UI to be used with the FOSS version, change "isUserAuth" to true, you will be "logged in"
export const useUserStore = defineStore( 'user', {
'state': () => ( {
'isUserAuth': false,
'hasSubscribed': false,
'isUsingKeyboard': false,
'isFOSSVersion': false
} ),
'getters': {
'getUserAuthenticated': state => state.isUserAuth,
'getSubscriptionStatus': state => state.hasSubscribed,
},
'actions': {
setUserAuth ( auth: boolean ) {
this.isUserAuth = auth;
},
setSubscriptionStatus ( status: boolean ) {
this.hasSubscribed = status;
},
setKeyboardUsageStatus ( status: boolean ) {
this.isUsingKeyboard = status;
}
}
} );
+24
View File
@@ -0,0 +1,24 @@
<template>
<div class="home-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<h1>404</h1>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.home-view {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.logo {
height: 50vh;
}
</style>
+197
View File
@@ -0,0 +1,197 @@
<template>
<div class="app-view">
<button id="logout" @click="logout()">
<span class="material-symbols-outlined">logout</span>
</button>
<div v-if="!hasFinishedLoading" class="loading-view">
<h1>Loading...</h1>
</div>
<div v-else-if="hasFinishedLoading && isReady" class="home-view">
<libraryView
class="library-view"
:playlists="playlists"
:is-logged-in="isLoggedIntoAppleMusic"
@selected-playlist="( id ) => { selectPlaylist( id ) }"
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"
/>
</div>
<div v-else class="login-view">
<img src="@/assets/appleMusicIcon.svg" alt="Apple Music Icon">
<button class="fancy-button" style="margin-top: 20px;" @click="logIntoAppleMusic()">
Log into Apple Music
</button>
<button
class="fancy-button"
title="This allows you to use local playlists only.
Cover images for your songs will be fetched from the apple music api as good as possible"
@click="skipLogin()"
>
Continue without logging in
</button>
</div>
<playerView
ref="player"
:class="'player-view'
+ ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )"
@player-state-change="( state ) => { handlePlayerStateChange( state ) }"
/>
<!-- TODO: Call to backend to check if user has access -->
</div>
</template>
<script setup lang="ts">
import playerView from '@/components/playerView.vue';
import libraryView from '@/components/libraryView.vue';
import {
ref
} from 'vue';
import type {
ReadFile
} from '@/scripts/song';
import router from '@/router';
import {
useUserStore
} from '@/stores/userStore';
const isLoggedIntoAppleMusic = ref( false );
const isReady = ref( false );
const isShowingFullScreenPlayer = ref( false );
const player = ref( playerView );
const playlists = ref( [] );
const hasFinishedLoading = ref( false );
const userStore = useUserStore();
const handlePlayerStateChange = ( newState: string ) => {
if ( newState === 'hide' ) {
isShowingFullScreenPlayer.value = false;
} else {
isShowingFullScreenPlayer.value = true;
}
};
let loginChecker = 0;
const logIntoAppleMusic = () => {
player.value.logIntoAppleMusic();
loginChecker = setInterval( () => {
if ( player.value.getAuth()[ 0 ] ) {
isLoggedIntoAppleMusic.value = true;
isReady.value = true;
player.value.getPlaylists( data => {
playlists.value = data.data.data;
} );
clearInterval( loginChecker );
} else if ( player.value.getAuth()[ 1 ] ) {
clearInterval( loginChecker );
alert( 'An error occurred when logging you in. Please try again!' );
}
}, 500 );
};
const skipLogin = () => {
isReady.value = true;
isLoggedIntoAppleMusic.value = false;
player.value.skipLogin();
};
const selectPlaylist = ( id: string ) => {
player.value.selectPlaylist( id );
player.value.controlUI( 'show' );
};
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
player.value.selectCustomPlaylist( playlist );
player.value.controlUI( 'show' );
};
fetch( localStorage.getItem( 'url' ) + '/checkUserStatus', {
'credentials': 'include'
} ).then( res => {
if ( res.status === 200 ) {
res.text().then( text => {
if ( text === 'ok' ) {
hasFinishedLoading.value = true;
userStore.setSubscriptionStatus( true );
} else {
userStore.setSubscriptionStatus( false );
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
router.push( '/get' );
}
} );
} else if ( res.status === 402 ) {
userStore.setSubscriptionStatus( false );
router.push( '/get' );
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
} else {
console.log( res.status );
}
} );
const logout = () => {
// location.href = 'http://localhost:8080/logout?return=' + location.href;
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
};
</script>
<style scoped>
#logout {
border: none;
background: none;
position: fixed;
left: calc( 10px + 2rem );
top: 10px;
cursor: pointer;
}
#logout .material-symbols-outlined {
font-size: 1.5rem;
color: var( --primary-color );
}
.library-view {
height: calc( 90vh - 10px );
width: 100%;
}
.app-view {
height: 100%;
width: 100%;
}
.home-view {
height: 100%;
}
.login-view {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.logo {
height: 50vh;
}
.player-view {
height: 13vh;
width: calc( 100vw - 20px );
position: fixed;
bottom: 10px;
left: 10px;
background-color: var( --secondary-color );
transition: all 0.75s ease-in-out;
}
.full-screen-player {
height: 100vh;
width: 100vw;
left: 0;
bottom: 0;
}
.player-hidden {
display: none;
}
</style>
@@ -72,7 +72,7 @@
cashinInDepot = false; cashinInDepot = false;
const keys = Object.keys( offering.value[ selectedBar.value ]!.offering ); const keys = Object.keys( offering.value[ selectedBar.value ].offering );
selection.value = {}; selection.value = {};
@@ -88,10 +88,10 @@
let totalDepot = 0; let totalDepot = 0;
for ( let i = 0; i < keys.length; i++ ) { for ( let i = 0; i < keys.length; i++ ) {
const o = selection.value[ keys[ i ]! ]!; const o = selection.value[ keys[ i ] ];
totalPrice += o * offering.value[ selectedBar.value ]!.offering[ keys[ i ]! ]!.price; totalPrice += o * offering.value[ selectedBar.value ].offering[ keys[ i ] ].price;
totalDepot += o * ( offering.value[ selectedBar.value ]!.offering[ keys[ i ]! ]!.depot ?? 0 ); totalDepot += o * ( offering.value[ selectedBar.value ].offering[ keys[ i ] ].depot ?? 0 );
} }
if ( totalDepot > 0 ) { if ( totalDepot > 0 ) {
@@ -104,9 +104,9 @@
} ); } );
const changeValue = ( id: string, amount: number ) => { const changeValue = ( id: string, amount: number ) => {
selection.value[ id ]! += amount; selection.value[ id ] += amount;
if ( selection.value[ id ]! < 0 ) { if ( selection.value[ id ] < 0 ) {
selection.value[ id ] = 0; selection.value[ id ] = 0;
} }
}; };
@@ -139,7 +139,7 @@
<table v-if="Object.keys( offering ).includes( selectedBar )" class="offering-wrapper"> <table v-if="Object.keys( offering ).includes( selectedBar )" class="offering-wrapper">
<tbody> <tbody>
<tr <tr
v-for="offer in offering[ selectedBar ]!.offering" v-for="offer in offering[ selectedBar ].offering"
:key="offer.id" :key="offer.id"
:class="[ 'offering', offer.showLine ? 'show-line' : '' ]" :class="[ 'offering', offer.showLine ? 'show-line' : '' ]"
> >
+106
View File
@@ -0,0 +1,106 @@
<template>
<div>
<div class="top-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<h1>MusicPlayer</h1>
<p v-if="reasonForRedirectHere" style="color: red;">
{{ reasons[ reasonForRedirectHere ] }}
</p>
<p v-if="!reasonForRedirectHere">
<i>An Open Source, browser-based MusicPlayer with beautiful graphics</i>
</p>
<div style="margin-top: 20px;">
<a href="https://store.janishutz.com/product/com.janishutz.MusicPlayer" class="fancy-button" target="_blank">Subscribe</a>
<a
v-if="!reasonForRedirectHere"
href="/"
class="fancy-button"
style="margin-left: 10px;"
>Log in</a>
<button
v-if="reasonForRedirectHere"
href="/"
class="fancy-button"
style="margin-left: 10px;"
@click="logout()"
>
Log out
</button>
<a
href="https://github.com/simplePCBuilding/MusicPlayerV2"
class="fancy-button"
style="margin-left: 10px;"
target="_blank"
>GitHub</a>
</div>
</div>
<div>
<h2>Fully featured Music Player</h2>
<p>All the features you'd expect a Music Player to have are also present here</p>
<h2>Apple Music integration</h2>
<p>Use MusicPlayer in conjunction with Apple Music</p>
<h2>Share your playlist</h2>
<p>
You can share your playlist on a beautifully animated public page,
so that other people can join in and view your playlist
</p>
<h2>Fully browser based</h2>
<p>No installation required when using MusicPlayer on <a href="https://music.janishutz.com">music.janishutz.com</a></p>
</div>
</div>
</template>
<script setup lang="ts">
import {
ref, type Ref
} from 'vue';
interface Reasons {
[key: string]: string;
}
const reasons: Ref<Reasons> = ref( {
'notOwned': 'Please subscribe to use MusicPlayer here, or download and install it manually from GitHub!',
} );
const reasonForRedirectHere = ref( sessionStorage.getItem( 'getRedirectionReason' ) );
sessionStorage.removeItem( 'getRedirectionReason' );
const logout = () => {
// location.href = 'http://localhost:8080/logout?return=' + location.href;
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
};
</script>
<style scoped>
.logo {
height: 60vh;
max-height: 90vw;
border-radius: 20px;
}
.top-view {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.full-height {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
.promo-img {
height: 100vh;
width: 100vh;
}
</style>
+90
View File
@@ -0,0 +1,90 @@
<template>
<div class="home-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<button
:class="'fancy-button' + ( isTryingToSignIn ? ' fancy-button-inactive' : '' )"
style="margin-top: 5vh;"
title="Sign in or sign up with janishutz.com ID"
@click="login()"
>
{{ isTryingToSignIn ? 'Signing you in...' : 'Login / Sign up' }}
</button>
<p style="width: 80%;">
MusicPlayer is a browser based Music Player, that allows you to connect other devices,
simply with another web-browser, where you can see the current playlist with sleek animations.
You can log in using your Apple Music account or load a playlist from your local disk,
simply by selecting the songs using a file picker.
</p>
<router-link to="/get" class="fancy-button">
More information
</router-link>
<notificationsModule ref="notifications" location="bottomleft" size="bigger" />
</div>
</template>
<script setup lang="ts">
// TODO: Make possible to install and use without account, if using FOSS version
import {
RouterLink
} from 'vue-router';
import notificationsModule from '@/components/notificationsModule.vue';
import {
ref
} from 'vue';
import router from '@/router';
import sdk from '@janishutz/login-sdk-browser';
import {
useUserStore
} from '@/stores/userStore';
const notifications = ref( notificationsModule );
const isTryingToSignIn = ref( true );
const login = () => {
sdk.login();
};
const store = useUserStore();
if ( store.isUserAuth ) {
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
localStorage.removeItem( 'redirect' );
} else {
sdk.verifyFull()
.then( res => {
if ( res ) {
store.isUserAuth = true;
if ( localStorage.getItem( 'close-tab' ) ) {
localStorage.removeItem( 'close-tab' );
window.close();
}
localStorage.setItem( 'login-ok', 'true' );
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
localStorage.removeItem( 'redirect' );
} else {
isTryingToSignIn.value = false;
}
} )
.catch( e => {
console.debug( e );
isTryingToSignIn.value = false;
} );
}
</script>
<style scoped>
.home-view {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.logo {
height: 50vh;
border-radius: 50px;
}
</style>
+389
View File
@@ -0,0 +1,389 @@
<template>
<div>
<div class="info">
Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a>
</div>
<div class="remote-view">
<div v-if="hasLoaded && !showCouldNotFindRoom" style="width: 100%">
<div class="current-song-wrapper">
<img
v-if="playlist[ playingSong ] && playlist[ playingSong ].cover"
id="current-image"
:src="playlist[ playingSong ].cover"
class="fancy-view-song-art"
crossorigin="anonymous"
>
<span v-else class="material-symbols-outlined fancy-view-song-art">music_note</span>
<div class="current-song">
<h1 style="margin-bottom: 5px;">
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
</h1>
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
<p
v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false"
class="additional-info"
>
{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}
</p>
<progress id="progress" max="1000" :value="progressBar"></progress>
</div>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" :key="song.id" class="song-list">
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
<div class="time-until">
{{ getTimeUntil( song.id ) }}
</div>
</div>
<!-- <img :src="" alt=""> -->
</div>
</div>
<div v-else-if="!hasLoaded && !showCouldNotFindRoom">
<h1>Loading...</h1>
</div>
<div v-else style="max-width: 80%;">
<span class="material-symbols-outlined" style="font-size: 4rem;">wifi_off</span>
<h1>Couldn't connect!</h1>
<p>
There does not appear to be a share with the specified name, or an error occurred when connecting.
</p>
<p>You may <a href="">reload</a> the page to try again!</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SocketConnection from '@/scripts/connection';
import type {
Song
} from '@/scripts/song';
import {
computed, ref, type Ref
} from 'vue';
const isPlaying = ref( false );
const playlist: Ref<Song[]> = ref( [] );
const pos = ref( 0 );
const playingSong = ref( 0 );
const progressBar = ref( 0 );
const hasLoaded = ref( false );
const showCouldNotFindRoom = ref( false );
const playbackStart = ref( 0 );
let timeTracker = 0;
const conn = new SocketConnection();
conn.connect().then( d => {
playlist.value = d.playlist;
isPlaying.value = d.playbackStatus;
playingSong.value = d.playlistIndex;
playbackStart.value = d.playbackStart;
if ( isPlaying.value ) {
startTimeTracker();
}
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
progressBar.value = ( pos.value / ( playlist.value[ playingSong.value ]
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
hasLoaded.value = true;
conn.registerListener( 'playlist', data => {
playlist.value = data;
} );
conn.registerListener( 'playback', data => {
isPlaying.value = data;
if ( isPlaying.value ) {
startTimeTracker();
} else {
stopTimeTracker();
}
} );
conn.registerListener( 'playback-start', data => {
playbackStart.value = data;
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
} );
conn.registerListener( 'playlist-index', data => {
playingSong.value = parseInt( data );
} );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
conn.registerListener( 'delete-share', _ => {
alert( `This share was just deleted. It is no longer available.
The page will reload automatically to try and re-establish connection!` );
conn.disconnect();
location.reload();
} );
} )
.catch( e => {
console.error( e );
showCouldNotFindRoom.value = true;
} );
const songQueue = computed( () => {
let ret: Song[] = [];
let pos = 0;
for ( let song in playlist.value ) {
if ( pos >= playingSong.value ) {
ret.push( playlist.value[ song ] );
}
pos += 1;
}
return ret;
} );
const getTimeUntil = computed( () => {
return ( song: string ) => {
let timeRemaining = 0;
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
if ( playlist.value[ i ].id == song ) {
break;
}
timeRemaining += playlist.value[ i ].duration;
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than '
+ Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min after starting to play';
}
}
};
} );
const startTimeTracker = () => {
try {
clearInterval( timeTracker );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch ( err ) { /* empty */ }
timeTracker = setInterval( () => {
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
if ( isNaN( progressBar.value ) ) {
progressBar.value = 0;
}
if ( playlist.value[ playingSong.value ].duration + 10 - pos.value < 0 ) {
stopTimeTracker();
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
location.reload();
}
}, 100 );
};
const stopTimeTracker = () => {
clearInterval( timeTracker );
};
document.addEventListener( 'visibilitychange', () => {
if ( !document.hidden ) {
if ( !conn.getStatus ) {
stopTimeTracker();
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
location.reload();
}
}
} );
</script>
<style>
#themeSelector {
display: none;
}
</style>
<style scoped>
.remote-view {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: justify;
background-color: rgb(2, 16, 61);
color: white;
min-height: 100vh;
}
.loaded {
display: block;
}
.loading {
display: flex;
height: 100vh;
}
.playing-symbols {
position: absolute;
left: 10vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 5vw;
height: 5vw;
background-color: rgba( 0, 0, 0, 0.6 );
}
.playing-symbols-wrapper {
width: 4vw;
height: 5vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.song-list-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 5%;
}
.song-list {
display: flex;
flex-direction: row;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
border: 1px white solid;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.song-details-wrapper {
margin: 0;
display: block;
margin-left: 10px;
margin-right: auto;
width: 65%;
text-align: left;
}
.pause-icon {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw !important;
user-select: none;
}
.current-song-wrapper {
display: flex;
align-items: center;
flex-direction: column;
width: 100%;
margin-bottom: 2%;
margin-top: 1%;
}
.current-song {
display: flex;
align-items: center;
flex-direction: column;
margin-top: 1vh;
padding: 1vh;
max-width: 80%;
text-align: center;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 10px;
font-size: 30vh !important;
border-radius: 30px;
}
#app {
background-color: rgba( 0, 0, 0, 0 );
}
#progress, #progress::-webkit-progress-bar {
background-color: rgb(82, 82, 82);
color: rgb(82, 82, 82);
width: 30vw;
height: 10px;
border: none;
border-radius: 0px;
accent-color: white;
-webkit-appearance: none;
appearance: none;
border-radius: 10px;
margin-bottom: 5px;
}
#progress::-moz-progress-bar {
background-color: white;
}
#progress::-webkit-progress-value {
background-color: white !important;
}
.mode-selector-wrapper {
opacity: 0;
position: fixed;
right: 0.5%;
top: 0.5%;
padding: 0.5%;
}
.mode-selector-wrapper:hover {
opacity: 1;
}
.additional-info {
font-size: 1.2rem;
margin: 0;
font-weight: bolder;
}
.info {
position: fixed;
font-size: 12px;
transform: rotate(270deg);
left: -150px;
margin: 0;
padding: 0;
top: 50%;
color: white;
}
.time-until {
width: 30%;
text-align: end;
}
</style>
@@ -0,0 +1,595 @@
<template>
<div>
<span
v-if="isAntiTamperEnabled"
class="anti-tamper material-symbols-outlined"
@click="secureModeInfo( 'toggle' )"
>lock</span>
<div
v-if="isShowingSecureModeInfo && isAntiTamperEnabled"
class="anti-tamper-info"
@click="secureModeInfo( 'hide' )"
>
Anti-Tamper is enabled. Leaving this window will cause a notification to be dispatched to the player!
</div>
<div class="info">
Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a>
</div>
<div class="remote-view">
<div v-if="hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
<div class="current-song-wrapper">
<img
v-if="playlist[ playingSong ] && playlist[ playingSong ].cover"
id="current-image"
:src="playlist[ playingSong ].cover"
class="fancy-view-song-art"
crossorigin="anonymous"
>
<span v-else class="material-symbols-outlined fancy-view-song-art">music_note</span>
<div class="current-song">
<h1 style="margin-bottom: 5px;">
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
</h1>
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
<p
v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false"
class="additional-info"
>
{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}
</p>
<progress id="progress" max="1000" :value="progressBar"></progress>
</div>
</div>
<div class="mode-selector-wrapper">
<select v-model="visualizationSettings" @change="handleAnimationChange()">
<option value="mic">
Microphone (Mic access required)
</option>
<option value="off">
No visualization except background
</option>
</select>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" :key="song.id" class="song-list">
<img v-if="song.cover" :src="song.cover" class="song-image">
<span v-else class="material-symbols-outlined song-cover">music_note</span>
<div
v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying"
class="playing-symbols"
>
<div class="playing-symbols-wrapper">
<div id="bar-1" class="playing-bar"></div>
<div id="bar-2" class="playing-bar"></div>
<div id="bar-3" class="playing-bar"></div>
</div>
</div>
<div class="song-details-wrapper">
<h3>{{ song.title }}</h3>
<p>{{ song.artist }}</p>
</div>
<div class="time-until">
{{ getTimeUntil( song.id ) }}
</div>
</div>
<!-- <img :src="" alt=""> -->
</div>
</div>
<div v-else-if="!hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
<h1>Loading...</h1>
</div>
<div v-else class="showcase-wrapper">
<h1>Couldn't connect!</h1>
<p>
There does not appear to be a share with the specified name, or an error occurred when connecting.
</p>
<p>You may reload the page to try again!</p>
</div>
<div id="background" class="background">
<div class="beat-manual"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import SocketConnection from '@/scripts/connection';
import type {
Song
} from '@/scripts/song';
import {
type Ref, computed, ref
} from 'vue';
import bizualizer from '@/scripts/bizualizer';
const isPlaying = ref( false );
const playlist: Ref<Song[]> = ref( [] );
const pos = ref( 0 );
const playingSong = ref( 0 );
const progressBar = ref( 0 );
const hasLoaded = ref( false );
const showCouldNotFindRoom = ref( false );
const playbackStart = ref( 0 );
let timeTracker = 0;
const visualizationSettings = ref( 'mic' );
const isAntiTamperEnabled = ref( false );
const conn = new SocketConnection();
conn.connect().then( d => {
playlist.value = d.playlist;
isPlaying.value = d.playbackStatus;
playingSong.value = d.playlistIndex;
playbackStart.value = d.playbackStart;
if ( isPlaying.value ) {
startTimeTracker();
}
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
progressBar.value
= ( pos.value / ( playlist.value[ playingSong.value ]
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
hasLoaded.value = true;
if ( d.useAntiTamper ) {
isAntiTamperEnabled.value = true;
notifier();
}
conn.registerListener( 'playlist', data => {
playlist.value = data;
} );
conn.registerListener( 'playback', data => {
isPlaying.value = data;
if ( isPlaying.value ) {
startTimeTracker();
} else {
stopTimeTracker();
}
} );
conn.registerListener( 'playback-start', data => {
playbackStart.value = data;
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
} );
conn.registerListener( 'playlist-index', data => {
playingSong.value = parseInt( data );
setTimeout( () => {
setBackground();
}, 1000 );
} );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
conn.registerListener( 'delete-share', _ => {
alert( 'This share was just deleted. It is no longer available. This page will reload automatically!' );
conn.disconnect();
location.reload();
} );
} )
.catch( e => {
console.error( e );
showCouldNotFindRoom.value = true;
} );
const songQueue = computed( () => {
let ret: Song[] = [];
let pos = 0;
for ( let song in playlist.value ) {
if ( pos >= playingSong.value ) {
ret.push( playlist.value[ song ] );
}
pos += 1;
}
return ret;
} );
const getTimeUntil = computed( () => {
return ( song: string ) => {
let timeRemaining = 0;
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
if ( playlist.value[ i ].id == song ) {
break;
}
timeRemaining += playlist.value[ i ].duration;
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min';
}
} else {
if ( timeRemaining === 0 ) {
return 'Plays next';
} else {
return 'Playing less than '
+ Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min after starting to play';
}
}
};
} );
const startTimeTracker = () => {
try {
clearInterval( timeTracker );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch ( err ) { /* empty */ }
setTimeout( () => {
handleAnimationChange();
setBackground();
}, 1000 );
timeTracker = setInterval( () => {
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
if ( isNaN( progressBar.value ) ) {
progressBar.value = 0;
}
}, 100 );
};
const stopTimeTracker = () => {
clearInterval( timeTracker );
handleAnimationChange();
};
const animateBeat = () => {
$( '.beat-manual' ).stop();
const duration = Math.ceil( 60 / 180 * 500 ) - 50;
$( '.beat-manual' ).fadeIn( 50 );
setTimeout( () => {
$( '.beat-manual' ).fadeOut( duration );
setTimeout( () => {
bizualizer.coolDown();
$( '.beat-manual' ).stop();
}, duration );
}, 50 );
};
const handleAnimationChange = () => {
if ( visualizationSettings.value === 'mic' && isPlaying.value ) {
bizualizer.subscribeToBeatUpdate( animateBeat );
} else {
bizualizer.unsubscribeFromBeatUpdate();
}
};
const setBackground = () => {
bizualizer.createBackground().then( bg => {
$( '#background' ).css( 'background', bg );
} );
};
const notifier = () => {
Notification.requestPermission();
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
// Detect if window is currently in focus
window.onblur = () => {
sendNotification();
};
// Detect if browser window becomes hidden (also with blur event)
document.onvisibilitychange = () => {
if ( document.visibilityState === 'hidden' ) {
sendNotification();
}
};
};
const sendNotification = () => {
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
'body': 'Please return to the original webpage immediately!',
'requireInteraction': true,
} );
conn.emit( 'tampering', '' );
};
const isShowingSecureModeInfo = ref( false );
const secureModeInfo = ( action: string ) => {
if ( action === 'toggle' ) {
isShowingSecureModeInfo.value = !isShowingSecureModeInfo.value;
} else if ( action === 'show' ) {
isShowingSecureModeInfo.value = true;
} else {
isShowingSecureModeInfo.value = false;
}
};
</script>
<style scoped>
.anti-tamper {
position: fixed;
z-index: 10;
bottom: 5px;
right: 5px;
font-size: 2rem;
cursor: default;
}
.anti-tamper-info {
position: fixed;
z-index: 10;
bottom: calc( 10px + 2rem );
right: 5px;
max-width: 20rem;
background-color: black;
color: white;
padding: 5px;
}
.remote-view {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: left;
color: white;
}
.showcase-wrapper {
width: 100%;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.playing-symbols {
position: absolute;
left: 10vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 5vw;
height: 5vw;
background-color: rgba( 0, 0, 0, 0.6 );
border-radius: 10px;
}
.playing-symbols-wrapper {
width: 4vw;
height: 5vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.playing-bar {
height: 60%;
background-color: white;
width: 10%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY( 1 );
}
50% {
transform: scaleY( 0.5 );
}
100% {
transform: scaleY( 1 );
}
}
.song-list-wrapper {
border-radius: 10px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.song-list {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
border: 1px white solid;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.song-details-wrapper {
margin: 0;
display: block;
margin-left: 10px;
margin-right: auto;
text-align: left;
}
.song-list .song-image {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw;
border-radius: 10px;
}
.pause-icon {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw !important;
user-select: none;
}
.current-song-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 55vh;
width: 100%;
margin-bottom: 0.5%;
margin-top: 0.25%;
}
.current-song {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-top: 1vh;
padding: 1vh;
text-align: center;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 10px;
font-size: 30vh !important;
border-radius: 30px;
}
#app {
background-color: rgba( 0, 0, 0, 0 );
}
#progress, #progress::-webkit-progress-bar {
background-color: rgb(82, 82, 82);
color: rgb(82, 82, 82);
width: 30vw;
height: 10px;
border: none;
border-radius: 0px;
accent-color: white;
-webkit-appearance: none;
appearance: none;
border-radius: 10px;
margin-bottom: 5px;
}
#progress::-moz-progress-bar {
background-color: white;
}
#progress::-webkit-progress-value {
background-color: white !important;
}
.mode-selector-wrapper {
opacity: 0;
position: fixed;
right: 0.5%;
top: 0.5%;
padding: 0.5%;
}
.mode-selector-wrapper:hover {
opacity: 1;
}
.dancing-style {
font-size: 250%;
margin: 0;
font-weight: bolder;
}
.info {
position: fixed;
font-size: 12px;
transform: rotate(270deg);
left: -150px;
margin: 0;
padding: 0;
top: 50%;
z-index: 100;
color: white;
}
</style>
<style scoped>
.background {
position: fixed;
left: -50vw;
width: 200vw;
height: 200vw;
top: -50vw;
z-index: 1;
filter: blur(10px);
background: conic-gradient( blue, green, red, blue );
animation: gradientAnim 10s infinite linear;
background-position: center;
}
.beat, .beat-manual {
height: 100%;
width: 100%;
background-color: rgba( 0, 0, 0, 0.1 );
display: none;
}
.beat {
animation: beatAnim 0.6s infinite linear;
}
@keyframes beatAnim {
0% {
background-color: rgba( 0, 0, 0, 0.2 );
}
20% {
background-color: rgba( 0, 0, 0, 0 );
}
100% {
background-color: rgba( 0, 0, 0, 0.2 );
}
}
@keyframes gradientAnim {
from {
transform: rotate( 0deg );
}
to {
transform: rotate( 360deg );
}
}
</style>
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "public/musickit.js"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+18
View File
@@ -0,0 +1,18 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node", "jquery"]
}
}
+21
View File
@@ -0,0 +1,21 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
nodePolyfills(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 8081
}
})
+3 -8
View File
@@ -21,18 +21,13 @@
<img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version"> <img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version">
</div> </div>
# Development Branch
This branch is for the upcoming version 4 of Music Player and is in early stages of development
It uses my manually created [types for MusicKitJS](https://github.com/janishutz/musickit-v3-types)
# MusicPlayer
A music player, specifically created for displaying song information on multiple different displays that are connected to the same network, just from the browser. 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 is subscription-based and can be paid for on my [store](https://store.janishutz.com/product/com.janishutz). 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).
## Version 4
A completely new version of MusicPlayer is currently [in development](https://github.com/janishutz/MusicPlayer/tree/dev) and will be ready by November 2026.
<div id="donate" align="center"> <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> <a href="https://store.janishutz.com/donate" target="_blank"><img src="https://store-cdn.janishutz.com/static/support-me.jpg" width="150px"></a>
</div> </div>
+29 -71
View File
@@ -1,15 +1,15 @@
import vue from 'eslint-plugin-vue';
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import globals from 'globals'; import globals from 'globals';
import typescript from '@typescript-eslint/eslint-plugin';
import stylistic from '@stylistic/eslint-plugin'; import stylistic from '@stylistic/eslint-plugin';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import typescript from '@typescript-eslint/eslint-plugin';
import vue from 'eslint-plugin-vue';
const style = { const style = {
'plugins': { 'plugins': {
'@stylistic': stylistic, '@stylistic': stylistic,
'@stylistic/js': stylistic, '@stylistic/js': stylistic,
'@stylistic/ts': stylistic '@stylistic/ts': stylistic,
}, },
'files': [ 'files': [
'**/*.ts', '**/*.ts',
@@ -20,26 +20,11 @@ const style = {
'**/*.jsx' '**/*.jsx'
], ],
'rules': { 'rules': {
'sort-imports': [
'warn',
{
'ignoreCase': false,
'ignoreDeclarationSort': false,
'ignoreMemberSort': false,
'memberSyntaxSortOrder': [
'none',
'all',
'multiple',
'single'
],
'allowSeparatedGroups': false
}
],
// Formatting // Formatting
'@stylistic/array-bracket-newline': [ '@stylistic/array-bracket-newline': [
'error', 'error',
{ {
'multiline': false, 'multiline': true,
'minItems': 2 'minItems': 2
} }
], ],
@@ -50,8 +35,7 @@ const style = {
'@stylistic/array-element-newline': [ '@stylistic/array-element-newline': [
'error', 'error',
{ {
'consistent': false, 'multiline': true,
'multiline': false,
'minItems': 2 'minItems': 2
} }
], ],
@@ -72,14 +56,7 @@ const style = {
], ],
'@stylistic/brace-style': [ '@stylistic/brace-style': [
'error', 'error',
'1tbs', '1tbs'
{
'allowSingleLine': false
}
],
'@stylistic/comma-dangle': [
'error',
'never'
], ],
'@stylistic/comma-spacing': [ '@stylistic/comma-spacing': [
'error', 'error',
@@ -96,9 +73,9 @@ const style = {
'error', 'error',
'property' 'property'
], ],
'@stylistic/function-call-argument-newline': [ '@stylistic/eol-last': [
'error', 'error',
'consistent' 'always'
], ],
'@stylistic/function-call-spacing': [ '@stylistic/function-call-spacing': [
'error', 'error',
@@ -106,7 +83,11 @@ const style = {
], ],
'@stylistic/function-paren-newline': [ '@stylistic/function-paren-newline': [
'error', 'error',
'multiline-arguments' 'multiline'
],
'@stylistic/function-call-argument-newline': [
'error',
'consistent'
], ],
'@stylistic/implicit-arrow-linebreak': [ '@stylistic/implicit-arrow-linebreak': [
'error', 'error',
@@ -116,10 +97,6 @@ const style = {
'error', 'error',
4 4
], ],
'@stylistic/indent-binary-ops': [
'error',
4
],
'@stylistic/key-spacing': [ '@stylistic/key-spacing': [
'error', 'error',
{ {
@@ -141,31 +118,18 @@ const style = {
'@stylistic/max-len': [ '@stylistic/max-len': [
'warn', 'warn',
{ {
'code': 140, 'code': 90,
'comments': 160, 'comments': 100,
'ignoreComments': false, 'ignoreComments': false,
'ignoreUrls': true, 'ignoreUrls': true,
'ignoreStrings': true, 'ignoreStrings': false
'ignoreTemplateLiterals': true,
'ignoreRegExpLiterals': true
} }
], ],
'@stylistic/max-statements-per-line': [
'error',
{
'max': 1
}
],
'@stylistic/multiline-ternary': [
'error',
'always-multiline'
],
'@stylistic/new-parens': [ '@stylistic/new-parens': [
'error', 'error',
'always' 'always'
], ],
'@stylistic/newline-per-chained-call': 'error', '@stylistic/newline-per-chained-call': [ 'error' ],
'@stylistic/no-confusing-arrow': 'error',
'@stylistic/no-extra-parens': [ '@stylistic/no-extra-parens': [
'error', 'error',
'all', 'all',
@@ -203,17 +167,17 @@ const style = {
'always' 'always'
], ],
'@stylistic/object-property-newline': 'error', '@stylistic/object-property-newline': 'error',
'@stylistic/one-var-declaration-per-line': 'error',
'@stylistic/operator-linebreak': [ '@stylistic/operator-linebreak': [
'error', 'error',
'before' 'before'
], ],
'@stylistic/one-var-declaration-per-line': 'error',
'@stylistic/padded-blocks': [ '@stylistic/padded-blocks': [
'error', 'error',
{ {
'blocks': 'never', 'blocks': 'never',
'classes': 'always', 'classes': 'always',
'switches': 'never' 'switches': 'never',
} }
], ],
@@ -597,7 +561,7 @@ const style = {
'blankLine': 'always', 'blankLine': 'always',
'prev': 'class', 'prev': 'class',
'next': '*' 'next': '*'
} },
], ],
'@stylistic/quote-props': [ '@stylistic/quote-props': [
'error', 'error',
@@ -649,23 +613,17 @@ const style = {
'error', 'error',
'always' 'always'
], ],
'@stylistic/switch-colon-spacing': 'error',
'@stylistic/template-curly-spacing': [ '@stylistic/template-curly-spacing': [
'error', 'error',
'always' 'always'
], ],
'@stylistic/template-tag-spacing': [ '@stylistic/switch-colon-spacing': 'error',
'error',
'always'
],
'@stylistic/type-generic-spacing': 'error',
'@stylistic/type-named-tuple-spacing': 'error',
'@stylistic/wrap-iife': [ '@stylistic/wrap-iife': [
'error', 'error',
'inside' 'inside'
], ],
'@stylistic/wrap-regex': 'error', '@stylistic/wrap-regex': 'error',
'@stylistic/ts/type-annotation-spacing': 'error' '@stylistic/ts/type-annotation-spacing': 'error',
} }
}; };
@@ -684,15 +642,15 @@ export default tseslint.config(
'ecmaVersion': 'latest', 'ecmaVersion': 'latest',
'globals': globals.browser, 'globals': globals.browser,
'parserOptions': { 'parserOptions': {
'parser': tseslint.parser 'parser': tseslint.parser,
} },
}, },
'plugins': { 'plugins': {
'vue': vue, 'vue': vue,
'@stylistic': stylistic, '@stylistic': stylistic,
'@stylistic/js': stylistic, '@stylistic/js': stylistic,
'@stylistic/ts': stylistic, '@stylistic/ts': stylistic,
'@typescript-eslint': typescript '@typescript-eslint': typescript,
}, },
'extends': [ 'extends': [
eslint.configs.recommended, eslint.configs.recommended,
@@ -736,9 +694,9 @@ export default tseslint.config(
'error', 'error',
{ {
'singleline': 3, 'singleline': 3,
'multiline': 1 'multiline': 1,
} }
] ],
} },
} },
); );
+41 -17
View File
@@ -1,19 +1,43 @@
{ {
"dependencies": { "name": "musicplayer-v2-backend",
"express": "^5.2.1", "version": "1.0.0",
"express-session": "^1.19.0", "description": "The backend for MusicPlayerV2",
"jsonwebtoken": "^9.0.3" "main": "index.js",
}, "scripts": {
"devDependencies": { "test": "echo \"Error: no test specified\" && exit 1"
"eslint": "^10.9.0", },
"@types/express": "^5.0.6", "repository": {
"@types/express-session": "^1.19.0", "type": "git",
"@types/node": "^26.2.0", "url": "git+https://github.com/simplePCBuilding/MusicPlayerV2.git"
"@eslint/js": "^10.0.1", },
"@stylistic/eslint-plugin": "^5.10.0", "author": "Janis Hutz",
"@types/jsonwebtoken": "^9.0.10", "license": "GPL-3.0-or-later",
"eslint-plugin-vue": "^10.10.0", "bugs": {
"globals": "^17.11.0", "url": "https://github.com/simplePCBuilding/MusicPlayerV2/issues"
"typescript-eslint": "^8.67.0" },
} "homepage": "https://github.com/simplePCBuilding/MusicPlayerV2#readme",
"devDependencies": {
"@eslint/js": "^9.29.0",
"@stylistic/eslint-plugin": "^5.0.0",
"@types/express-session": "^1.18.0",
"eslint-plugin-vue": "^10.2.0",
"typescript": "^5.4.5",
"typescript-eslint": "^8.35.0"
},
"dependencies": {
"@janishutz/login-sdk-server": "^1.2.0",
"@janishutz/login-sdk-server-stubs": "^1.0.0",
"@janishutz/store-sdk": "^1.1.0",
"@types/body-parser": "^1.19.5",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.6",
"body-parser": "^1.20.2",
"cors": "^2.8.5",
"express": "^4.19.2",
"express-session": "^1.18.0",
"jsonwebtoken": "^9.0.2",
"node-mysql": "^0.4.2",
"socket.io": "^4.7.5"
}
} }
+253 -34
View File
@@ -1,58 +1,277 @@
import express from 'express'; import express from 'express';
import expressSession from 'express-session'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import path from 'path'; import cors from 'cors';
import {
createServer
} from 'node:http';
import crypto from 'node:crypto';
import {
SocketData
} from './definitions';
// ┌ ┐
// │ Handle FOSS vs paid version │
// └ ┘
const isFossVersion = false;
import storeSDK from '@janishutz/store-sdk';
import sdk from '@janishutz/login-sdk-server';
import sse from './sse';
import socket from './socket';
import logger from './logger';
// const isFossVersion = true;
//
// import storeSDK from './sdk/store-sdk-stub';
// import sdk from '@janishutz/login-sdk-server-stubs';
const corsOpts: cors.CorsOptions = {
'credentials': true,
'origin': ( origin, cb ) => {
if ( isFossVersion ) cb( null, true );
else cb( null, origin === 'https://music.janishutz.com' );
}
};
const run = () => { const run = () => {
const app = express(); const app = express();
const httpServer = createServer( app );
app.use( expressSession( { if ( !isFossVersion ) {
'secret': 'dev', logger.info( '[ APP ] Starting in non-FOSS version' );
'resave': true,
'saveUninitialized': false const storeConfig = JSON.parse( fs.readFileSync( path.join(
} ) ); __dirname,
'/config/store-sdk.config.secret.json'
) ).toString() );
storeSDK.configure( storeConfig );
// ───────────────────────────────────────────────────────────────────
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/config/sdk.config.secret.json'
) ).toString() );
// Load id.janishutz.com SDK and allow signing in
sdk.setUp(
{
'prod': false,
'service': {
'serviceID': 'jh-music',
'serviceToken': sdkConfig[ 'token' ]
},
'user-agent': sdkConfig[ 'ua' ],
'sessionType': 'memory',
'frontendURL': 'https://music.janishutz.com',
'corsWhitelist': [ 'https://music.janishutz.com' ],
'recheckTimeout': 300 * 1000,
'advancedVerification': 'sdk',
},
app,
() => {
return new Promise( resolve => {
resolve( true );
} );
},
() => {
return new Promise( resolve => {
resolve( true );
} );
},
() => {
return new Promise( resolve => {
resolve( true );
} );
},
() => {
return new Promise( resolve => {
resolve( true );
} );
},
);
}
app.get( '/', ( request: express.Request, response: express.Response ) => { /*
response.send( 'HELLO WORLD' ); Configuration of SSE or WebSocket
*/
const socketData: SocketData = {};
sse.useSSE( app, socketData, corsOpts, sdk.getSessionID, sdk.getSignedIn );
socket.useWebSocket( httpServer, socketData );
/*
GENERAL ROUTES
*/
app.get( '/', ( _request: express.Request, response: express.Response ) => {
response.send( 'Please visit <a href="https://music.janishutz.com">https://music.janishutz.com</a> to use this service' );
} ); } );
const privateKey = fs.readFileSync( path.join( app.get(
__dirname, '/createRoomToken',
'/config/apple_private_key.p8' cors( corsOpts ),
) ).toString(); sdk.loginCheck(),
const musicApiConfig = JSON.parse( fs.readFileSync( path.join( ( request: express.Request, response: express.Response ) => {
__dirname, // eslint-disable-next-line no-constant-binary-expression
'/config/apple-music-api.config.secret.json' const roomName = String( request.query.roomName ) ?? '';
) ).toString() );
app.get( '/dev-token', ( _request: express.Request, response: express.Response ) => { if ( !socketData[ roomName ] ) {
// sign dev token const roomToken = crypto.randomUUID();
const now = new Date().getTime();
const tomorrow = now + ( 24 * 3600 * 1000 ); socketData[ roomName ] = {
const jwtToken = jwt.sign( { 'playbackStart': 0,
'iss': musicApiConfig.teamID, 'playbackStatus': false,
'iat': Math.floor( now / 1000 ), 'playlist': [],
'exp': Math.floor( tomorrow / 1000 ) 'playlistIndex': 0,
}, privateKey, { 'roomName': roomName,
'algorithm': 'ES256', 'roomToken': roomToken,
'keyid': musicApiConfig.keyID 'ownerUID': sdk.getUID( request ),
'useAntiTamper': request.query.useAntiTamper === 'true'
? true : false,
};
logger.debug( `Created room "${ roomName }"` );
response.send( roomToken );
} else {
if (
socketData[ roomName ].ownerUID
=== sdk.getUID( request )
) {
response.send( socketData[ roomName ].roomToken );
} else {
response.status( 409 ).send( 'ERR_CONFLICT' );
}
}
}
);
app.get(
'/getAppleMusicDevToken',
cors( corsOpts ),
sdk.loginCheck(),
( req, res ) => {
checkIfOwned( req )
.then( owned => {
if ( owned ) {
// sign dev token
const privateKey = fs.readFileSync( path.join(
__dirname,
'/config/apple_private_key.p8'
) ).toString();
const config = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/config/apple-music-api.config.secret.json'
) ).toString() );
const now = new Date().getTime();
const tomorrow = now + ( 24 * 3600 * 1000 );
const jwtToken = jwt.sign( {
'iss': config.teamID,
'iat': Math.floor( now / 1000 ),
'exp': Math.floor( tomorrow / 1000 ),
}, privateKey, {
'algorithm': 'ES256',
'keyid': config.keyID
} );
res.send( jwtToken );
} else {
res.status( 402 ).send( 'ERR_NOT_OWNED' );
}
} )
.catch( e => {
if ( e === 'ERR_NOT_OWNED' ) {
res.status( 402 ).send( e );
} else if ( e === 'ERR_AUTH_REQUIRED' ) {
res.status( 401 ).send( e );
} else {
res.send( 500 ).send( e );
}
} );
}
);
const ownedCache = {};
const checkIfOwned = ( request: express.Request ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
const uid = sdk.getUID( request );
if ( ownedCache[ uid ] ) {
resolve( ownedCache[ uid ] );
} else {
storeSDK.getSubscriptions( uid )
.then( stat => {
logger.log( 'Subscription check was successful' );
const now = new Date().getTime();
for ( const sub in stat ) {
if ( stat[ sub ].expires - now > 0
&& (
stat[ sub ].id
=== 'com.janishutz.MusicPlayer.subscription'
|| stat[ sub ].id
=== 'com.janishutz.MusicPlayer.subscription-month'
)
) {
ownedCache[ uid ] = true;
resolve( true );
}
}
ownedCache[ uid ] = false;
resolve( false );
} )
.catch( e => {
logger.error( 'Subscription check unsuccessful with error', e );
reject( 'ERR_NOT_OWNED' );
} );
}
} ); } );
};
response.send( jwtToken ); app.get(
} ); '/checkUserStatus',
cors( corsOpts ),
sdk.loginCheck(),
( request: express.Request, response: express.Response ) => {
checkIfOwned( request )
.then( owned => {
if ( owned ) {
response.send( 'ok' );
} else {
response.status( 402 ).send( 'ERR_NOT_OWNED' );
}
} )
.catch( e => {
if ( e === 'ERR_NOT_OWNED' ) {
response.status( 402 ).send( e );
} else if ( e === 'ERR_AUTH_REQUIRED' ) {
response.status( 401 ).send( e );
} else {
response.send( 500 ).send( e );
}
} );
}
);
app.use( ( _request: express.Request, response: express.Response ) => { app.use( ( request: express.Request, response: express.Response ) => {
response.sendFile( path.join( __dirname, '' ) ); response.status( 404 ).send( 'ERR_NOT_FOUND: ' + request.path );
} ); } );
const PORT = process.env.PORT || 8080; const PORT = process.env.PORT || 8082;
app.listen( PORT ); httpServer.listen( PORT );
}; };
export default { export default {
+23
View File
@@ -0,0 +1,23 @@
export interface Room {
'playbackStatus': boolean;
'playbackStart': number;
'playlist': Song[];
'playlistIndex': number;
'roomName': string;
'roomToken': string;
'ownerUID': string;
'useAntiTamper': boolean;
}
export interface Song {
'title': string;
'artist': string;
'duration': number;
'cover': string;
'additionalInfo'?: string;
}
export interface SocketData {
[key: string]: Room;
}
+1 -1
View File
@@ -44,7 +44,7 @@ const configure = ( location: 'stderr' | 'file', minLevel: LogLevel, file?: stri
throw new Error( 'File parameter required when location is "file"' ); throw new Error( 'File parameter required when location is "file"' );
} }
loc = location === 'stderr' ? 'stderr' : ( file ?? 'musicplayer.log' ); loc = location === 'stderr' ? 'stderr' : file;
lev = levels.indexOf( minLevel ); lev = levels.indexOf( minLevel );
}; };
+178
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
};
+286
View File
@@ -0,0 +1,286 @@
import cors from 'cors';
import express from 'express';
import bodyParser from 'body-parser';
import {
SocketData
} from './definitions';
import logger from './logger';
const useSSE = (
app: express.Application,
socketData: SocketData,
corsOpts: cors.CorsOptions,
getSessionID: ( request: express.Request ) => string,
getSignedIn: ( request: express.Request ) => boolean
) => {
/*
ROUTES FOR SERVER SENT EVENTS VERSION
*/
// Connected clients have their session ID as key
interface SocketClientList {
[key: string]: SocketClient;
}
interface SocketClient {
'response': express.Response;
'room': string;
}
interface ClientReferenceList {
/**
* Find all clients connected to one room
*/
[key: string]: string[];
}
const importantClients: SocketClientList = {};
const connectedClients: SocketClientList = {};
const clientReference: ClientReferenceList = {};
app.get(
'/socket/connection',
cors( corsOpts ),
( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
if ( socketData[ String( request.query.room ) ] ) {
response.writeHead( 200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
} );
response.status( 200 );
response.flushHeaders();
response.write( `data: ${ JSON.stringify( {
'type': 'basics',
'data': socketData[ String( request.query.room ) ]
} ) }\n\n` );
const sid = getSessionID( request );
if ( getSignedIn( request ) ) {
importantClients[ sid ] = {
'response': response,
'room': String( request.query.room )
};
}
connectedClients[ sid ] = {
'response': response,
'room': String( request.query.room )
};
if ( !clientReference[ String( request.query.room ) ] ) {
clientReference[ String( request.query.room ) ] = [];
}
if ( !clientReference[ String( request.query.room ) ]
.includes( sid ) ) {
clientReference[ String( request.query.room ) ].push( sid );
}
request.on( 'close', () => {
try {
importantClients[ sid ] = undefined;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch ( e ) { /* empty */ }
const cl = clientReference[ String( request.query.room ) ];
for ( const c in cl ) {
if ( cl[ c ] === sid ) {
try {
cl.splice( parseInt( c ), 1 );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch ( _ ) { /* empty */ }
break;
}
}
connectedClients[ sid ] = undefined;
} );
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
}
);
app.get(
'/socket/getData',
cors( corsOpts ),
( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
response.send( socketData[ String( request.query.room ) ] );
} else {
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
}
);
app.get(
'/socket/joinRoom',
cors( corsOpts ),
( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
if ( socketData[ String( request.query.room ) ] ) {
logger.debug( `Room "${ request.query.room }" was joined` );
response.send( 'ok' );
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
}
);
app.options( '/socket/update', cors( corsOpts ) );
app.post(
'/socket/update',
cors( corsOpts ),
bodyParser.json(),
( request: express.Request, response: express.Response ) => {
if ( socketData[ request.body.roomName ] ) {
if ( request.body.event === 'tampering' ) {
logger.debug( `Room "${
request.query.roomName }" has new event: Tampering` );
const clients = clientReference[ request.body.roomName ];
if ( !clients ) {
response.send( 'ERR_CANNOT_SEND' );
return;
}
for ( const client in clients ) {
if ( importantClients[ clients[ client ] ] ) {
importantClients[ clients[ client ] ]
.response.write( 'data: ' + JSON.stringify( {
'type': 'tampering-msg',
'data': true
} ) + '\n\n' );
}
}
response.send( 'ok' );
} else {
if (
socketData[ request.body.roomName ].roomToken
=== request.body.roomToken
) {
let send = false;
let update = '';
if ( request.body.event === 'playback-start-update' ) {
send = true;
update = 'playback-start';
socketData[ request.body.roomName ]
.playbackStart = request.body.data;
} else if ( request.body.event === 'playback-update' ) {
send = true;
update = 'playback';
socketData[ request.body.roomName ]
.playbackStatus = request.body.data;
} else if ( request.body.event === 'playlist-update' ) {
send = true;
update = 'playlist';
socketData[ request.body.roomName ]
.playlist = request.body.data;
} else if ( request.body.event === 'playlist-index-update' ) {
send = true;
update = 'playlist-index';
socketData[ request.body.roomName ]
.playlistIndex = request.body.data;
}
logger.debug( `Room "${
request.query.roomName }" has new event: ${ update }` );
if ( send ) {
const clients = clientReference[ request.body.roomName ];
if ( !clients ) {
response.send( 'ERR_CANNOT_SEND' );
return;
}
for ( const client in clients ) {
if ( connectedClients[ clients[ client ] ] ) {
connectedClients[ clients[ client ] ]
.response.write( 'data: ' + JSON.stringify( {
'type': update,
'data': request.body.data
} ) + '\n\n' );
}
}
response.send( 'ok' );
} else {
response.status( 404 ).send( 'ERR_CANNOT_SEND' );
}
} else {
response.status( 403 ).send( 'ERR_UNAUTHORIZED' );
}
}
} else {
response.status( 400 ).send( 'ERR_WRONG_REQUEST' );
}
}
);
app.options( '/socket/deleteRoom', cors( corsOpts ) );
app.post(
'/socket/deleteRoom',
cors( corsOpts ),
bodyParser.json(),
( request: express.Request, response: express.Response ) => {
if ( request.body.roomName ) {
if ( socketData[ request.body.roomName ] ) {
if (
socketData[ request.body.roomName ].roomToken
=== request.body.roomToken
) {
logger.debug( `Room "${
request.query.roomName }" was deleted` );
socketData[ request.body.roomName ] = undefined;
const clients = clientReference[ request.body.roomName ];
if ( !clients ) {
response.send( 'ok' );
return;
}
for ( const client in clients ) {
if ( connectedClients[ clients[ client ] ] ) {
connectedClients[ clients[ client ] ]
.response.write( 'data: ' + JSON.stringify( {
'type': 'delete-share',
'data': true
} ) + '\n\n' );
}
}
response.send( 'ok' );
} else {
response.send( 403 ).send( 'ERR_UNAUTHORIZED' );
}
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 400 ).send( 'ERR_NO_ROOM_NAME' );
}
}
);
};
export default {
useSSE
};
+386
View File
@@ -0,0 +1,386 @@
/*
* libreevent - db.js
*
* Created by Janis Hutz 03/26/2023, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
import path from 'path';
import fs from 'fs';
import * as sqlDB from './mysqldb.js';
import logger from '../logger.js';
declare let __dirname: string | undefined;
if ( typeof __dirname === 'undefined' ) {
__dirname = path.resolve( path.dirname( '' ) );
} else {
__dirname = __dirname + '/../';
}
const dbRef = {
'user': 'music_users',
'users': 'music_users',
};
const dbh = new sqlDB.SQLDB();
dbh.connect();
/**
* Initialize database (create tables, etc)
* @returns {undefined}
*/
const initDB = (): undefined => {
( async () => {
logger.info( '[ DB ] Setting up...' );
dbh.setupDB();
logger.info( '[ DB ] Setting up complete!' );
} )();
};
/**
* Retrieve data from the database
* @param {string} db The name of the database
* @param {string} column The name of the column of the data-table in which to search for the searchQuery
* @param {string} searchQuery The query for the selected column
* @returns {Promise<object>} Returns a promise that resolves to an object containing the results.
*/
const getDataSimple = ( db: string, column: string, searchQuery: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
dbh.query( {
'command': 'getFilteredData',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( data => {
resolve( data );
} )
.catch( error => {
reject( error );
} );
} );
};
/**
* Use the SQL LeftJoin function to obtain data from DB.
* @param {string} db DB name to get data from
* @param {string} column The column in the DB in which to search for the searchQuery
* @param {string} searchQuery The data to look for in the selected column
* @param {string} secondTable The second table on which to perform the left join function
* @param {object} columns The columns to return, list of objects: { 'db': TABLE NAME, 'column': COLUMN NAME })
* @param {string} nameOfMatchingParam Which properties should be matched to get the data, e.g. order.user_id=users.id
* @returns {Promise<Object | Error>} Returns all records from the db and all matching data specified with the matchingParam from the secondTable.
*/
const getDataWithLeftJoinFunction = ( db: string, column: string, searchQuery: string, secondTable: string, columns: object, nameOfMatchingParam: string ): Promise<object> => {
/*
LeftJoin (Select values in first table and return all corresponding values of second table):
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- operation.columns (The columns of both tables to be selected, list of objects: { 'db': TABLE NAME, 'column': COLUMN NAME })
- operation.secondTable (The second table to perform Join operation with)
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
*/
return new Promise( ( resolve, reject ) => {
const settings = {
'command': 'LeftJoin',
'property': column,
'searchQuery': searchQuery,
'selection': '',
'secondTable': dbRef[ secondTable ],
'matchingParam': dbRef[ db ] + '.' + nameOfMatchingParam + '=' + dbRef[ secondTable ] + '.' + nameOfMatchingParam,
};
for ( const el in columns ) {
settings.selection += dbRef[ columns[ el ].db ] + '.' + columns[ el ].column + ',';
}
settings.selection = settings.selection.slice( 0, settings.selection.length - 1 );
dbh.query( settings, dbRef[ db ] ).then( data => {
resolve( data );
} )
.catch( error => {
reject( error );
} );
} );
};
/**
* Get all data from the selected database
* @param {string} db The database of which all data should be retrieved
* @returns {Promise<object>} Returns an object containing all data
*/
const getData = ( db: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
dbh.query( {
'command': 'getAllData'
}, dbRef[ db ] ).then( data => {
resolve( data );
} )
.catch( error => {
reject( error );
} );
} );
};
/**
* Write data to the database
* @param {string} db The database in which the data should be written
* @param {string} column The column in which to search for the data
* @param {string} searchQuery The query with which to search
* @param {string} data The data to write. Also include the column & searchQuery parameters, if they also need to be added
* @returns {Promise<object>} Returns a promise that resolves to the interaction module return.
*/
const writeDataSimple = ( db: string, column: string, searchQuery: string, data: any ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
dbh.query( {
'command': 'checkDataAvailability',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( res => {
if ( res.length > 0 ) {
dbh.query( {
'command': 'updateData',
'property': column,
'searchQuery': searchQuery,
'newValues': data
}, dbRef[ db ] ).then( dat => {
resolve( dat );
} )
.catch( error => {
reject( error );
} );
} else {
dbh.query( {
'command': 'addData',
'data': data
}, dbRef[ db ] ).then( dat => {
resolve( dat );
} )
.catch( error => {
reject( error );
} );
}
} )
.catch( error => {
reject( error );
} );
} );
};
/**
* Delete data from the database
* @param {string} db The database from which the data should be deleted
* @param {string} column The column in which to search for the data
* @param {string} searchQuery The query with which to search
* @returns {Promise<object>} Returns a promise that resolves to the interaction module return.
*/
const deleteDataSimple = ( db: string, column: string, searchQuery: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
dbh.query( {
'command': 'deleteData',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( dat => {
resolve( dat );
} )
.catch( error => {
reject( error );
} );
} );
};
/**
* Check if the data is available in the database.
* @param {string} db The database in which to check
* @param {string} column The column in which to search for the data
* @param {string} searchQuery The query with which to search
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean (true = is available)
*/
const checkDataAvailability = ( db: string, column: string, searchQuery: string ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
dbh.query( {
'command': 'checkDataAvailability',
'property': column,
'searchQuery': searchQuery
}, dbRef[ db ] ).then( res => {
if ( res.length > 0 ) {
resolve( true );
} else {
resolve( false );
}
} )
.catch( error => {
reject( error );
} );
} );
};
/**
* Load multiple JSON files at once
* @param {Array<string>} files The files which to load
* @returns {Promise<object>} Returns the data from all files
*/
const getJSONDataBatch = async ( files: Array<string> ): Promise<object> => {
const allFiles = {};
for ( const file in files ) {
try {
allFiles[ files[ file ] ] = await getJSONData( files[ file ] );
} catch ( err ) {
allFiles[ files[ file ] ] = 'ERROR: ' + err;
}
}
return allFiles;
};
/**
* Load all data from a JSON file
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
* @returns {Promise<object>} The data that was loaded
*/
const getJSONData = ( file: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
if ( data.byteLength > 0 ) {
resolve( JSON.parse( data.toString() ) ?? {} );
} else {
resolve( { } );
}
}
} );
} );
};
/**
* Load some data from a JSON file
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
* @param {string} identifier The identifier of the element which should be loaded
* @returns {Promise<object>} The data that was loaded
*/
const getJSONDataSimple = ( file: string, identifier: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
if ( data.byteLength > 0 ) {
resolve( JSON.parse( data.toString() )[ identifier ] ?? {} );
} else {
resolve( { } );
}
}
} );
} );
};
/**
* Get JSON data, but synchronous (blocking)
* @param {string} file The file to be loaded (path relative to root)
* @returns {object} Returns the JSON file
*/
const getJSONDataSync = ( file: string ): object => {
return JSON.parse( fs.readFileSync( path.join( __dirname + '/' + file ) ).toString() );
};
/**
* Description
* @param {any} db:string
* @param {any} identifier:string
* @param {any} values:any
* @returns {any}
*/
const writeJSONDataSimple = ( db: string, identifier: string, values: any ) => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
let dat = {};
if ( data.byteLength > 0 ) {
dat = JSON.parse( data.toString() ) ?? {};
}
dat[ identifier ] = values;
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
}
resolve( true );
} );
}
} );
} );
};
/**
* Write data to a JSON file
* @param {string} db The database to write into
* @param {object} data The data to write
* @returns {Promise<boolean>}
*/
const writeJSONData = ( db: string, data: object ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( data ), error => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
resolve( true );
}
} );
} );
};
/**
* Delete data from a JSON file
* @param {string} db The file to delete from (just filename, has to be in "/data/" folder, no file extension)
* @param {string} identifier The identifier of the element to delete
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean
*/
const deleteJSONDataSimple = ( db: string, identifier: string ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
let dat = {};
if ( data.byteLength > 0 ) {
dat = JSON.parse( data.toString() ) ?? {};
}
delete dat[ identifier ];
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
}
resolve( true );
} );
}
} );
} );
};
export default {
initDB,
checkDataAvailability,
deleteDataSimple,
deleteJSONDataSimple,
getData,
getDataSimple,
getDataWithLeftJoinFunction,
getJSONData,
getJSONDataBatch,
getJSONDataSimple,
getJSONDataSync,
writeDataSimple,
writeJSONData,
writeJSONDataSimple
};
+221
View File
@@ -0,0 +1,221 @@
/*
* libreevent - mysqldb.js
*
* Created by Janis Hutz 07/12/2023, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
import mysql from 'mysql';
import fs from 'fs';
import path from 'path';
import logger from '../logger';
declare let __dirname: string | undefined;
if ( typeof __dirname === 'undefined' ) {
__dirname = path.resolve( path.dirname( '' ) );
} else {
__dirname = __dirname + '/../';
}
// If the connection does not work for you, you will need to add your ip
// to the whitelist of the database
class SQLConfig {
command: string;
property?: string;
searchQuery?: string;
selection?: string;
query?: string;
newValues?: object;
secondTable?: string;
matchingParam?: string;
data?: object;
}
class SQLDB {
sqlConnection: mysql.Connection;
isRecovering: boolean;
config: object;
constructor () {
this.config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/db.config.secret.json' ) ) );
this.sqlConnection = mysql.createConnection( this.config );
this.isRecovering = false;
}
connect () {
return new Promise( ( resolve, reject ) => {
const self = this;
if ( this.isRecovering ) {
logger.info( '[ SQL ] Attempting to recover from critical error' );
this.sqlConnection = mysql.createConnection( this.config );
this.isRecovering = false;
}
this.sqlConnection.connect( err => {
if ( err ) {
logger.error( '[ SQL ]: An error ocurred whilst connecting: ' + err.stack );
reject( err );
return;
}
logger.info( '[ SQL ] Connected to database successfully' );
self.sqlConnection.on( 'error', err => {
if ( err.code === 'ECONNRESET' ) {
self.isRecovering = true;
setTimeout( () => {
self.disconnect();
self.connect();
}, 1000 );
} else {
logger.error( err );
}
} );
resolve( 'connection' );
} );
} );
}
disconnect ( ) {
this.sqlConnection.end();
}
async setupDB () {
this.sqlConnection.query( 'SELECT @@default_storage_engine;', ( error, results ) => {
if ( error ) throw error;
if ( results[ 0 ][ '@@default_storage_engine' ] !== 'InnoDB' ) throw 'DB HAS TO USE InnoDB!';
} );
this.sqlConnection.query( 'CREATE TABLE music_users ( account_id INT ( 10 ) NOT NULL AUTO_INCREMENT, email TINYTEXT NOT NULL, uid TINYTEXT, lang TINYTEXT, username TINYTEXT, settings VARCHAR( 5000 ), PRIMARY KEY ( account_id ) ) ENGINE=INNODB;', error => {
if ( error ) if ( error.code !== 'ER_TABLE_EXISTS_ERROR' ) throw error;
return 'DONE';
} );
}
query ( operation: SQLConfig, table: string ): Promise<Array<object>> {
return new Promise( ( resolve, reject ) => {
/*
Possible operation.command values (all need the table argument of the method call):
- getAllData: no additional instructions needed
- getFilteredData:
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- InnerJoin (Select values that match in both tables):
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
- operation.secondTable (The second table to perform Join operation with)
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
- LeftJoin (Select values in first table and return all corresponding values of second table):
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
- operation.secondTable (The second table to perform Join operation with)
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
- RightJoin (Select values in second table and return all corresponding values of first table):
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
- operation.secondTable (The second table to perform Join operation with)
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
- addData:
- operation.data (key-value pair with all data as values and column to insert into as key)
- deleteData:
- operation.property (the column to search for the value)
- operation.searchQuery (the value to search for [will be sanitised by method])
- updateData:
- operation.newValues (a object with keys being the column and value being the value to be inserted into that column, values are being
sanitised by the function)
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- checkDataAvailability:
- operation.property (the column to search for the value),
- operation.searchQuery (the value to search for [will be sanitised by method])
- fullCustomCommand:
- operation.query (the SQL instruction to be executed) --> NOTE: This command will not be sanitised, so use only with proper sanitisation!
*/
let command = '';
if ( operation.command === 'getAllData' ) {
command = 'SELECT * FROM ' + table;
} else if ( operation.command === 'getFilteredData' || operation.command === 'checkDataAvailability' ) {
command = 'SELECT * FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
} else if ( operation.command === 'fullCustomCommand' ) {
command = operation.query;
} else if ( operation.command === 'addData' ) {
let keys = '';
let values = '';
for ( const key in operation.data ) {
keys += String( key ) + ', ';
values += this.sqlConnection.escape( String( operation.data[ key ] ) ) + ', ';
}
command = 'INSERT INTO ' + table + ' (' + keys.slice( 0, keys.length - 2 ) + ') VALUES (' + values.slice( 0, values.length - 2 ) + ');';
} else if ( operation.command === 'updateData' ) {
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
else {
command = 'UPDATE ' + table + ' SET ';
let updatedValues = '';
for ( const value in operation.newValues ) {
updatedValues += value + ' = ' + this.sqlConnection.escape( String( operation.newValues[ value ] ) ) + ', ';
}
command += updatedValues.slice( 0, updatedValues.length - 2 );
command += ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
}
} else if ( operation.command === 'deleteData' ) {
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
else {
command = 'DELETE FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
}
} else if ( operation.command === 'InnerJoin' ) {
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' INNER JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
} else if ( operation.command === 'LeftJoin' ) {
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' LEFT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
} else if ( operation.command === 'RightJoin' ) {
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' RIGHT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
}
this.sqlConnection.query( command, ( error, results ) => {
if ( error ) reject( error );
resolve( results );
} );
} );
}
}
export {
SQLConfig, SQLDB
};
+12
View File
@@ -0,0 +1,12 @@
import db from './db.js';
// import hash from '../security/hash.js';
db.initDB();
// setTimeout( () => {
// console.log( 'Setting up admin account' );
// hash.hashPassword( 'test' ).then( hash => {
// db.writeDataSimple( 'admin', 'email', 'info@janishutz.com', { email: 'info@janishutz.com', pass: hash, two_fa: 'enhanced' } );
// console.log( 'Complete!' );
// } );
// }, 5000 );
+11 -16
View File
@@ -1,18 +1,13 @@
{ {
"compilerOptions": { "compilerOptions": {
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src", "allowJs": true,
"allowJs": true, "target": "ES6",
"target": "ES6", "skipLibCheck": true,
"skipLibCheck": true, "allowSyntheticDefaultImports": true,
"allowSyntheticDefaultImports": true, "types": ["node"],
"types": [ "module": "NodeNext",
"node" "moduleResolution": "NodeNext"
], },
"module": "NodeNext", "include": [ "./src/**/*" ],
"moduleResolution": "NodeNext"
},
"include": [
"./src/**/*"
]
} }
-1
View File
@@ -1 +0,0 @@
VITE_BACKEND_URL=http://localhost:8080
-2
View File
@@ -1,2 +0,0 @@
# MusicPlayer Webapp
More notes to come
-45
View File
@@ -1,45 +0,0 @@
{
"name": "vue-project",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.1",
"@fortawesome/fontawesome-svg-core": "^7.3.1",
"@fortawesome/free-regular-svg-icons": "^7.3.1",
"@globalhive/vuejs-tour": "^3.0.1",
"@kyvg/vue3-notification": "^3.4.2",
"nprogress": "^0.2.0",
"pinia": "^4.0.2",
"vue": "^3.5.40",
"vue-router": "^5.2.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@stylistic/eslint-plugin": "^5.10.0",
"@tsconfig/node24": "^24.0.4",
"@types/node": "^24.13.3",
"@vitejs/plugin-vue": "^6.0.8",
"@vue/tsconfig": "^0.9.1",
"eslint-plugin-vue": "^10.10.0",
"globals": "^17.11.0",
"musickitjs-v3-types": "^1.1.1",
"npm-run-all2": "^9.0.2",
"sass-embedded": "^1.103.1",
"typescript": "~6.0.0",
"typescript-eslint": "^8.67.0",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^22.18.0 || >=24.12.0"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

-60
View File
@@ -1,60 +0,0 @@
<script setup lang="ts">
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
import PopupElement from '@/components/PopupElement.vue';
import player from '@/ts/player';
import {
sources
} from '@/ts/player/state';
const showPopup = defineModel<boolean>( {
'required': true
} );
const addSong = ( source: string ) => {
player.addSongFromSource( source );
showPopup.value = false;
};
</script>
<template>
<div>
<ImportTypePicker />
<PopupElement v-model="showPopup" show-close>
<h1>Add Song</h1>
<div class="song-sources">
<div v-for="(source, index) in sources" :key="index" @click="() => addSong( source.id )">
<i v-if="source.icon" :class="['fa-solid', 'fa-' + source.icon]"></i>
<p>{{ source.name }}</p>
</div>
</div>
</PopupElement>
</div>
</template>
<style lang="scss" scoped>
.song-sources {
display: flex;
flex-wrap: wrap;
width: 50vw;
height: 35vh;
justify-content: center;
overflow-y: scroll;
overflow-x: hidden;
>div {
width: 32%;
margin: 0.5%;
height: 50%;
background-color: var(--accent-background);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
>.fa-solid {
font-size: 1.5rem;
}
}
}
</style>
-13
View File
@@ -1,13 +0,0 @@
<script setup lang="ts">
import PopupElement from './PopupElement.vue';
</script>
<template>
<div>
<PopupElement>
<h2></h2>
<button>Ok</button>
<button>Cancel</button>
</PopupElement>
</div>
</template>
-83
View File
@@ -1,83 +0,0 @@
<script setup lang="ts">
import ProgressBar from './ProgressBar.vue';
import {
beautifyTime
} from '@/ts/util/time';
import player from '@/ts/player';
const playbackPercentage = player.playbackPercentage;
const duration = player.duration;
const repeatMode = player.repeat;
const shuffleMode = player.shuffle;
const seek = () => {
player.seekTo( playbackPercentage.value );
};
const toggleRepeatMode = () => {
switch ( repeatMode.value ) {
case 'one':
player.setRepeat( 'all' );
break;
case 'off':
player.setRepeat( 'one' );
break;
case 'all':
player.setRepeat( 'off' );
}
};
const openShareMenu = () => {
alert( 'Share menu not yet implemented' );
};
// TODO: Button availability
</script>
<template>
<div class="mp-player">
<div class="controls">
<i class="fa-solid fa-backward-step" @click="player.prev"></i>
<i class="fa-solid fa-arrow-rotate-left quick-seek" @click="player.back10"></i>
<div :class="['play-pause', 'paused']">
<i class="fa-solid fa-play" @click="player.play"></i>
<i class="fa-solid fa-pause" @click="player.pause"></i>
</div>
<i class="fa-solid fa-arrow-rotate-right quick-seek" @click="player.skip10"></i>
<i class="fa-solid fa-forward-step" @click="player.next"></i>
</div>
<div class="time">
<!-- TODO: Probably needs to be a computed -->
<p class="current">
{{ beautifyTime( playbackPercentage * duration ) }}
</p>
<p class="duration">
{{ beautifyTime( duration ) }}
</p>
</div>
<ProgressBar v-model="playbackPercentage" @move-end="seek" />
<div class="bottom-bar">
<!-- FA being a POS means need to make own mods to it -->
<i
:class="[
'fa-solid',
'fa-repeat',
'repeat',
repeatMode === 'one' ? 'once' : undefined,
repeatMode === 'all' ? 'all' : undefined
]"
@click="toggleRepeatMode"
></i>
<i class="fa-solid fa-share-from-square" @click="openShareMenu"></i>
<i
:class="['fa-solid', 'fa-shuffle', shuffleMode ? 'active' : undefined]"
@click="player.setShuffle( !shuffleMode )"
></i>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/player.scss';
</style>
-5
View File
@@ -1,5 +0,0 @@
<template>
<div>
Hello World
</div>
</template>
-85
View File
@@ -1,85 +0,0 @@
<template>
<div :class="['popup', isOpen ? undefined : 'hidden' ]">
<div class="popup-backdrop"></div>
<div class="popup-main">
<i v-if="props.showClose" class="fa-solid fa-xmark close-icon" @click="closePopup()"></i>
<slot></slot>
</div>
</div>
</template>
<script setup lang="ts">
const isOpen = defineModel<boolean>();
const props = defineProps<{
'showClose'?: boolean
}>();
const closePopup = () => {
isOpen.value = false;
};
</script>
<style scoped lang="scss">
.popup {
position: fixed;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
transition-delay: 0;
.popup-backdrop {
width: 100vw;
height: 100vh;
position: absolute;
background-color: var( --overlay-color );
top: 0;
left: 0;
overflow: hidden;
transition: all 0.5s;
}
.popup-main {
width: fit-content;
height: fit-content;
background-color: var( --secondary-color );
padding: 2.5%;
border-radius: 20px;
transform: scale(1);
position: relative;
overflow-y: scroll;
display: block;
transition: transform 0.25s ease, opacity 0.25s ease;
>.close-icon {
position: absolute;
top: 20px;
right: 20px;
font-size: 1.5rem;
cursor: pointer;
user-select: none;
}
}
&.hidden {
top: -100vh;
transition-delay: 0.5s;
.popup-main {
transform: scale(1.25);
opacity: 0;
transition: transform 0.15s ease, opacity 0.15s ease;
}
.popup-backdrop {
opacity: 0;
}
}
}
</style>
-54
View File
@@ -1,54 +0,0 @@
<script setup lang="ts">
import {
ref,
useTemplateRef
} from 'vue';
const val = defineModel<number>( {
'required': true,
'default': 0.5
} );
const offset = ref( -1 );
const bar = useTemplateRef( 'bar' );
const start = ( ev: MouseEvent ) => {
offset.value = ev.x - bar.value!.offsetLeft;
val.value = offset.value / bar.value!.clientWidth;
emit( 'move-start' );
};
const move = ( ev: MouseEvent ) => {
if ( offset.value > -1 ) {
val.value = Math.max( 0, Math.min( ev.x / bar.value!.clientWidth, 1 ) );
}
};
const end = () => {
offset.value = -1;
emit( 'move-end' );
};
const emit = defineEmits<{
( e: 'move-end' ): void;
( e: 'move-start' ): void;
}>();
</script>
<template>
<div class="progressbar">
<div ref="bar" class="back">
<div :style="`width: ${ val * 100 }%;`"></div>
</div>
<div
:class="['click-target', offset >= 0 ? 'active' : undefined]"
@mousedown="start"
@mousemove="move"
@mouseup="end"
@mouseleave="end"
></div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/progressbar.scss';
</style>
-113
View File
@@ -1,113 +0,0 @@
<script setup lang="ts">
import {
type Ref,
type WritableComputedRef,
computed,
ref
} from 'vue';
import AddSong from './AddSong.vue';
import type {
Song
} from '@/ts/dtype/playlist';
import SongEditor from './SongEditor.vue';
import SortableList from './SortableList.vue';
import {
beautifyTime
} from '@/ts/util/time';
import player from '@/ts/player';
const queue: WritableComputedRef<Song[]> = computed( {
get () {
return player.queue.value.slice( player.queueIdx.value );
},
set ( val ) {
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value ).concat( val );
}
} );
const isPlaying = player.isPlaying;
const showAddSong = ref( false );
const showEditSong = ref( false );
const editingSong: Ref<null | Song> = ref( null );
const addSong = () => {
showAddSong.value = true;
};
const editSong = ( idx: number ) => {
showEditSong.value = true;
editingSong.value = player.queue.value[ idx ]!;
};
const deleteSong = ( idx: number ) => {
player.removeSong( idx );
};
</script>
<template>
<div>
<div>
<button @click="addSong">
<i class="fa-solid fa-plus"></i>
Add
</button>
<button @click="player.clearQueue">
<i class="fa-solid fa-xmark"></i>
Clear
</button>
<button>
<i class="fa-solid fa-save"></i>
Save
</button>
<!-- TODO: Should only appear when sharing -->
<button>
<i class="fa-solid fa-paper-plane"></i>
Transmit
</button>
</div>
<SongEditor v-model="showEditSong" editing-song="" />
<AddSong v-model="showAddSong" />
<div>
<SortableList v-slot="{ item: song, index }" v-model="queue">
<div class="song-list-element">
<div class="song-cover-wrapper">
<img
v-if="song.artwork"
:src="song.artwork"
alt="Song cover"
class="song-cover"
>
<i v-else class="fa-solid fa-music song-cover"></i>
<div v-if="index === 0" class="play-overlay">
<div v-if="isPlaying" class="playing-symbols">
<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>
<i
v-else
class="fa-solid fa-pause"
></i>
</div>
<div v-else class="play-overlay hover">
<i class="fa-solid fa-play" @click="() => player.playIndex( index )"></i>
</div>
</div>
<div class="song-details">
<h3>{{ song.name }}</h3>
<p>{{ song.artist }}</p>
<p>{{ song['additional-info'] }}</p>
</div>
<div class="song-actions">
<p>{{ beautifyTime( song.duration ) }}</p>
<i v-if="index !== 0" class="fa-solid fa-trash-can" @click="() => deleteSong( index )"></i>
<i class="fa-solid fa-pen-to-square" @click="() => editSong( index )"></i>
</div>
</div>
</SortableList>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/queue.scss';
</style>
-16
View File
@@ -1,16 +0,0 @@
<script setup lang="ts">
import PopupElement from './PopupElement.vue';
const model = defineModel<boolean>( {
'required': true
} );
</script>
<template>
<div>
<PopupElement v-model="model" show-close>
<h2>Edit Song</h2>
<!-- TODO: Need a way to use the search feature to replace the song details, as of course as well text editors -->
</PopupElement>
</div>
</template>
-163
View File
@@ -1,163 +0,0 @@
<script setup lang="ts" generic="T">
import {
computed,
ref,
useTemplateRef
} from 'vue';
const array = defineModel<T[]>( {
'required': true
} );
defineSlots<{
default( props: {
'item': T,
'index': number
} ): void
}>();
const emit = defineEmits<{
( e: 'dropped' ): void
}>();
const scrollContainer = useTemplateRef( 'scroll-container' );
const movingIdx = ref( -1 );
const movingCurrentIdx = ref( -1 );
const movableSize = ref( -1 );
const offset = ref( -1 );
let mover = -1;
let moveSpeed = 0;
const start = ( ev: MouseEvent, idx: number ) => {
if ( array.value.length < 2 ) return;
movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight;
offset.value = ev.y - ( movableSize.value / 2 );
movingIdx.value = idx;
movingCurrentIdx.value = idx;
};
const move = ( ev: MouseEvent ) => {
if ( movingIdx.value < 0 ) return;
offset.value = ev.y - ( movableSize.value / 2 );
const relToContainer = ev.y - scrollContainer.value!.offsetTop;
const height = scrollContainer.value!.offsetHeight;
// Scrolling up and down
if ( relToContainer < 50 ) {
if ( relToContainer < 0 )
moveSpeed = -20;
else
moveSpeed = -Math.ceil( Math.max( 1, Math.min( 5 / relToContainer, 10 ) ) );
startTracker();
} else if ( relToContainer > height - 50 ) {
if ( height - relToContainer < 0 )
moveSpeed = 20;
else
moveSpeed = Math.ceil( Math.max( 1, Math.min( 5 / ( height - relToContainer ), 10 ) ) );
startTracker();
} else {
if ( mover >= 0 ) {
try {
clearInterval( mover );
mover = -1;
} catch { /* empty */ }
}
}
// Determine current index to insert
const percentage = ( relToContainer + scrollContainer.value!.scrollTop ) / scrollContainer.value!.scrollHeight;
const newIdx = Math.min( Math.max( Math.round( percentage * array.value.length ), 0 ), array.value.length - 1 );
if ( newIdx !== movingIdx.value )
movingCurrentIdx.value = newIdx;
else
movingCurrentIdx.value = movingIdx.value;
};
const startTracker = () => {
if ( mover < 0 ) {
mover = setInterval( () => {
scrollContainer.value!.scrollTop += moveSpeed;
}, 100 );
}
};
const end = () => {
offset.value = -1;
try {
clearInterval( mover );
mover = -1;
} catch { /* empty */ }
const before = array.value.slice( 0, movingIdx.value );
const after = array.value.slice( movingIdx.value + 1 );
const el = array.value[ movingIdx.value ]!;
const arr = before.concat( after );
arr.splice( movingCurrentIdx.value, 0, el );
array.value = arr;
movingIdx.value = -1;
movingCurrentIdx.value = -1;
emit( 'dropped' );
};
const style = computed( () => {
return ( index: number ) => {
if ( movingIdx.value === array.value.length - 1 && movingIdx.value === index + 1
&& movingCurrentIdx.value === movingIdx.value )
return `margin-bottom: ${ movableSize.value }px;`;
if ( movingIdx.value >= 0 ) {
if ( movingIdx.value === index ) {
// Current item is moved
return `top: ${ offset.value }px;`;
} else {
// Current item is not moved
if ( movingCurrentIdx.value === array.value.length - 1 && index === movingCurrentIdx.value ) {
// Create gap below
return `margin-bottom: ${ movableSize.value }px;`;
} else if ( movingCurrentIdx.value === index
|| ( movingCurrentIdx.value === index - 1 && movingIdx.value == index - 1 ) ) {
// Create a gap above
return `margin-top: ${ movableSize.value }px;`;
}
}
}
return '';
};
} );
</script>
<template>
<div ref="scroll-container" class="sortable-list">
<div
v-if="movingIdx >= 0"
class="grip-target"
@mousemove="move"
@mouseleave="end"
@mouseup="end"
>
</div>
<div
v-for="(item, index) in array"
:id="'movable-' + index"
:key="index"
:class="[ 'movable', movingIdx === index ? 'moving' : undefined ]"
:style="style(index)"
>
<slot :item="item" :index="index"></slot>
<i class="fa-solid fa-grip-vertical" @mousedown="( e ) => start( e, index )"></i>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/sortablelist.scss'
</style>
-14
View File
@@ -1,14 +0,0 @@
<script setup lang="ts">
import PopupElement from '@/components/PopupElement.vue';
import {
isShowingDiskLoader
} from './diskLoader';
</script>
<template>
<div>
<PopupElement v-model="isShowingDiskLoader" show-close>
<h3>Load from disk</h3>
</PopupElement>
</div>
</template>
-68
View File
@@ -1,68 +0,0 @@
<script setup lang="ts">
import {
type ImportTypes,
importers,
isShowingImportTypePicker
} from './importTypePicker';
import PopupElement from '@/components/PopupElement.vue';
import SearchView from './SearchView.vue';
import {
openDiskLoaderInterface
} from './diskLoader';
import {
openSearchInterface
} from './searchManager';
const openPicker = ( source: ImportTypes ) => {
isShowingImportTypePicker.value = false;
if ( source.type === 'cloud' ) {
openSearchInterface( source );
} else {
openDiskLoaderInterface( source );
}
};
</script>
<template>
<div>
<SearchView />
<PopupElement v-model="isShowingImportTypePicker" show-close>
<h2>What would you like to add?</h2>
<div class="import-type-list">
<div v-for="(source, index) in importers" :key="index" @click="openPicker( source )">
{{ source.name }}
</div>
</div>
</PopupElement>
</div>
</template>
<style lang="scss" scoped>
.import-type-list {
display: flex;
flex-wrap: wrap;
width: 50vw;
height: 35vh;
justify-content: center;
overflow-y: scroll;
overflow-x: hidden;
>div {
width: 32%;
margin: 0.5%;
height: 50%;
background-color: var(--accent-background);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
>.fa-solid {
font-size: 1.5rem;
}
}
}
</style>
-92
View File
@@ -1,92 +0,0 @@
<script setup lang="ts">
import {
type Ref,
ref
} from 'vue';
import {
isShowingSearchView,
searchOpts
} from './searchManager';
import PopupElement from '@/components/PopupElement.vue';
import type {
Song
} from '@/ts/dtype/playlist';
const results: Ref<Song[]> = ref( [] );
const query = ref( '' );
const isSearching = ref( false );
const addedIndex = ref( -1 );
let addedTimeout = -1;
let timeout = -1;
let searchedForQuery = '';
const search = ( ev: KeyboardEvent ) => {
if ( ev.key === 'Enter' ) {
// TODO: If no new input, instead of searching, add first song
try {
clearTimeout( timeout );
} catch { /* empty */ }
doSearch();
} else if ( query.value.length > 2 ) {
try {
clearTimeout( timeout );
} catch { /* empty */ }
timeout = setTimeout( doSearch, 250 );
}
};
const doSearch = async () => {
if ( query.value === searchedForQuery ) return;
isSearching.value = true;
searchedForQuery = query.value;
results.value = await searchOpts.value?.search( query.value ) ?? [];
isSearching.value = false;
};
const add = ( idx: number ) => {
try {
clearTimeout( addedTimeout );
} catch { /* empty */ }
addedIndex.value = idx;
searchOpts.value?.addSelected( idx );
addedTimeout = setTimeout( () => {
addedIndex.value = -1;
}, 2000 );
};
</script>
<template>
<div>
<PopupElement v-model="isShowingSearchView" show-close>
<h2>Search {{ searchOpts?.name }}</h2>
<input
v-model="query"
type="text"
placeholder="Search..."
@keypress="search"
>
<div v-if="!isSearching" class="search-results-wrapper">
<div v-for="(result, index) in results" :key="index" @click="() => add(index)">
<img :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
<div>
<h4>{{ result.name }}</h4>
<p>{{ result.artist }}</p>
</div>
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
</div>
</div>
<div v-else class="search-results-wrapper placeholder">
Searching...
</div>
</PopupElement>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/search.scss';
</style>
-16
View File
@@ -1,16 +0,0 @@
import {
type Ref,
ref
} from 'vue';
import type {
FileImport
} from './importTypePicker';
export const isShowingDiskLoader = ref( false );
export const diskLoaderOpts: Ref<FileImport | null> = ref( null );
export const openDiskLoaderInterface = ( options: FileImport ) => {
isShowingDiskLoader.value = true;
diskLoaderOpts.value = options;
};
-33
View File
@@ -1,33 +0,0 @@
import {
type Ref,
ref
} from 'vue';
import type {
Song
} from '@/ts/dtype/playlist';
export interface CloudImport {
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
'name': string;
'type': 'cloud';
'search': ( term: string ) => Promise<Song[]>;
'addSelected': ( index: number ) => void;
}
// TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?)
export interface FileImport {
'name': string;
'type': 'file';
'process': ( files: File[] ) => Promise<Song[]>;
}
export type ImportTypes = CloudImport | FileImport;
export const isShowingImportTypePicker = ref( false );
export const importers: Ref<( CloudImport | FileImport )[]> = ref( [] );
export const openImportTypePicker = ( options: ( CloudImport | FileImport )[] ) => {
isShowingImportTypePicker.value = true;
importers.value = options;
};
-16
View File
@@ -1,16 +0,0 @@
import {
type Ref,
ref
} from 'vue';
import type {
CloudImport
} from './importTypePicker';
export const isShowingSearchView = ref( false );
export const searchOpts: Ref<CloudImport | null> = ref( null );
export const openSearchInterface = ( options: CloudImport ) => {
searchOpts.value = options;
isShowingSearchView.value = true;
};
-16
View File
@@ -1,16 +0,0 @@
import '@fortawesome/fontawesome-free/css/all.css';
import App from './App.vue';
import {
createApp
} from 'vue';
import {
createPinia
} from 'pinia';
import router from './router';
const app = createApp( App );
app.use( createPinia() );
app.use( router );
app.mount( '#app' );
-64
View File
@@ -1,64 +0,0 @@
import {
useAuthStore
} from '@/stores/authstore';
import {
createRouter, createWebHistory
} from 'vue-router';
const router = createRouter( {
'history': createWebHistory( import.meta.env.BASE_URL ),
'routes': [
{
'path': '/',
'name': 'home',
'component': () => import( '@/views/StartPage.vue' ),
'meta': {
'title': 'Home',
'auth': false
}
},
{
'path': '/app',
'name': 'app',
'component': () => import( '@/views/AppView.vue' ),
'meta': {
'title': 'Player',
'auth': true
}
},
{
'path': '/:pathMatch(.*)*',
'name': 'NotFound',
'component': () => import( '@/views/404View.vue' ),
'meta': {
'title': '404 :: Page not found',
'transition': 'scale'
}
}
]
} );
router.beforeEach( ( to, from ) => {
const store = useAuthStore();
if ( to.meta.authRequired && !store.isAuth ) {
return {
'name': 'login'
};
} else if ( ( to.name === 'login' && store.isAuth ) || ( to.name === 'signup' && store.isAuth ) ) {
return {
'name': 'app-home'
};
}
} );
router.afterEach( to => {
window.scrollTo( {
'top': 0,
'behavior': 'smooth'
} );
document.title = to.meta.title ? to.meta.title + ' - MusicPlayer' : 'MusicPlayer';
// NProgress.done();
} );
export default router;
View File
-110
View File
@@ -1,110 +0,0 @@
.mp-player {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
.fa-solid {
font-size: 2.5rem;
cursor: pointer;
transition: font-size ease 0.5s;
user-select: none;
&:hover {
font-size: 2.7rem;
}
}
>.controls {
display: flex;
align-items: center;
justify-content: center;
>.play-pause {
cursor: pointer;
&.paused {
>.fa-pause {
display: none;
}
>.fa-play {
display: unset;
}
}
}
>.quick-seek {
position: relative;
&::after {
content: "10";
font-family: sans-serif;
font-weight: bold;
font-size: 40%;
position: absolute;
left: 0;
top: 5%;
height: 95%;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
}
}
}
>.time {
display: flex;
width: 85%;
margin-left: auto;
margin-right: auto;
>.duration {
margin-left: auto;
}
}
.progress {
width: 95%;
}
>.bottom-bar {
>.fa-shuffle {
&.active {
background-color: var(--primary-color);
color: var(--secondary-color);
border-radius: 10px;
}
}
>.repeat {
position: relative;
padding: 5px;
&.once,
&.all {
background-color: var(--primary-color);
color: var(--secondary-color);
border-radius: 10px;
}
&.once::after {
position: absolute;
content: "1";
font-size: 40%;
font-family: sans-serif;
top: 5px;
right: 5px;
background-color: var(--accent-color);
width: 1.5em;
height: 1.5em;
display: flex;
align-items: center;
justify-content: center;
border-radius: 1em;
line-height: 100%;
}
}
}
}
-37
View File
@@ -1,37 +0,0 @@
.progressbar {
position: relative;
width: 100%;
cursor: pointer;
user-select: none;
>.back {
width: 100%;
background-color: var(--accent-background);
height: 10px;
border-radius: 10px;
overflow: hidden;
>div {
background-color: var(--accent-color);
height: 10px;
}
}
>.click-target {
width: 100%;
height: 10px;
position: absolute;
top: 0;
left: 0;
z-index: 10;
&.active {
cursor: grabbing;
width: 100vw;
height: 100vh;
transition: none;
position: fixed;
z-index: 10000;
}
}
}
-127
View File
@@ -1,127 +0,0 @@
.song-list-element {
display: flex;
width: 100%;
>.song-cover-wrapper {
height: 10rem;
width: 10rem;
position: relative;
margin-right: 20px;
.song-cover,
.fa-solid {
height: 10rem;
width: 10rem;
font-size: 7rem;
line-height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
>.play-overlay {
background-color: var(--overlay-color);
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
&.hover {
display: none;
}
}
&:hover {
>.play-overlay.hover {
display: block;
}
}
}
>.song-details {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
>* {
margin: 5px;
}
>h3 {
font-size: 1.8rem;
}
}
>.song-actions {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
margin-left: auto;
margin-right: 10px;
>* {
margin: 5px;
}
>.fa-solid {
font-size: 1.25rem;
cursor: pointer;
}
}
}
.playing-symbols {
position: absolute;
left: 0;
right: 0;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
margin: 0;
padding-left: 20%;
padding-right: 20%;
width: 60%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
.playing-bar {
height: 40%;
background-color: white;
width: 12%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY(1);
}
50% {
transform: scaleY(0.5);
}
100% {
transform: scaleY(1);
}
}
}
-44
View File
@@ -1,44 +0,0 @@
.search-results-wrapper {
width: 70vw;
height: 60vh;
overflow-x: hidden;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction: column;
&.placeholder {
justify-content: center;
}
>div {
display: flex;
align-items: center;
margin-bottom: 5px;
width: 75%;
cursor: pointer;
>div {
display: flex;
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
height: 100%;
>* {
margin: 5px;
}
}
>img {
height: 7rem;
width: 7rem;
margin-right: 20px;
}
>.fa-solid {
margin-left: auto;
font-size: 2rem;
}
}
}
-31
View File
@@ -1,31 +0,0 @@
.sortable-list {
overflow-y: scroll;
overflow-x: hidden;
height: 100%;
position: relative;
>.grip-target {
width: 100vw;
height: 100vh;
position: fixed;
top: 0;
left: 0;
cursor: grabbing;
}
>.movable {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 5px;
&.moving {
position: fixed;
}
>i {
cursor: move;
}
}
}
View File
-14
View File
@@ -1,14 +0,0 @@
import {
defineStore
} from 'pinia';
import {
ref
} from 'vue';
export const useAuthStore = defineStore( 'authstore', () => {
const isAuth = ref( false );
return {
isAuth
};
} );
-7
View File
@@ -1,7 +0,0 @@
import type {
MusicKitObject
} from 'musickitjs-v3-types/MusicKit';
declare global {
var MusicKit: MusicKitObject;
}
-1
View File
@@ -1 +0,0 @@
export type RepeatMode = 'off' | 'one' | 'all';
-49
View File
@@ -1,49 +0,0 @@
// NOTE: For re-associating files with details here, can use dropdown in UI
export type Playlist = Song[];
export interface UrlToFileMapping {
[key: string]: string
}
export interface Song {
/**
* Where the song can be found
*/
'source': 'local' | 'applemusic';
/**
* The identifier for the song so it can be found again (such as Apple Music ID or URL for playback)
*/
'identifier': string;
/**
* Any additional identifiers (such as filename, etc) to associate
*/
'additional-identifier'?: string;
/**
* Additional info, such as dance type to be displayed on remote screens
*/
'additional-info': string;
/**
* The artist of the song
*/
'artist': string;
/**
* The name of the song
*/
'name': string;
/**
* The cover image as a URL
*/
'artwork': string;
/**
* Song duration
*/
'duration': number;
}
View File
-151
View File
@@ -1,151 +0,0 @@
import {
addSongList,
clearQueue,
removeSong,
shuffleList
} from './playlists/add';
import {
currentSource,
isPlaying,
queue,
queueIdx,
rawQueue,
repeat,
shuffle,
sources
} from './state';
import {
duration,
playbackPercentage,
startTracking,
stopTracking
} from './status-tracking';
import type {
RepeatMode
} from '../dtype/player';
import {
playIndex
} from './playlists';
const next = () => {
playIndex( ( queueIdx.value + 1 ) % queue.value.length );
};
const prev = () => {
// TODO: Back to beginning if more than some seconds have passed?
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
};
const play = () => {
if ( currentSource.value === '' ) return;
isPlaying.value = true;
sources[currentSource.value]?.play();
startTracking();
};
const pause = () => {
if ( currentSource.value === '' ) return;
isPlaying.value = false;
sources[currentSource.value]?.pause();
stopTracking();
};
/**
* Seek to a specific point in the song
* @param pos - Percentage of song, value in [0, 1]
*/
const seekTo = ( pos: number ) => {
if ( currentSource.value === '' ) return;
sources[currentSource.value]?.seekTo( pos );
};
const skip10 = () => {
if ( currentSource.value === '' ) return;
const source = sources[currentSource.value];
const duration = source?.getDuration() ?? -1;
seekTo( ( ( ( source?.getPlaybackPos() ?? 0 ) * duration ) + 10 ) / duration );
};
const back10 = () => {
if ( currentSource.value === '' ) return;
const source = sources[currentSource.value];
const duration = source?.getDuration() ?? -1;
seekTo( ( ( ( source?.getPlaybackPos() ?? 0 ) * duration ) - 10 ) / duration );
};
/**
* Turn on or off shuffle
* @param enabled - Whether to enable or disable shuffle
*/
const setShuffle = ( enabled: boolean ) => {
shuffle.value = enabled;
if ( enabled ) {
shuffleList();
queueIdx.value = 0;
} else {
const curr = queue.value[queueIdx.value];
queue.value = [];
for ( let i = 0; i < rawQueue.value.length; i++ ) {
if ( rawQueue.value[i] === curr )
queueIdx.value = i;
queue.value.push( rawQueue.value[i]! );
}
}
};
/**
* Change the repeat mode
* @param mode - The repeat mode to switch into
*/
const setRepeat = ( mode: RepeatMode ) => {
repeat.value = mode;
};
const getSources = (): string[] => {
return Object.keys( sources );
};
/**
* Add songs to the playlist from given source
* @param source - The ID of the source to add from
*/
const addSongFromSource = async ( source: string ) => {
sources[source]!.addSongsFromThisSource( addSongList );
};
export default {
play,
pause,
seekTo,
skip10,
back10,
setShuffle,
setRepeat,
playIndex,
getSources,
addSongFromSource,
next,
prev,
clearQueue,
removeSong,
queue,
queueIdx,
duration,
playbackPercentage,
repeat,
shuffle,
isPlaying
};
-106
View File
@@ -1,106 +0,0 @@
import {
queue,
queueIdx,
rawQueue,
shuffle
} from '../state';
import type {
Song
} from '@/ts/dtype/playlist';
export const addSongList = ( songs: Song[], first: boolean = false ) => {
if ( first ) {
// FIXME: Needs to insert at current index in queue, but where for rawQueue?
// Probably at end if shuffled, else insert into queue, then copy
rawQueue.value = songs.concat( rawQueue.value );
queue.value = songs.concat( queue.value );
} else {
rawQueue.value = rawQueue.value.concat( songs );
queue.value = queue.value.concat( songs );
}
};
export const clearQueue = () => {
queue.value = [];
rawQueue.value = [];
};
/**
* Remove a song from the given queue index
* @param idx - The index to remove at
* @returns True if successful, False if not
*/
export const removeSong = ( idx: number ) => {
if ( idx <= queueIdx.value ) return false;
const removed = queue.value.splice( idx, 1 )[0];
for ( let i = 0; i < rawQueue.value.length; i++ ) {
if ( rawQueue.value[ i ] === removed ) {
rawQueue.value.splice( i, 1 );
}
}
return true;
};
/**
* Move a song in the queue
* @param idx - The index to move
* @param newIdx - The index to move it to
* @returns True if successful, false if constraints were violated
*/
export const moveSong = ( idx: number, newIdx: number ) => {
if ( idx === newIdx ) return true;
if ( idx <= queueIdx.value || newIdx <= queueIdx.value || idx > queue.value.length || newIdx > queue.value.length )
return false;
// Affects only the queue if shuffled, else copy the queue into rawQueue
const movedEl = queue.value[ idx ]!;
if ( idx < newIdx )
for ( let i = idx + 1; i < Math.min( queue.value.length, newIdx + 1 ); i++ ) {
queue.value[ i - 1 ] = queue.value[ i ]!;
}
else if ( idx > newIdx )
for ( let i = idx + 1; i > Math.max( 0, newIdx + 1 ); i-- ) {
queue.value[ i ] = queue.value[ i - 1 ]!;
}
queue.value[ newIdx ] = movedEl;
if ( !shuffle.value ) {
// If we don't shuffle, the rawQueue should also be updated
rawQueue.value = [];
for ( const song of queue.value ) {
rawQueue.value.push( song );
}
}
return true;
};
/** Shuffle the song list */
export const shuffleList = () => {
const shuffled = rawQueue.value
.map( ( val, idx ) => {
return {
val,
idx,
'sort': Math.random()
};
} ).sort( ( a, b ) => a.sort - b.sort );
const q: Song[] = [];
// Make current song the first in the list
q.push( rawQueue.value[queueIdx.value]! );
for ( const el of shuffled ) {
if ( el.idx !== queueIdx.value )
q.push( el.val );
}
queue.value = q;
};
-36
View File
@@ -1,36 +0,0 @@
import {
currentSource,
queue,
queueIdx,
repeat,
sources
} from '../state';
import {
startTracking,
stopTracking
} from '../status-tracking';
/**
* Play a song at the given index of the queue. Wraps to 0 and end if index below 0 or above end
* @param idx - The index in the queue to play at
*/
export const playIndex = async ( idx: number ) => {
if ( idx >= queue.value.length ) {
idx = repeat.value === 'all' ? 0 : -1;
} else if ( idx < 0 ) {
idx = repeat.value === 'all' ? queue.value.length - 1 : -1;
}
if ( idx < 0 ) return false;
sources[currentSource.value]?.stop();
stopTracking();
const nextSong = queue.value[ idx ]!;
queueIdx.value = idx;
currentSource.value = nextSong.source;
sources[currentSource.value]?.playSong( nextSong.identifier );
startTracking();
};
-2
View File
@@ -1,2 +0,0 @@
// TODO: If local files contained, prompt user to select files, then associate based on file names
export const load = () => {};
-103
View File
@@ -1,103 +0,0 @@
import type {
Song
} from '@/ts/dtype/playlist';
// TODO: Add search interface elements to plugin's args (such as a search interface)
export type PlayerSourcePluginInitializer = () => Promise<PlayerSourcePlugin>;
export interface PlayerSourcePlugin {
/**
* The name of the source that is displayed in the UI
*/
'name': string;
/**
* The identifier used in the playlist files
*/
'id': string;
/**
* Font-Awesome icon name (without fa and needs to be of free icons
*/
'icon'?: string;
/**
* Describe how files are loaded here
*/
'loading': {
/**
* Set to true if the user needs to load files
*/
'requiresLocalFiles': true;
/**
* The allowed MIME types for loading associated files
*/
'MIMETypes': string;
/**
* Function used to associate the file to the songs of the playlist
* @param files - The files that were picked by the user
* @returns A promise resolving to an array of song identifiers (corresponding to Song.identifier, for association)
*/
'association': ( files: File[] ) => Promise<string[]>;
} | {
/**
* Set to true if the user needs to load files
*/
'requiresLocalFiles': false;
};
/**
* Implement any login flow here. Optional
*/
'login'?: () => Promise<boolean>;
/**
* Play a song by its ID. Should start playing it immediately
*/
'playSong': ( id: string ) => Promise<void>;
/**
* Continue playing the song
*/
'play': () => void;
/**
* Pause the currently playing song
*/
'pause': () => void;
/**
* Stop / unload the current song. This indicates that either the playlist was cleared
* or a next song is about to be loaded (possibly from another plugin)
*/
'stop': () => void;
/**
* Seek to a specific point in the song.
* @param pos is always given as a percentage (pos \in [0, 1])
*/
'seekTo': ( pos: number ) => void;
/**
* Return the playback position as a percentage of the duration. (return value \in [0, 1])
*/
'getPlaybackPos': () => number;
/**
* Return the song duration in seconds.
*/
'getDuration': () => number;
/**
* Check if source is available for playback
*/
'available': () => boolean;
/**
* Called when user adds another song to the playlist via this source.
* You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc
*/
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
}
-34
View File
@@ -1,34 +0,0 @@
import type {
PlayerSourcePlugin,
PlayerSourcePluginInitializer
} from '../interface';
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
const player = document.createElement( 'audio' );
const playSong = async ( id: string ) => {
player.src = id;
player.play();
};
return {
'available': () => true,
'id': 'local',
'name': 'Local Disk',
'play': player.play,
'getPlaybackPos': () => player.currentTime / player.duration,
'getDuration': () => player.duration,
playSong,
'seekTo': pos => player.currentTime = pos,
'pause': player.pause,
'stop': () => player.src = '',
// TODO: Implement
'addSongsFromThisSource': async () => [],
'loading': {
'requiresLocalFiles': true,
'MIMETypes': 'audio/mp3,audio/wav',
// TODO: Implement
'association': async () => []
}
};
};
@@ -1,79 +0,0 @@
import type {
PlayerSourcePlugin,
PlayerSourcePluginInitializer
} from '../interface';
import type {
Song
} from '@/ts/dtype/playlist';
import {
addFromAppleMusic
} from './search';
import {
musicKitPlayback
} from './playback';
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
return new Promise( ( resolve, reject ) => {
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', {
'credentials': 'include'
} );
if ( res.status === 200 ) {
const token = await res.text();
const instance = await window.MusicKit.configure( {
'developerToken': token,
'app': {
'name': 'MusicPlayer',
'build': '4'
// 'icon': 'https://music.janishutz.com/logo.jpg'
},
'storefrontId': storefront.toUpperCase()
} );
const controls = musicKitPlayback( instance );
const login = async (): Promise<boolean> => {
try {
await instance.authorize();
return true;
} catch {
return false;
}
};
return {
'available': controls.getLoggedIn,
'play': controls.play,
'pause': controls.pause,
'stop': controls.stop,
'playSong': controls.playSong,
'getDuration': controls.getDuration,
'getPlaybackPos': controls.getPlaybackPos,
'id': 'applemusic',
'name': 'Apple Music',
'seekTo': controls.seekTo,
'login': login,
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
'loading': {
'requiresLocalFiles': false
}
};
} else {
throw new Error( 'ERR_AUTH' );
}
};
if ( !window.MusicKit ) {
document.addEventListener( 'musickitloaded', () => {
init( storefront )
.then( resolve )
.catch( reject );
} );
} else {
init( storefront )
.then( resolve )
.catch( reject );
}
} );
};
@@ -1,51 +0,0 @@
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
const play = () => {
musickitInstance.play();
};
const pause = () => {
musickitInstance.pause();
};
const seekTo = ( pos: number ) => {
musickitInstance.seekToTime( pos * musickitInstance.currentPlaybackDuration );
};
const playSong = async ( id: string ) => {
await musickitInstance.setQueue( {
'song': id
} );
musickitInstance.play();
};
const stop = () => {
musickitInstance.stop();
};
const getPlaybackPos = (): number => {
return musickitInstance.currentPlaybackProgress;
};
const getDuration = (): number => {
return musickitInstance.currentPlaybackDuration;
};
const getLoggedIn = (): boolean => {
return musickitInstance.isAuthorized;
};
return {
play,
pause,
seekTo,
playSong,
stop,
getPlaybackPos,
getDuration,
getLoggedIn
};
};
-26
View File
@@ -1,26 +0,0 @@
import type {
Artwork
} from 'musickitjs-v3-types/enums';
export interface AppleMusicApiSearchResult {
'results': {
'songs': {
'data': AppleMusicSongData[],
'href': string;
}
};
}
export interface AppleMusicSongData {
'id': string,
'type': string;
'href': string;
'attributes': {
'albumName': string;
'artistName': string;
'artwork': Artwork,
'name': string;
'genreNames': string[];
'durationInMillis': number;
}
}
@@ -1,35 +0,0 @@
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
import {
openImportTypePicker
} from '@/composables/importTypePicker';
import {
searchPlaylists
} from './playlists';
import {
searchSongs
} from './songs';
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
const songs = await searchSongs( instance, cb );
const playlists = await searchPlaylists( instance, cb );
openImportTypePicker( [
{
'name': 'Songs',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search
},
{
'name': 'Playlists',
'type': 'cloud',
'addSelected': playlists.addSelected,
'search': playlists.search
}
] );
};
@@ -1,24 +0,0 @@
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
// TODO: Get all playlists
const search = async ( term: string ): Promise<Song[]> => {
return [];
};
const addSelected = async ( idx: number ) => {
// TODO: Implement
cb( [] );
};
return {
search,
addSelected
};
};
@@ -1,46 +0,0 @@
import type {
AppleMusicApiSearchResult
} from './dtype';
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
let songs: Song[] = [];
const search = async ( term: string ): Promise<Song[]> => {
const params = {
'term': term,
'types': [ 'songs' ]
};
const results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
songs = [];
for ( const result of results ) {
songs.push( {
'duration': Math.round( result.attributes.durationInMillis / 1000 ),
'name': result.attributes.name,
'additional-info': '',
'artist': result.attributes.artistName,
'artwork': window.MusicKit.formatArtworkURL( result.attributes.artwork, 1000, 1000 ),
'identifier': result.id,
'source': 'applemusic'
} );
}
return songs;
};
const addSelected = async ( idx: number ) => {
cb( [ songs[idx]! ] );
};
return {
search,
addSelected
};
};
-53
View File
@@ -1,53 +0,0 @@
import {
type Ref,
ref
} from 'vue';
import type {
PlayerSourcePlugin
} from './plugins/interface';
import type {
Playlist
} from '../dtype/playlist';
import type {
RepeatMode
} from '../dtype/player';
import {
useLocalPlayer
} from './plugins/local';
import {
useMusicKit
} from './plugins/musickit';
export const sources: {
[key: string]: PlayerSourcePlugin
} = {};
export const currentSource = ref( '' );
export const rawQueue: Ref<Playlist> = ref( [] );
export const queueIdx = ref( 0 );
export const queue: Ref<Playlist> = ref( [] );
export const isPlaying = ref( false );
export const shuffle = ref( false );
export const repeat: Ref<RepeatMode> = ref( 'off' );
const initSources = async () => {
try {
sources['applemusic'] = await useMusicKit();
} catch {
console.warn( 'MusicKitJS intialization failed' );
}
try {
sources['local'] = await useLocalPlayer();
} catch {
console.warn( 'Local Player intialization failed' );
}
};
initSources();
-49
View File
@@ -1,49 +0,0 @@
import {
currentSource,
queueIdx,
repeat,
sources
} from './state';
import {
playIndex
} from './playlists';
import {
ref
} from 'vue';
// Tracking of time and player status
let interval = -1;
export const playbackPercentage = ref( 0 );
export const duration = ref( 0 );
export const startTracking = () => {
if ( interval === -1 ) {
interval = setInterval( tracker, 250 );
}
duration.value = sources[currentSource.value]?.getDuration() ?? -1;
};
const tracker = () => {
playbackPercentage.value = sources[currentSource.value]?.getPlaybackPos() ?? -1;
if ( playbackPercentage.value > duration.value ) {
if ( repeat.value === 'one' ) {
sources[currentSource.value]?.seekTo( 0 );
} else {
stopTracking();
playIndex( queueIdx.value + 1 );
}
}
};
export const stopTracking = () => {
try {
clearInterval( interval );
interval = -1;
} catch {
// empty
}
};
View File

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