37 Commits
V3.0.0 ... dev

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -1,30 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

View File

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

View File

@@ -1,46 +0,0 @@
# MusicPlayerV2-GUI
This template should help get you started developing with Vue 3 in Vite.
## Recommended IDE Setup
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
## Type Support for `.vue` Imports in TS
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
1. Disable the built-in TypeScript Extension
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
## Customize configuration
See [Vite Configuration Reference](https://vitejs.dev/config/).
## Project Setup
```sh
npm install
```
### Compile and Hot-Reload for Development
```sh
npm run dev
```
### Type-Check, Compile and Minify for Production
```sh
npm run build
```
### Lint with [ESLint](https://eslint.org/)
```sh
npm run lint
```

View File

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

View File

@@ -1,22 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200">
<!-- TODO: Update URL -->
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
<script src="https://static.janishutz.com/libs/jquery/jquery.min.js"></script>
<script src="https://id.janishutz.com/sdk/sdk.min.js"></script>
<!-- <script src="http://localhost:8080/sdk/sdk.min.js"></script> -->
<title>MusicPlayer</title>
</head>
<body>
<div id="app"></div>
<script>
localStorage.setItem( 'music-player-config', 'sse' );// Or 'ws'
</script>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +0,0 @@
{
"name": "musicplayerv2-gui",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"build-only": "vite build",
"type-check": "vue-tsc --build --force",
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
},
"dependencies": {
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
"@jridgewell/sourcemap-codec": "^1.4.15",
"@melloware/coloris": "^0.24.0",
"@rollup/plugin-inject": "^5.0.5",
"buffer": "^6.0.3",
"colorthief": "^2.2.0",
"music-metadata-browser": "^2.5.10",
"musickit-typescript": "^1.2.4",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.5",
"vite-plugin-node-polyfills": "^0.22.0",
"vue": "^3.4.15",
"vue-router": "^4.2.5"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.3",
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.11.10",
"@vitejs/plugin-vue": "^5.0.3",
"@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"eslint": "^8.49.0",
"eslint-plugin-vue": "^9.17.0",
"npm-run-all2": "^6.1.1",
"typescript": "~5.3.0",
"vite": "^5.0.11",
"vue-tsc": "^2.0.29"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,210 +0,0 @@
<!--
* libreevent - App.vue
*
* Created by Janis Hutz 05/14/2023, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
-->
<template>
<div>
<button @click="changeTheme();" id="themeSelector" title="Toggle between light and dark mode"><span class="material-symbols-outlined" v-html="theme"></span></button>
<router-view v-slot="{ Component, route }" id="main-view">
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
<component :is="Component"></component>
</transition>
</router-view>
</div>
</template>
<style>
body {
background-color: var( --background-color );
}
:root, :root.light {
--primary-color: #0a1520;
--secondary-color: white;
--background-color: rgb(221, 221, 221);
--nav-background: white;
--hover-color: #00457a;
--popup-color: rgb(224, 224, 224);
--overlay-color: rgba(0, 0, 0, 0.7);
--PI: 3.14159265358979;
--gray-color: rgb(53, 53, 53);
--footer-background: rgb(233, 233, 233);
--accent-background: rgb(195, 235, 243);
--loading-color: rgb(167, 167, 167);
--slider-color: rgb(119, 132, 255);
}
:root.dark {
--primary-color: white;
--secondary-color: black;
--background-color: rgb(32, 32, 32);
--nav-background: rgb(54, 54, 54);
--popup-color: rgb(58, 58, 58);
--hover-color: #007ddd;
--overlay-color: rgba(104, 104, 104, 0.575);
--gray-color: rgb(207, 207, 207);
--footer-background: rgb(53, 53, 53);
--accent-background: rgb(24, 12, 58);
--loading-color: rgb(65, 65, 65);
--slider-color: rgb(119, 132, 255);
}
@media ( prefers-color-scheme: dark ) {
:root {
--primary-color: white;
--secondary-color: black;
--background-color: rgb(32, 32, 32);
--nav-background: rgb(54, 54, 54);
--popup-color: rgb(58, 58, 58);
--hover-color: #007ddd;
--overlay-color: rgba(104, 104, 104, 0.575);
--gray-color: rgb(207, 207, 207);
--footer-background: rgb(53, 53, 53);
--accent-background: rgb(24, 12, 58);
--loading-color: rgb(65, 65, 65);
--slider-color: rgb(119, 132, 255);
}
}
::selection {
background-color: #7c8cec;
color: white;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
font-size: 17px;
}
#app {
transition: 0.5s;
background-color: var( --background-color );
font-family: 'Plus Jakarta Sans', sans-serif;
/* font-family: Avenir, Helvetica, Arial, sans-serif; */
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: var( --primary-color );
display: flex;
flex-direction: column;
flex-grow: 1;
width: 100vw;
margin: 0;
}
#main-view {
min-height: 60vh;
}
.scale-enter-active,
.scale-leave-active {
transition: all 0.5s ease;
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.9);
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.4s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.material-symbols-outlined {
font-variation-settings:
'FILL' 0,
'wght' 400,
'GRAD' 0,
'opsz' 48
}
.clr-open {
border: black solid 1px !important;
}
#themeSelector {
position: fixed;
top: 10px;
left: 10px;
background: none;
border: none;
color: var( --primary-color );
cursor: pointer;
}
</style>
<style>
.fancy-button {
text-decoration: none;
color: white;
padding: 20px;
border-radius: 20px;
border: none;
background: linear-gradient( 45deg, rgb(0, 33, 139), rgb(151, 0, 0) );
font-size: larger;
transition: all 0.5s;
background-size: 150%;
cursor: pointer;
}
.fancy-button:hover {
border-radius: 5px;
background-position: 50%;
}
.fancy-button-inactive {
background: linear-gradient( 45deg, rgba(0, 33, 139, 0.6), rgba(151, 0, 0, 0.6) );
cursor: not-allowed;
}
.fancy-button-inactive:hover {
border-radius: 20px;
background-position: 0px;
}
</style>
<script setup lang="ts">
import { ref } from 'vue';
import { RouterView } from 'vue-router';
const theme = ref( 'light_mode' );
const changeTheme = () => {
if ( theme.value === 'dark_mode' ) {
document.documentElement.classList.remove( 'dark' );
document.documentElement.classList.add( 'light' );
localStorage.setItem( 'theme', 'light_mode' );
theme.value = 'light_mode';
} else if ( theme.value === 'light_mode' ) {
document.documentElement.classList.remove( 'light' );
document.documentElement.classList.add( 'dark' );
localStorage.setItem( 'theme', 'dark_mode' );
theme.value = 'dark_mode';
}
}
theme.value = localStorage.getItem( 'theme' ) ?? '';
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === 'dark_mode' ) {
document.documentElement.classList.add( 'dark' );
theme.value = 'dark_mode';
} else {
document.documentElement.classList.add( 'light' );
theme.value = 'light_mode';
}
</script>

View File

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

Before

Width:  |  Height:  |  Size: 3.3 KiB

View File

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

Before

Width:  |  Height:  |  Size: 276 B

View File

@@ -1,35 +0,0 @@
<template>
<div>
<h1>Library</h1>
<playlistsView :playlists="$props.playlists" @selected-playlist="( id ) => selectPlaylist( id )" :is-logged-in="$props.isLoggedIn"
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"></playlistsView>
</div>
</template>
<script setup lang="ts">
import playlistsView from '@/components/playlistsView.vue';
import type { ReadFile } from '@/scripts/song';
const emits = defineEmits( [ 'selected-playlist', 'custom-playlist' ] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );
}
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
emits( 'custom-playlist', playlist );
}
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
},
'isLoggedIn': {
'default': false,
'type': Boolean,
'required': true,
}
} );
</script>

View File

@@ -1,384 +0,0 @@
<!-- eslint-disable no-undef -->
<template>
<div id="notifications">
<div class="message-box" :class="[ location, size ]" :style="'z-index: ' + ( messageType === 'hide' ? '-1' : '1000' )">
<div class="message-container" :class="messageType">
<button @click="handleNotifications();" class="close-notification"><span class="material-symbols-outlined close-notification-icon">close</span></button>
<span class="material-symbols-outlined types hide" v-if="messageType == 'hide'">question_mark</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'ok'" style="background-color: green;">done</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'error'" style="background-color: red;">close</span>
<span class="material-symbols-outlined types progress-spinner" v-else-if="messageType == 'progress'" style="background-color: blue;">progress_activity</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'info'" style="background-color: lightblue;">info</span>
<span class="material-symbols-outlined types" v-else-if="messageType == 'warning'" style="background-color: orangered;">warning</span>
<p class="message" @click="notificationAction()">{{ notifications[ currentDID ] ? notifications[ currentDID ].message : '' }}</p>
<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 { onUnmounted, ref, type 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>

View File

@@ -1,929 +0,0 @@
<template>
<div>
<div v-if="isShowingWarning" class="warning">
<h3>WARNING!</h3>
<p>A client display is being tampered with!</p>
<p>A desktop notification with a warning has already been dispatched.</p>
<button @click="dismissNotification()" class="simple-button">Ok</button>
<div class="flash"></div>
</div>
<div class="player">
<div :class="'main-player' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )">
<div :class="'song-name-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )" @click="controlUI( 'show' )">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo-player" v-if="coverArt === ''">
<img :src="coverArt" alt="MusicPlayer Logo" class="logo-player" v-else>
<div class="name-time">
<p class="song-name">{{ currentlyPlayingSongName }} <i v-if="currentlyPlayingSongArtist">by {{ currentlyPlayingSongArtist }}</i></p>
<div class="playback" v-if="!isShowingFullScreenPlayer">
<div class="playback-pos-wrapper">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p> / </p>
<p class="playback-duration">{{ niceDuration }}</p>
</div>
</div>
</div>
</div>
<div :class="'controls-wrapper' + ( isShowingFullScreenPlayer ? ' full-screen' : '' )" :style="playlist.length > 0 ? '' : 'pointer-events: none'">
<div class="main-controls">
<span class="material-symbols-outlined controls next-previous" @click="control( 'previous' )" id="previous" v-if="isShowingFullScreenPlayer">skip_previous</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'back' )" :style="'rotate: -' + 360 * clickCountBack + 'deg;'" v-if="isShowingFullScreenPlayer">replay_10</span>
<span class="material-symbols-outlined controls" v-if="isPlaying" @click="playPause()" id="play-pause">pause</span>
<span class="material-symbols-outlined controls" v-else @click="playPause()" id="play-pause">play_arrow</span>
<span class="material-symbols-outlined controls forward-back" @click="control( 'forward' )" :style="'rotate: ' + 360 * clickCountForward + 'deg;'" v-if="isShowingFullScreenPlayer">forward_10</span>
<span class="material-symbols-outlined controls next-previous" @click="control( 'next' )" id="next">skip_next</span>
</div>
<div class="slider-wrapper" v-if="isShowingFullScreenPlayer">
<div class="slider-pb-pos">
<p class="playback-pos">{{ nicePlaybackPos }}</p>
<p class="playback-duration" @click="toggleRemaining()" title="Toggle between remaining time and song duration">{{ niceDuration }}</p>
</div>
<sliderView :position="pos" :active="true" :duration="duration" name="main" @pos="( pos ) => goToPos( pos )"></sliderView>
</div>
<div class="shuffle-repeat" v-if="isShowingFullScreenPlayer">
<span class="material-symbols-outlined controls" @click="control( 'repeat' )" style="margin-right: auto;">repeat{{ repeatMode }}</span>
<div style="margin-right: auto; pointer-events: all;">
<span class="material-symbols-outlined controls" @click="control( 'start-share' )" title="Share your playlist on a public playlist page (opens a configuration window)" v-if="!isConnectedToNotifier">share</span>
<div v-else>
<span class="material-symbols-outlined controls" @click="control( 'stop-share' )" title="Stop sharing your playlist on a public playlist page">close</span>
<span class="material-symbols-outlined controls" @click="control( 'show-share' )" title="Show information on the share, including URL to connect to">info</span>
</div>
</div>
<span class="material-symbols-outlined controls" @click="control( 'shuffle' )">shuffle{{ shuffleMode }}</span>
</div>
</div>
</div>
</div>
<div :class="'playlist-view' + ( isShowingFullScreenPlayer ? '' : ' hidden' )">
<span class="material-symbols-outlined close-fullscreen" @click="controlUI( 'hide' )">close</span>
<playlistView :playlist="playlist" class="pl-wrapper" :currently-playing="currentlyPlayingSongIndex" :is-playing="isPlaying" :pos="pos"
@control="( action ) => { control( action ) }" @play-song="( song ) => { playSong( song ) }"
@add-new-songs="( songs ) => addNewSongs( songs )" @playlist-reorder="( move ) => moveSong( move )"
:is-logged-into-apple-music="player.isLoggedIn"
@add-new-songs-apple-music="( song ) => addNewSongFromObject( song )"
@delete-song="song => removeSongFromPlaylist( song )"
@clear-playlist="() => clearPlaylist()"
@send-additional-info="() => sendAdditionalInfo()"></playlistView>
</div>
<notificationsModule ref="notifications" location="bottomleft" size="bigger"></notificationsModule>
<popupModule @update="( data ) => popupReturnHandler( data )" ref="popup"></popupModule>
<audio src="" id="local-audio" controls="false"></audio>
</div>
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue';
import playlistView from '@/components/playlistView.vue';
import MusicKitJSWrapper from '@/scripts/music-player';
import sliderView from './sliderView.vue';
import type { ReadFile, Song, SongMove } from '@/scripts/song';
import { parseBlob } from 'music-metadata-browser';
import notificationsModule from './notificationsModule.vue';
import { useUserStore } from '@/stores/userStore';
import NotificationHandler from '@/scripts/notificationHandler';
import popupModule from './popupModule.vue';
const isPlaying = ref( false );
const repeatMode = ref( '' );
const shuffleMode = ref( '' );
const currentlyPlayingSongName = ref( 'Not playing' );
const currentlyPlayingSongIndex = ref( 0 );
const clickCountForward = ref( 0 );
const clickCountBack = ref( 0 );
const isShowingFullScreenPlayer = ref( false );
const player = new MusicKitJSWrapper();
const playlist: Ref<Song[]> = ref( [] );
const coverArt = ref( '' );
const nicePlaybackPos = ref( '00:00' );
const niceDuration = ref( '00:00' );
const isShowingRemainingTime = ref( false );
let isShowingRemainingTimeBackend = false;
const currentlyPlayingSongArtist = ref( '' );
const pos = ref( 0 );
const duration = ref( 0 );
const notifications = ref( notificationsModule );
const notificationHandler = new NotificationHandler();
const isConnectedToNotifier = ref( false );
const popup = ref( popupModule );
const roomName = ref( '' );
const isShowingWarning = ref( false );
let currentlyOpenPopup = '';
let logoutErrorNotification = -1;
const emits = defineEmits( [ 'playerStateChange' ] );
document.addEventListener( 'musicplayer:autherror', () => {
localStorage.setItem( 'close-tab', 'true' );
isConnectedToNotifier.value = false;
logoutErrorNotification = notifications.value.createNotification( 'You appear to have been logged out. Click to log in again!', 600, 'error', 'critical', '/', true );
} );
window.addEventListener( 'storage', () => {
if ( localStorage.getItem( 'login-ok' ) === 'true' ) {
notifications.value.cancelNotification( logoutErrorNotification );
notifications.value.createNotification( 'Logged in again. You will have to reconnect to the share!', 20, 'ok', 'normal' );
localStorage.removeItem( 'login-ok' );
}
} );
const playPause = () => {
isPlaying.value = !isPlaying.value;
if ( isPlaying.value ) {
player.control( 'play' );
startProgressTracker();
} else {
player.control( 'pause' );
stopProgressTracker();
}
}
const goToPos = ( position: number ) => {
player.goToPos( position );
pos.value = position;
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
const toggleRemaining = () => {
isShowingRemainingTime.value = !isShowingRemainingTime.value;
}
const control = ( action: string ) => {
if ( action === 'pause' ) {
isPlaying.value = false;
player.control( 'pause' );
stopProgressTracker();
notificationHandler.emit( 'playback-update', isPlaying.value );
} else if ( action === 'play' ) {
isPlaying.value = true;
player.control( 'play' );
startProgressTracker();
notificationHandler.emit( 'playback-update', isPlaying.value );
} else if ( action === 'repeat' ) {
if ( repeatMode.value === '' ) {
repeatMode.value = '_on';
player.setRepeatMode( 'all' );
} else if ( repeatMode.value === '_on' ) {
repeatMode.value = '_one_on';
player.setRepeatMode( 'once' );
} else {
repeatMode.value = '';
player.setRepeatMode( 'off' );
}
} else if ( action === 'shuffle' ) {
if ( shuffleMode.value === '' ) {
shuffleMode.value = '_on';
player.setShuffle( true );
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
} else {
shuffleMode.value = '';
player.setShuffle( false );
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}
getDetails();
} else if ( action === 'forward' ) {
clickCountForward.value += 1;
if( player.control( 'skip-10' ) ) {
startProgressTracker();
} else {
pos.value = player.getPlaybackPos();
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
} else if ( action === 'back' ) {
clickCountBack.value += 1;
if( player.control( 'back-10' ) ) {
startProgressTracker();
} else {
pos.value = player.getPlaybackPos();
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
}
} else if ( action === 'next' ) {
stopProgressTracker();
player.control( 'next' );
coverArt.value = '';
currentlyPlayingSongArtist.value = '';
currentlyPlayingSongName.value = 'Loading...';
startProgressTracker();
} else if ( action === 'previous' ) {
stopProgressTracker();
player.control( 'previous' );
coverArt.value = '';
currentlyPlayingSongArtist.value = '';
currentlyPlayingSongName.value = 'Loading...';
startProgressTracker();
} else if ( action === 'start-share' ) {
popup.value.openPopup( {
title: 'Define a share name',
popupType: 'input',
subtitle: 'A share allows others to join your playlist and see the current song, the playback position and the upcoming songs. You can get the link to the page, once the share is set up. Please choose a name, which will then be part of the URL with which others can join the share. The anti tamper feature notifies you, whenever a user leaves the fancy view.',
data: [
{
name: 'Share Name',
dataType: 'text',
id: 'roomName'
},
{
name: 'Use Anti-Tamper?',
dataType: 'checkbox',
id: 'useAntiTamper'
}
]
} );
currentlyOpenPopup = 'create-share';
} else if ( action === 'stop-share' ) {
if ( confirm( 'Do you really want to stop sharing?' ) ) {
notificationHandler.disconnect();
isConnectedToNotifier.value = false;
notifications.value.createNotification( 'Disconnected successfully!', 5, 'ok', 'normal' );
}
} else if ( action === 'show-share' ) {
popup.value.openPopup( {
title: 'Details on share',
subtitle: 'You are currently connected to share "' + roomName.value
+ '". \nYou can connect to it via <a href="https://music.janishutz.com/share/' + roomName.value + '" target="_blank">https://music.janishutz.com/share/' + roomName.value + '</a>'
+ '. \n\nYou can connect to the fancy showcase screen using this link: <a href="https://music.janishutz.com/fancy/' + roomName.value + '" target="_blank">https://music.janishutz.com/fancy/' + roomName.value + '</a>'
+ '. Be aware that this one will use significantly more system AND network resources, so only use that for a screen that is front and center, not for a QR code to have all people connect to.'
} );
currentlyOpenPopup = 'share-details';
}
}
const controlUI = ( action: string ) => {
if ( action === 'show' ) {
isShowingFullScreenPlayer.value = true;
isShowingRemainingTime.value = isShowingRemainingTimeBackend;
emits( 'playerStateChange', 'show' );
} else if ( action === 'hide' ) {
isShowingFullScreenPlayer.value = false;
isShowingRemainingTimeBackend = isShowingRemainingTime.value;
isShowingRemainingTime.value = false;
try {
prepNiceDurationTime( player.getPlayingSong() );
} catch ( err ) { /* empty */ }
emits( 'playerStateChange', 'hide' );
}
}
const getPlaylists = ( cb: ( data: object ) => void ) => {
player.getUserPlaylists( cb );
}
const logIntoAppleMusic = () => {
player.logIn();
}
const getAuth = (): boolean[] => {
return player.getAuth();
}
const skipLogin = () => {
player.init();
}
const selectPlaylist = ( id: string ) => {
currentlyPlayingSongArtist.value = '';
coverArt.value = '';
currentlyPlayingSongName.value = 'Loading...';
player.setPlaylistByID( id ).then( () => {
isPlaying.value = true;
startProgressTracker();
setTimeout( () => {
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}, 2000 );
} );
}
const selectCustomPlaylist = async ( pl: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing playlist', 200, 'progress', 'normal' );
playlist.value = [];
let plLoad: Song[] = [];
for ( let element in pl ) {
try {
plLoad.push( await fetchSongData( pl[ element ] ) );
} catch ( e ) {
console.error( e );
}
notifications.value.updateNotification( n, `Analyzing playlist (${element}/${pl.length})` );
}
playlist.value = plLoad;
player.setPlaylist( playlist.value );
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
setTimeout( () => {
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}, 2000 );
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'Playlist loaded', 10, 'ok', 'normal' );
}
const fetchSongData = ( songDetails: ReadFile ): Promise<Song> => {
return new Promise( ( resolve, reject ) => {
fetch( songDetails.url ).then( res => {
if ( res.status === 200 ) {
res.blob().then( blob => {
parseBlob( blob ).then( data => {
try {
player.findSongOnAppleMusic( data.common.title ?? songDetails.filename.split( '.' )[ 0 ] ).then( d => {
let url = d.data.results.songs.data[ 0 ].attributes.artwork.url;
url = url.replace( '{w}', String( d.data.results.songs.data[ 0 ].attributes.artwork.width ) );
url = url.replace( '{h}', String( d.data.results.songs.data[ 0 ].attributes.artwork.height ) );
const song: Song = {
artist: data.common.artist ?? d.data.results.songs.data[ 0 ].attributes.artistName,
title: data.common.title ?? d.data.results.songs.data[ 0 ].attributes.name,
duration: data.format.duration ?? ( d.data.results.songs.data[ 0 ].attributes.durationInMillis / 1000 ),
id: songDetails.url,
origin: 'disk',
cover: url
}
resolve( song );
} ).catch( e => {
console.error( e );
const song: Song = {
artist: data.common.artist ?? 'Unknown artist',
title: data.common.title ?? 'Unknown song title',
duration: data.format.duration ?? 1000,
id: songDetails.url,
origin: 'disk',
cover: ''
}
resolve( song );
} );
} catch ( err ) {
console.error( err );
alert( 'One of your songs was not loadable. (finalization-error)' )
}
} ).catch( e => {
console.error( e );
alert( 'One of your songs was not loadable. (parser-error)' );
reject( e );
} );
} ).catch( e => {
console.error( e );
alert( 'One of your songs was not loadable. (converter-error)' );
reject( e );
} );
} else {
console.error( res.status );
alert( 'One of your songs was not loadable. (invalid-response-code)' );
}
} ).catch( e => {
console.error( e );
alert( 'One of your songs was not loadable. (could-not-connect)' );
reject( e );
} );
} );
}
const getDetails = () => {
const details = player.getPlayingSong();
currentlyPlayingSongName.value = details.title;
coverArt.value = details.cover;
currentlyPlayingSongIndex.value = player.getQueueID();
playlist.value = player.getQueue();
currentlyPlayingSongArtist.value = details.artist;
}
const playSong = ( id: string ) => {
const p = player.getPlaylist();
currentlyPlayingSongArtist.value = '';
coverArt.value = '';
currentlyPlayingSongName.value = 'Loading...';
stopProgressTracker();
for ( const s in p ) {
if ( p[ s ].id === id ) {
player.prepare( parseInt( s ) );
startProgressTracker();
break;
}
}
}
let progressTracker = 0;
let hasReachedEnd = false;
let hasStarted = false;
const startProgressTracker = () => {
hasReachedEnd = false;
isPlaying.value = true;
let playingSong = player.getPlayingSong();
hasStarted = false;
pos.value = 0;
progressTracker = setInterval( () => {
pos.value = player.getPlaybackPos();
if ( pos.value > playingSong.duration - 1 && !hasReachedEnd ) {
stopProgressTracker();
hasReachedEnd = true;
if ( repeatMode.value === '_one_on' ) {
player.goToPos( 0 );
setTimeout( () => {
control( 'play' );
}, 500 );
} else {
control( 'next' );
}
}
if ( pos.value > 0 && !hasStarted ) {
getDetails();
playingSong = player.getPlayingSong();
prepNiceDurationTime( playingSong );
notificationHandler.emit( 'playlist-index-update', currentlyPlayingSongIndex.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
hasStarted = true;
}
const minuteCount = Math.floor( pos.value / 60 );
nicePlaybackPos.value = minuteCount + ':';
if ( ( '' + minuteCount ).length === 1 ) {
nicePlaybackPos.value = '0' + minuteCount + ':';
}
const secondCount = Math.floor( pos.value - minuteCount * 60 );
if ( ( '' + secondCount ).length === 1 ) {
nicePlaybackPos.value += '0' + secondCount;
} else {
nicePlaybackPos.value += secondCount;
}
if ( isShowingRemainingTime.value ) {
const minuteCounts = Math.floor( ( playingSong.duration - pos.value ) / 60 );
niceDuration.value = '-' + String( minuteCounts ) + ':';
if ( ( '' + minuteCounts ).length === 1 ) {
niceDuration.value = '-0' + minuteCounts + ':';
}
const secondCounts = Math.floor( ( playingSong.duration - pos.value ) - minuteCounts * 60 );
if ( ( '' + secondCounts ).length === 1 ) {
niceDuration.value += '0' + secondCounts;
} else {
niceDuration.value += secondCounts;
}
}
}, 100 );
}
const prepNiceDurationTime = ( playingSong: Song ) => {
duration.value = playingSong.duration;
const minuteCounts = Math.floor( ( playingSong.duration ) / 60 );
niceDuration.value = String( minuteCounts ) + ':';
if ( ( '' + minuteCounts ).length === 1 ) {
niceDuration.value = '0' + minuteCounts + ':';
}
const secondCounts = Math.floor( ( playingSong.duration ) - minuteCounts * 60 );
if ( ( '' + secondCounts ).length === 1 ) {
niceDuration.value += '0' + secondCounts;
} else {
niceDuration.value += secondCounts;
}
}
const stopProgressTracker = () => {
try {
clearInterval( progressTracker );
} catch ( _ ) { /* empty */ }
isPlaying.value = false;
notificationHandler.emit( 'playback-update', isPlaying.value );
}
const moveSong = ( move: SongMove ) => {
player.moveSong( move );
getDetails();
notificationHandler.emit( 'playlist-update', playlist.value );
}
const addNewSongs = async ( songs: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing new songs', 200, 'progress', 'normal' );
playlist.value = player.getQueue();
for ( let element in songs ) {
try {
playlist.value.push( await fetchSongData( songs[ element ] ) );
} catch ( e ) {
console.error( e );
}
notifications.value.updateNotification( n, `Analyzing new songs (${element}/${songs.length})` );
}
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
}
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'New songs added', 10, 'ok', 'normal' );
notificationHandler.emit( 'playlist-update', playlist.value );
}
const addNewSongFromObject = ( song: Song ) => {
playlist.value = player.getQueue();
playlist.value.push( song );
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
}
notificationHandler.emit( 'playlist-update', playlist.value );
}
const removeSongFromPlaylist = ( song: number ) => {
playlist.value = player.getQueue();
playlist.value.splice( song, 1 );
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
startProgressTracker();
}
notificationHandler.emit( 'playlist-update', playlist.value );
}
const clearPlaylist = () => {
playlist.value = [];
player.control( 'pause' );
stopProgressTracker();
isPlaying.value = false;
player.setPlaylist( [] );
currentlyPlayingSongArtist.value = '';
currentlyPlayingSongName.value = 'Not playing';
coverArt.value = '';
pos.value = 0;
notificationHandler.emit( 'playlist-update', playlist.value );
}
const sendAdditionalInfo = () => {
notifications.value.createNotification( 'Additional song info transmitted', 5, 'ok', 'normal' );
notificationHandler.emit( 'playlist-update', playlist.value );
}
emits( 'playerStateChange', isShowingFullScreenPlayer.value ? 'show' : 'hide' );
const userStore = useUserStore();
document.addEventListener( 'keydown', ( e ) => {
if ( !userStore.isUsingKeyboard ) {
if ( e.key === ' ' ) {
e.preventDefault();
playPause();
} else if ( e.key === 'ArrowRight' ) {
e.preventDefault();
control( 'next' );
} else if ( e.key === 'ArrowLeft' ) {
e.preventDefault();
control( 'previous' );
}
}
} );
const dismissNotification = () => {
isShowingWarning.value = false;
}
const popupReturnHandler = ( data: any ) => {
if ( currentlyOpenPopup === 'create-share' ) {
notificationHandler.connect( data.roomName, data.useAntiTamper ?? false ).then( () => {
roomName.value = notificationHandler.getRoomName();
isConnectedToNotifier.value = true;
notificationHandler.emit( 'playlist-index-update', currentlyPlayingSongIndex.value );
notificationHandler.emit( 'playback-update', isPlaying.value );
notificationHandler.emit( 'playback-start-update', new Date().getTime() - pos.value * 1000 );
notificationHandler.emit( 'playlist-update', playlist.value );
notifications.value.createNotification( 'Joined share "' + data.roomName + '"!', 5, 'ok', 'normal' );
// eslint-disable-next-line @typescript-eslint/no-unused-vars
notificationHandler.registerListener( 'tampering-msg', ( _ ) => {
isShowingWarning.value = true;
} );
} ).catch( e => {
if ( e === 'ERR_CONFLICT' ) {
notifications.value.createNotification( 'A share with this name exists already!', 5, 'error', 'normal' );
control( 'start-share' );
} else if ( e === 'ERR_UNAUTHORIZED' ) {
console.error( e );
localStorage.setItem( 'close-tab', 'true' );
logoutErrorNotification = notifications.value.createNotification( 'You appear to have been logged out. Click to log in again!', 20, 'error', 'normal', '/', true );
} else {
console.error( e );
notifications.value.createNotification( 'Could not create share!', 5, 'error', 'normal' );
}
} );
}
}
window.addEventListener( 'beforeunload', async () => {
await notificationHandler.disconnect();
} );
defineExpose( {
logIntoAppleMusic,
getPlaylists,
controlUI,
getAuth,
skipLogin,
selectPlaylist,
selectCustomPlaylist,
} );
</script>
<style scoped>
.player {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
transition: all 1s;
}
.main-player {
height: 12vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
transition: all 1s;
position: relative
}
.main-player.full-screen {
flex-direction: column;
height: 30vh;
min-height: 250px;
}
.song-name-wrapper {
margin-top: 10px;
cursor: pointer;
margin-left: 10px;
width: 100%;
height: 100%;
text-align: justify;
font-weight: bold;
font-size: 1.25rem;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-grow: 0;
}
.song-name-wrapper.full-screen {
flex-direction: row;
max-height: 50%;
align-items: center;
}
.name-time {
margin-right: auto;
margin-left: 10px;
}
.song-name {
margin: 0;
height: fit-content;
}
.slider-wrapper {
position: relative;
width: 90%;
margin-bottom: 5px;
}
.shuffle-repeat {
margin-top: 5px;
display: flex;
width: 80%;
position: relative;
z-index: 5;
}
.slider-pb-pos {
display: flex;
justify-content: center;
align-items: center;
}
.slider-pb-pos .playback-duration {
margin-top: 5px;
margin-left: auto;
user-select: none;
cursor: pointer;
}
.slider-pb-pos .playback-pos {
margin-top: 5px;
user-select: none;
user-select: none;
}
.logo-player {
cursor: pointer;
height: 80%;
width: auto;
margin-left: 10px;
}
.hidden {
height: 0%;
}
.controls {
cursor: pointer;
font-size: 1.75rem;
user-select: none;
transition: all 0.5s ease-in-out;
}
.controls-wrapper {
margin-right: 30px;
display: flex;
justify-content: center;
align-items: center;
flex-wrap: nowrap;
}
.controls-wrapper.full-screen {
flex-direction: column;
width: 80%;
}
.main-controls {
display: flex;
justify-content: center;
align-items: center;
}
#play-pause {
font-size: 2.5rem;
}
.controls:hover {
transform: scale(1.25);
}
.forward-back {
transition: all 0.4s ease-in-out;
}
.next-previous {
transform: translateX(0px);
transition: all 0s;
}
.next-previous:hover {
transform: scale(1);
}
#previous:active {
transform: translateX(-10px);
}
#next:active {
transform: translateX(10px);
}
.close-fullscreen {
position: absolute;
top: 10px;
right: 10px;
font-size: 2.5rem;
color: var( --primary-color );
cursor: pointer;
transition: all 0.5s ease-in-out;
}
.close-fullscreen:hover {
transform: scale( 1.25 );
}
.hidden .close-fullscreen {
display: none;
}
.pl-wrapper {
height: 70vh;
}
.playback {
width: fit-content;
bottom: -20px;
left: 7%;
font-weight: normal;
font-size: 1rem;
}
.playback.full-screen {
left: 30%;
position: absolute;
width: 40%;
}
.playback-pos-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
user-select: none;
}
.playback-pos-wrapper p {
margin: 0;
}
.playback-pos-wrapper.full-screen p {
margin-bottom: 15px;
}
.playback-pos-wrapper.full-screen .playback-duration {
margin-left: auto;
}
@media only screen and (min-width: 800px) {
.slider-wrapper {
width: 40%;
}
.shuffle-repeat {
width: 35%;
}
.main-controls .controls {
font-size: 2rem;
}
#play-pause {
font-size: 3rem;
}
}
#local-audio {
position: fixed;
bottom: -50%;
}
</style>
<style scoped>
.warning {
display: flex;
justify-content: center;
align-items: center;
width: 40vw;
height: 50vh;
font-size: 2vh;
background-color: rgb(255, 0, 0);
color: white;
position: fixed;
right: 1vh;
top: 1vh;
flex-direction: column;
z-index: 1001;
}
.warning h3 {
font-size: 4vh;
}
.warning .flash {
background-color: rgba(255, 0, 0, 0.4);
animation: flashing linear infinite 1s;
width: 100vw;
height: 100vh;
top: 0;
left: 0;
position: fixed;
z-index: -1;
}
@keyframes flashing {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
100% {
opacity: 0;
}
}
.simple-button {
padding: 10px 15px;
border: none;
background-color: rgb(0, 0, 51);
color: white;
font-size: 1rem;
border-radius: 15px;
cursor: pointer;
transition: all 0.5s;
}
.simple-button:hover {
border-radius: 5px;
}
</style>

View File

@@ -1,375 +0,0 @@
<template>
<div>
<h1>Queue</h1>
<input type="file" multiple accept="audio/*" id="more-songs" class="small-buttons">
<button @click="addNewSongs()" class="small-buttons" title="Load selected files"><span class="material-symbols-outlined">upload</span></button>
<button @click="openSearch()" v-if="$props.isLoggedIntoAppleMusic" class="small-buttons" title="Search Apple Music for the song"><span class="material-symbols-outlined">search</span></button>
<button @click="clearPlaylist()" class="small-buttons" title="Clear the playlist"><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 class="playlist-box" id="pl-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 class="song" v-for="song in computedPlaylist" v-bind:key="song.id"
: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 :src="song.cover" alt="Song cover" class="song-cover">
<div v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && $props.isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
</div>
</div>
<span class="material-symbols-outlined play-icon" @click="control( 'play' )" v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' )">play_arrow</span>
<span class="material-symbols-outlined play-icon" @click="play( song.id )" v-else>play_arrow</span>
<span class="material-symbols-outlined pause-icon" @click="control( 'pause' )">pause</span>
<span class="material-symbols-outlined move-icon" @click="moveSong( song.id, 'up' )" title="Move song up" v-if="canBeMoved( 'up', song.id )">arrow_upward</span>
<span class="material-symbols-outlined move-icon" @click="moveSong( song.id, 'down' )" title="Move song down" v-if="canBeMoved( 'down', song.id )">arrow_downward</span>
<h3 class="song-title">{{ song.title }}</h3>
<div>
<input type="text" placeholder="Additional information for remote display" title="Additional information for remote display" v-model="song.additionalInfo" @focusin="kbControl( 'on' )" @focusout="kbControl( 'off' )">
<p class="playing-in">{{ getTimeUntil( song ) }}</p>
</div>
<button @click="deleteSong( song.id )" class="small-buttons" title="Remove this song from the queue" v-if="canBeMoved( 'down', song.id ) || canBeMoved( 'up', song.id )"><span class="material-symbols-outlined">delete</span></button>
</div>
</div>
<searchView ref="search" @selected-song="( song ) => { addNewSongsAppleMusic( song ) }"></searchView>
</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-title {
margin-left: 10px;
margin-right: auto;
}
.playing-symbols {
position: absolute;
left: 1vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
margin: 0;
width: 6rem;
height: 6rem;
background-color: rgba( 0, 0, 0, 0.6 );
}
.playing-symbols-wrapper {
width: 5rem;
height: 6rem;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.playing-bar {
height: 60%;
background-color: white;
width: 10%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY( 1 );
}
50% {
transform: scaleY( 0.5 );
}
100% {
transform: scaleY( 1 );
}
}
.play-icon, .pause-icon {
display: none;
width: 6rem;
height: 6rem;
object-fit: cover;
object-position: center;
font-size: 6rem;
cursor: pointer;
user-select: none;
}
.playing:hover .pause-icon {
display: block;
}
.playing:hover .playing-symbols {
display: none;
}
.song:hover .song-cover {
display: none;
}
.not-playing:hover .play-icon {
display: block;
}
.active-song .pause-icon {
display: block;
}
.active-song.not-playing .song-cover {
display: none;
}
.active-song .song-image, .active-song:hover .pause-icon {
display: none;
}
.move-icon {
font-size: 1.5rem;
cursor: pointer;
user-select: none;
}
.small-buttons {
margin-bottom: 10px;
font-size: 1rem;
background: none;
border: none;
cursor: pointer;
}
.small-buttons .material-symbols-outlined {
font-size: 1.5rem;
color: var( --primary-color );
transition: all 0.5s;
}
.small-buttons:hover .material-symbols-outlined {
transform: scale(1.1);
}
</style>

View File

@@ -1,106 +0,0 @@
<template>
<div class="playlists">
<h3 style="width: fit-content;">Your playlists</h3>
<div v-if="( $props.playlists ? $props.playlists.length < 1 : true ) && $props.isLoggedIn">
Loading...
<!-- TODO: Make prettier -->
</div>
<div v-else-if="!$props.isLoggedIn" class="not-logged-in">
<p>You are not logged into Apple Music. We therefore can't show you your playlists. <a href="" title="Refreshes the page, allowing you to log in">Change that</a></p>
<p>Use the button below to load songs from your local disk</p>
<input class="pl-loader-button" type="file" multiple="true" accept="audio/*" id="pl-loader"><br>
<button @click="loadPlaylistFromDisk()" class="pl-loader-button" id="load-button">Load</button>
<p v-if="!hasSelectedSongs">Please select at least one song to proceed!</p>
</div>
<div class="playlist-wrapper">
<div v-for="pl in $props.playlists" v-bind:key="pl.id" class="playlist" @click="selectPlaylist( pl.id )">
{{ pl.attributes.name }}
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ReadFile } from '@/scripts/song';
import { ref } from 'vue';
const hasSelectedSongs = ref( true );
defineProps( {
'playlists': {
'default': [],
'type': Array<any>,
'required': true,
},
'isLoggedIn': {
'default': false,
'type': Boolean,
'required': true,
}
} );
const loadPlaylistFromDisk = () => {
const fileURLList: ReadFile[] = [];
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
if ( allFiles.length > 0 ) {
hasSelectedSongs.value = true;
for ( let file = 0; file < allFiles.length; file++ ) {
fileURLList.push( { 'url': URL.createObjectURL( allFiles[ file ] ), 'filename': allFiles[ file ].name } );
}
emits( 'custom-playlist', fileURLList );
} else {
hasSelectedSongs.value = false;
}
}
const emits = defineEmits( [ 'selected-playlist', 'custom-playlist' ] );
const selectPlaylist = ( id: string ) => {
emits( 'selected-playlist', id );
}
</script>
<style scoped>
.playlists {
width: 100%;
height: 75vh;
overflow: hidden;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.playlist-wrapper {
width: 85%;
overflow-y: scroll;
overflow-x: hidden;
}
.playlist {
width: 100%;
padding: 15px;
border: solid var( --primary-color ) 1px;
border-radius: 5px;
margin: 1px;
cursor: pointer;
user-select: none;
}
.pl-loader-button {
background-color: white;
border: none;
padding: 10px;
border-radius: 5px;
margin: 5px;
font-size: 1rem;
cursor: pointer;
}
#load-button {
font-size: 1.5rem;
}
.not-logged-in {
width: 80%;
}
</style>

View File

@@ -1,304 +0,0 @@
<template>
<div>
<div :class="'popup-backdrop' + ( isShowingPopup ? '' : ' hidden' )" :style="'transform-origin: ' + transformOriginVertical + ';'">
<div class="popup-main">
<span class="material-symbols-outlined close-icon" @click="closePopup()">close</span>
<h2>{{ popupContent.title }}</h2>
<div v-if="popupContent.popupType === 'information' || popupContent.popupType === 'confirmation'" class="popup-content">
<p v-html="popupContent.subtitle"></p>
</div>
<div v-else class="popup-content">
<p v-if="isShowingIncomplete" class="incomplete-message">{{ popupContent.incompleteMessage ? popupContent.incompleteMessage : 'Some entries are not filled out. Please fill them out to proceed.' }}</p>
<p v-html="popupContent.subtitle"></p>
<div v-for="content in popupContent.data" v-bind:key="content.id" class="popup-content-wrapper">
<div v-if="content.dataType === 'text'">
<label :for="'text-' + content.id">{{ content.name }}</label><br>
<input type="text" :id="'text-' + content.id" v-model="data[ content.id ]" class="input">
</div>
<div v-else-if="content.dataType === 'number'">
<label :for="'number-' + content.id">{{ content.name }}</label><br>
<input type="number" :id="'number-' + content.id" v-model="data[ content.id ]" class="input">
</div>
<div v-else-if="content.dataType === 'checkbox'">
<label :for="'checkbox-' + content.id">{{ content.name }}</label><br>
<label class="switch">
<input type="checkbox" v-model="data[ content.id ]">
<span class="slider round"></span>
</label>
</div>
<div v-else-if="content.dataType === 'textbox'">
<label :for="'textarea-' + content.id">{{ content.name }}</label><br>
<textarea :id="'textarea-' + content.id" v-model="data[ content.id ]" class="textarea"></textarea>
</div>
<div v-else-if="content.dataType === 'colour'">
<label :for="'colour-' + content.id">{{ content.name }}</label><br>
<input type="text" :id="'colour-' + content.id" v-model="data[ content.id ]" class="input">
</div>
</div>
</div>
<div style="margin-top: 20px;">
<button @click="closePopup()" style="margin-right: 10px;" class="fancy-button" v-if="popupContent.popupType === 'confirmation'">Cancel</button>
<button @click="closePopupReturn()" class="fancy-button">Ok</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
type PopupType = 'confirmation' | 'information' | 'input';
interface PopupData {
/**
* What to display to the user in front of the input field
*/
name: string;
/**
* The type of data to display
*/
dataType: 'text' | 'number' | 'checkbox' | 'textbox' | 'colour';
/**
* ID that is used for internal usage only. May only contain alphanumerical characters, as well as dashes and underscores
*/
id: string;
}
interface PopupContent {
/**
* The title shown in big letters at the top of the popup
*/
title: string;
/**
* (OPTIONAL) The subtitle shown to the user in normal sized letters below title
*/
subtitle?: string;
/**
* (OPTIONAL) The message to show, if the user has not filled out all fields
*/
incompleteMessage?: string;
/**
* The type of popup (i.e. what it is for)
*/
popupType: PopupType;
/**
* (REQUIRED ONLY when popupType === 'input') The input fields to show
*/
data?: PopupData[];
}
interface Data {
[key: string]: any;
}
import "@melloware/coloris/dist/coloris.css";
import { ref, type Ref } from 'vue';
import Coloris from '@melloware/coloris';
Coloris.init();
const isShowingPopup = ref( false );
const transformOriginVertical = ref( '50% 50%' );
const data: Ref<Data> = ref( {} );
const isShowingIncomplete = ref( false );
const popupContent: Ref<PopupContent> = ref( {
title: 'Undefined popup title',
popupType: 'information',
subtitle: 'This popup was not configured correctly during development. Please send a bug-report at <a href="https://support.janishutz.com/index.php?a=add&category=7" target="_blank">support.janishutz.com</a> and inform us about where exactly you encountered this popup! We are sorry for the inconvenience'
} );
const closePopup = () => {
isShowingPopup.value = false;
Coloris.close();
}
const closePopupReturn = () => {
for ( let el in popupContent.value.data ) {
if ( !data.value[ popupContent.value.data[ parseInt( el ) ].id ] && popupContent.value.data[ parseInt( el ) ].dataType !== 'checkbox' ) {
isShowingIncomplete.value = true;
return;
}
}
closePopup();
if ( popupContent.value.popupType === 'confirmation' ) {
emit( 'update', true );
} else {
emit( 'update', data.value );
}
}
const openPopup = ( popupConfig: PopupContent, transformOrigin?: string ) => {
if ( transformOrigin ) {
transformOriginVertical.value = transformOrigin;
} else {
transformOriginVertical.value = '50% 50%';
}
data.value = {};
for ( let el in popupConfig.data ) {
if ( !popupConfig.data[ parseInt( el ) ].id ) {
console.warn( '[ popup ] Missing ID for input with name "' + popupConfig.data[ parseInt( el ) ].name + '"!' );
} else if ( popupConfig.data[ parseInt( el ) ].dataType === 'colour' ) {
Coloris( { el: '#colour-' + popupConfig.data[ parseInt( el ) ].id } );
}
}
isShowingPopup.value = true;
popupContent.value = popupConfig;
for ( const el in popupContent.value.data ) {
if ( popupContent.value.data[ parseInt( el ) ].dataType === 'checkbox' ) {
data.value[ popupContent.value.data[ parseInt( el ) ].id ] = false;
}
}
}
defineExpose( {
openPopup,
} );
const emit = defineEmits( [ 'update' ] );
</script>
<style scoped>
.popup-backdrop {
width: 100vw;
height: 100vh;
position: fixed;
background-color: var( --overlay-color );
top: 0;
left: 0;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
transition: all 0.5s;
transform: scale(1);
z-index: 99;
}
.incomplete-message {
color: red;
font-weight: 300;
font-style: italic;
margin-top: 0;
font-size: 0.8rem;
}
.hidden {
transform: scale(0);
}
.popup-main {
width: 40%;
height: 50%;
background-color: var( --secondary-color );
padding: 2.5%;
border-radius: 20px;
position: relative;
overflow-y: scroll;
display: block;
}
.close-icon {
position: absolute;
top: 20px;
right: 20px;
font-size: 2rem;
cursor: pointer;
user-select: none;
}
.popup-content {
position: unset;
height: 60%;
overflow-y: scroll;
}
.textarea {
width: 80%;
resize: vertical;
min-height: 30px;
border-radius: 10px;
border: solid var( --primary-color ) 1px;
padding: 5px;
}
.input {
padding: 5px;
border-radius: 10px;
border: solid var( --primary-color ) 1px;
}
.popup-content-wrapper {
margin-bottom: 10px;
}
</style>
<style scoped>
/* https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_switch */
.switch {
position: relative;
display: inline-block;
width: 60px;
height: 34px;
}
.switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
-webkit-transition: .4s;
transition: .4s;
}
input:checked + .slider {
background-color: #2196F3;
}
input:focus + .slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked + .slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
/* Rounded sliders */
.slider.round {
border-radius: 34px;
}
.slider.round:before {
border-radius: 50%;
}
</style>

View File

@@ -1,265 +0,0 @@
<template>
<div>
<div id="search-bar" :class="showsSearch ? 'search-shown' : ''">
<div id="search-box-wrapper">
<input type="text" v-model="searchText" id="search-box" placeholder="Type to search..." @keyup="e => { keyHandler( e ) }">
<div class="symbol-wrapper" id="search-symbol-wrapper">
<span class="material-symbols-outlined search-symbol" @click="search()">search</span>
</div>
<div :class="'search-result-wrapper' + ( searchText.length > 0 ? ' show-search-results' : '' )">
<div v-for="result in searchResults" v-bind:key="result.id"
:class="'search-result' + ( selectedProduct === result.id ? ' prod-selected' : '' )"
@mouseenter="removeSelection()" @click="select( result )">
<div :style="'background-image: url(' + result.attributes.artwork.url.replace( '{w}', '500' ).replace( '{h}', '500' ) + ');'" class="search-product-image"></div>
<div class="search-product-name"><p><b>{{ result.attributes.name }}</b> <i>by {{ result.attributes.artistName }}</i></p></div>
</div>
<div v-if="searchResults.length === 0 && searchText.length < 3">
<p>No results to show</p>
</div>
<div v-else-if="searchText.length < 3">
<p>Enter at least three characters to start searching</p>
</div>
</div>
</div>
<div class="symbol-wrapper">
<span class="material-symbols-outlined search-symbol" @click="controlSearch( 'hide' )" id="close-icon">close</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MusicKitJSWrapper from '@/scripts/music-player';
import type { AppleMusicSongData } from '@/scripts/song';
import { useUserStore } from '@/stores/userStore';
import { ref, type Ref } from 'vue';
const showsSearch = ref( false );
const searchText = ref( '' );
const selectedProduct = ref( '' );
let selectedProductIndex = -1;
const player = new MusicKitJSWrapper();
const updateSearchResults = () => {
if ( searchText.value.length > 2 ) {
player.findSongOnAppleMusic( searchText.value ).then( data => {
searchResults.value = data.data.results.songs.data ?? [];
selectedProductIndex = -1;
selectedProduct.value = '';
} );
}
}
const searchResults: Ref<AppleMusicSongData[]> = ref( [] );
const userStore = useUserStore();
const controlSearch = ( action: string ) => {
if ( action === 'show' ) {
userStore.setKeyboardUsageStatus( true );
showsSearch.value = true;
setTimeout( () => {
const searchBox = document.getElementById( 'search-box' ) as HTMLInputElement;
searchBox.focus();
}, 500 );
} else if ( action === 'hide' ) {
userStore.setKeyboardUsageStatus( false );
showsSearch.value = false;
}
searchText.value = '';
removeSelection();
}
const removeSelection = () => {
selectedProduct.value = '';
selectedProductIndex = -1;
}
const keyHandler = ( e: KeyboardEvent ) => {
if ( e.key === 'Escape' ) {
controlSearch( 'hide' );
} else if ( e.key === 'Enter' ) {
e.preventDefault();
if ( selectedProductIndex >= 0 ) {
select( searchResults.value[ selectedProductIndex ] );
controlSearch( 'hide' );
} else {
search();
}
} else if ( e.key === 'ArrowDown' ) {
e.preventDefault();
if ( selectedProductIndex < searchResults.value.length - 1 ) {
selectedProductIndex += 1;
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
}
} else if ( e.key === 'ArrowUp' ) {
e.preventDefault();
if ( selectedProductIndex > 0 ) {
selectedProductIndex -= 1;
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
} else {
removeSelection();
}
} else {
updateSearchResults();
}
}
const select = ( song: AppleMusicSongData ) => {
emits( 'selected-song', song );
controlSearch( 'hide' );
}
const search = () => {
emits( 'selected-song', searchResults.value[ 0 ] );
controlSearch( 'hide' );
}
const emits = defineEmits( [ 'selected-song' ] );
defineExpose( {
controlSearch
} );
</script>
<style scoped>
#search-bar {
position: fixed;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
top: -15vh;
background-color: var( --accent-background );
height: 15vh;
width: 100vw;
left: 0;
transition: all 1s;
z-index: 50;
}
#search-bar.search-shown {
top: 0;
}
#search-box {
width: calc(100% - 20px);
padding: 10px;
font-size: 150%;
}
#search-box-wrapper {
width: 60vw;
position: relative;
display: block;
}
@media only screen and (min-width: 1000px) {
#search-box-wrapper {
width: 45vw;
}
}
@media only screen and (min-width: 1500px) {
#search-box-wrapper {
width: 30vw;
}
}
#search-symbol-wrapper {
position: absolute;
right: 10px;
top: 3px;
}
.symbol-wrapper {
display: flex;
height: 3rem;
width: 3rem;
align-items: center;
justify-content: center;
}
.search-symbol {
color: black;
padding: 0;
margin: 0;
font-size: 200%;
cursor: pointer;
transition: all 0.5s;
}
.search-symbol:hover {
font-size: 250%;
}
#close-icon {
margin-left: 5px;
color: var( --primary-color );
}
</style>
<style scoped>
.search-result-wrapper {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 2px;
padding-bottom: 5px;
left: 0;
text-decoration: none;
background-color: white;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
transform-origin: top;
transform: scaleY( 0 );
transition: all 0.5s;
color: black;
}
.show-search-results {
transform: scaleY( 1 );
}
.search-result {
padding: 3px;
display: flex;
flex-direction: row;
width: 98%;
text-decoration: none;
transition: all 0.5s;
cursor: pointer;
}
.search-result:hover, .prod-selected {
text-decoration: underline black;
}
.search-product-image {
height: 4rem;
width: 4rem;
background-position: center;
background-size: cover;
margin-right: 20px;
display: flex;
align-items: center;
flex-direction: row;
justify-content: center;
}
.search-product-name {
display: flex;
flex-direction: row;
align-items: center;
text-decoration: none;
color: black;
font-weight: bold;
padding: 0;
margin: 0;
width: calc( 100% - 4rem - 20px );
}
</style>

View File

@@ -1,145 +0,0 @@
<template>
<div style="width: 100%; height: 100%;">
<progress :id="'progress-slider-' + name" class="progress-slider" :value="sliderProgress" max="1000" @mousedown="( e ) => { setPos( e ) }"
:class="active ? '' : 'slider-inactive'"></progress>
<div v-if="active" id="slider-knob" @mousedown="( e ) => { startMove( e ) }"
:style="'left: ' + ( originalPos + sliderPos ) + 'px;'">
<div id="slider-knob-style"></div>
</div>
<div v-else id="slider-knob" class="slider-inactive" style="left: 0;">
<div id="slider-knob-style"></div>
</div>
<div id="drag-support" @mousemove="e => { handleDrag( e ) }" @mouseup="() => { stopMove(); }"></div>
</div>
</template>
<style scoped>
.progress-slider {
width: 100%;
margin: 0;
position: absolute;
left: 0;
bottom: 0;
height: 5px;
cursor: pointer;
background-color: var( --slider-color );
}
.progress-slider::-webkit-progress-value {
background-color: var( --slider-color );
}
#slider-knob {
height: 20px;
width: 10px;
display: flex;
justify-content: flex-start;
align-items: flex-end;
position: absolute;
bottom: 0;
left: 0;
z-index: 2;
cursor: grab;
}
#slider-knob-style {
background-color: var( --slider-color );
height: 15px;
width: 5px;
}
#drag-support {
display: none;
opacity: 0;
height: 100vh;
width: 100vw;
position: fixed;
top: 0;
left: 0;
z-index: 10;
cursor: grabbing;
}
.drag-support-active {
display: block !important;
}
.slider-inactive {
cursor: default !important;
}
</style>
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps( {
style: {
type: Object,
},
position: {
type: Number,
default: 0,
},
duration: {
type: Number,
default: 100
},
active: {
type: Boolean,
default: true,
},
name: {
type: String,
default: '1',
}
} );
const offset = ref( 0 );
const isDragging = ref( false );
const sliderPos = ref( 0 );
const originalPos= ref( 0 );
const sliderProgress = ref( 0 );
const handleDrag = ( e: MouseEvent ) => {
if ( isDragging.value ) {
if ( 0 < originalPos.value + e.screenX - offset.value && originalPos.value + e.screenX - offset.value < ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) {
sliderPos.value = e.screenX - offset.value;
calcProgressPos();
}
}
}
const startMove = ( e: MouseEvent ) => {
offset.value = e.screenX;
isDragging.value = true;
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.add( 'drag-support-active' );
}
const stopMove = () => {
originalPos.value += sliderPos.value;
isDragging.value = false;
offset.value = 0;
sliderPos.value = 0;
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.remove( 'drag-support-active' );
calcPlaybackPos();
}
const setPos = ( e: MouseEvent ) => {
if ( props.active ) {
originalPos.value = e.offsetX;
calcProgressPos();
calcPlaybackPos();
}
}
const calcProgressPos = () => {
sliderProgress.value = Math.ceil( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * 1000 );
}
const calcPlaybackPos = () => {
emits( 'pos', Math.round( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * props.duration ) );
}
watch( () => props.position, () => {
if ( !isDragging.value ) {
sliderProgress.value = Math.ceil( props.position / props.duration * 1000 + 2 );
originalPos.value = Math.ceil( props.position / props.duration * ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).scrollWidth - 5 ) );
}
} )
const emits = defineEmits( [ 'pos' ] );
</script>

View File

@@ -1,15 +0,0 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
// localStorage.setItem( 'url', 'http://localhost:8082' );
localStorage.setItem( 'url', 'https://music-api.janishutz.com' );
app.mount('#app')

View File

@@ -1,87 +0,0 @@
import { createRouter, createWebHistory } from 'vue-router';
import HomeView from '@/views/HomeView.vue';
import { useUserStore } from '@/stores/userStore';
const router = createRouter( {
history: createWebHistory( import.meta.env.BASE_URL ),
routes: [
{
path: '/',
name: 'home',
component: HomeView,
meta: {
'authRequired': false,
'title': 'Login'
}
},
{
path: '/app',
name: 'app',
component: () => import( '../views/AppView.vue' ),
meta: {
'authRequired': true,
'title': 'App'
}
},
{
path: '/get',
name: 'get',
component: () => import( '../views/GetView.vue' ),
meta: {
'authRequired': false,
'title': 'Get'
}
},
{
path: '/share/:name',
name: 'share',
component: () => import( '../views/RemoteView.vue' ),
meta: {
'authRequired': false,
'title': 'Share'
}
},
{
path: '/fancy/:name',
name: 'fancy',
component: () => import( '../views/ShowcaseView.vue' ),
meta: {
'authRequired': false,
'title': 'Fancy View'
}
},
{
path: '/:pathMatch(.*)*',
name: 'NotFound',
component: () => import( '../views/404View.vue' ),
meta: {
title: '404 :: Page not found',
transition: 'scale',
}
},
]
} );
// router.beforeResolve( ( to, _from, next ) => {
// if ( to.name ) {
// NProgress.start();
// }
// next();
// } );
router.beforeEach( ( to ) => {
const userStore = useUserStore();
const isUserAuthenticated = userStore.getUserAuthenticated;
if ( !isUserAuthenticated && to.meta.authRequired ) {
localStorage.setItem( 'redirect', to.fullPath );
return { name: 'home' };
}
} );
router.afterEach( ( to ) => {
window.scrollTo( { top: 0, behavior: 'smooth' } );
document.title = to.meta.title ? to.meta.title + ' - MusicPlayer' : 'MusicPlayer';
// NProgress.done();
} );
export default router;

View File

@@ -1,139 +0,0 @@
import ColorThief from 'colorthief';
const colorThief = new ColorThief();
const getImageData = (): Promise<number[][]> => {
return new Promise( ( resolve ) => {
const img = ( document.getElementById( 'current-image' ) as HTMLImageElement );
if ( img.complete ) {
resolve( colorThief.getPalette( img ) );
} else {
img.addEventListener( 'load', () => {
resolve( colorThief.getPalette( img ) );
} );
}
} );
}
const createBackground = () => {
return new Promise( ( resolve ) => {
getImageData().then( palette => {
const colourDetails: number[][] = [];
const colours: string[] = [];
let differentEnough = true;
if ( palette[ 0 ] ) {
for ( const i in palette ) {
for ( const colour in colourDetails ) {
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - palette[ i ][ 0 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 1 ] - palette[ i ][ 1 ] ) / 255
+ Math.abs( colourDetails[ colour ][ 2 ] - palette[ i ][ 2 ] ) / 255 ) / 3 * 100;
if ( colourDiff > 15 ) {
differentEnough = true;
}
}
if ( differentEnough ) {
colourDetails.push( palette[ i ] );
colours.push( 'rgb(' + palette[ i ][ 0 ] + ',' + palette[ i ][ 1 ] + ',' + palette[ i ][ 2 ] + ')' );
}
differentEnough = false;
}
}
let outColours = 'conic-gradient(';
if ( colours.length < 3 ) {
for ( let i = 0; i < 3; i++ ) {
if ( colours[ i ] ) {
outColours += colours[ i ] + ',';
} else {
if ( i === 0 ) {
outColours += 'blue,';
} else if ( i === 1 ) {
outColours += 'green,';
} else if ( i === 2 ) {
outColours += 'red,';
}
}
}
} else if ( colours.length < 11 ) {
for ( const i in colours ) {
outColours += colours[ i ] + ',';
}
} else {
for ( let i = 0; i < 10; i++ ) {
outColours += colours[ i ] + ',';
}
}
outColours += colours[ 0 ] ?? 'blue' + ')';
resolve( outColours );
} );
} );
}
let callbackFun = () => {}
const subscribeToBeatUpdate = ( cb: () => void ) => {
callbackFun = cb;
micAudioHandler();
}
const unsubscribeFromBeatUpdate = () => {
callbackFun = () => {}
try {
clearInterval( micAnalyzer );
} catch ( e ) { /* empty */ }
}
const coolDown = () => {
beatDetected = false;
}
let micAnalyzer = 0;
let beatDetected = false;
const micAudioHandler = () => {
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
const analyser = audioContext.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array( bufferLength );
beatDetected = false;
navigator.mediaDevices.getUserMedia( { audio: true } ).then( ( stream ) => {
const mic = audioContext.createMediaStreamSource( stream );
mic.connect( analyser );
analyser.getByteFrequencyData( dataArray );
let prevSpectrum: number[] = [];
const threshold = 10; // Adjust as needed
micAnalyzer = setInterval( () => {
analyser.getByteFrequencyData( dataArray );
// Convert the frequency data to a numeric array
const currentSpectrum = Array.from( dataArray );
if ( prevSpectrum ) {
// Calculate the spectral flux
const flux = calculateSpectralFlux( prevSpectrum, currentSpectrum );
if ( flux > threshold && !beatDetected ) {
// Beat detected
beatDetected = true;
callbackFun();
}
}
prevSpectrum = currentSpectrum;
}, 60 / 180 * 250 );
} );
}
const calculateSpectralFlux = ( prevSpectrum: number[], currentSpectrum: number[] ) => {
let flux = 0;
for ( let i = 0; i < prevSpectrum.length; i++ ) {
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
flux += Math.max( 0, diff );
}
return flux;
}
export default {
createBackground,
subscribeToBeatUpdate,
unsubscribeFromBeatUpdate,
coolDown,
}

View File

@@ -1,179 +0,0 @@
/*
* MusicPlayerV2 - notificationHandler.ts
*
* Created by Janis Hutz 06/26/2024, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
// These functions handle connections to the backend with socket.io
import { io, type Socket } from "socket.io-client";
import type { SSEMap } from "./song";
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<any> {
return new Promise( ( resolve, reject ) => {
if ( this.reconnectRetryCount < 5 ) {
if ( this.useSocket ) {
this.socket.connect();
this.socket.emit( 'join-room', this.roomName, ( res: { status: boolean, msg: string, data: any } ) => {
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: any ): 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: 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}
*/
disconnect (): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.disconnect();
} else {
this.eventSource!.close();
}
}
}
getStatus (): boolean {
if ( this.useSocket ) {
return true;
} else {
if ( this.eventSource ) {
return this.eventSource!.OPEN && this.isConnected;
}
return false;
}
}
}
export default SocketConnection;

View File

@@ -1,483 +0,0 @@
import type { SearchResult, Song, SongMove } from "./song";
interface Config {
devToken: string;
userToken: string;
}
type ControlAction = 'play' | 'pause' | 'next' | 'previous' | 'skip-10' | 'back-10';
type RepeatMode = 'off' | 'once' | 'all';
class MusicKitJSWrapper {
playingSongID: number;
playlist: Song[];
queue: number[];
config: Config;
musicKit: any;
isLoggedIn: boolean;
isPreparedToPlay: boolean;
repeatMode: RepeatMode;
isShuffleEnabled: boolean;
hasEncounteredAuthError: boolean;
queuePos: number;
audioPlayer: HTMLAudioElement;
constructor () {
this.playingSongID = 0;
this.playlist = [];
this.queue = [];
this.config = {
devToken: '',
userToken: '',
};
this.isShuffleEnabled = false;
this.repeatMode = 'off';
this.isPreparedToPlay = false;
this.isLoggedIn = false;
this.hasEncounteredAuthError = false;
this.queuePos = 0;
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
const self = this;
if ( !window.MusicKit ) {
document.addEventListener( 'musickitloaded', () => {
self.init();
} );
} else {
this.init();
}
}
/**
* Log a user into Apple Music. Will automatically initialize MusicKitJS, once user is logged in
* @returns {void}
*/
logIn (): void {
if ( !this.musicKit.isAuthorized ) {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} ).catch( () => {
this.hasEncounteredAuthError = true;
} );
} else {
this.musicKit.authorize().then( () => {
this.isLoggedIn = true;
this.init();
} ).catch( () => {
this.hasEncounteredAuthError = true;
} );
}
}
/**
* Initialize MusicKitJS. Should not be called. Use logIn instead, which first tries to log the user in, then calls this method.
* @returns {void}
*/
init (): void {
fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', { credentials: 'include' } ).then( res => {
if ( res.status === 200 ) {
res.text().then( token => {
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
// MusicKit global is now defined
MusicKit.configure( {
developerToken: token,
app: {
name: 'MusicPlayer',
build: '3'
},
storefrontId: 'CH',
} ).then( () => {
this.config.devToken = token;
this.musicKit = MusicKit.getInstance();
if ( this.musicKit.isAuthorized ) {
this.isLoggedIn = true;
this.config.userToken = this.musicKit.musicUserToken;
}
this.musicKit.shuffleMode = MusicKit.PlayerShuffleMode.off;
} );
} );
}
} );
}
/**
* Get the authentication status of the user
* @returns {boolean[]} Returns an array, where the first element indicates login status, the second one, if an error was encountered
*/
getAuth (): boolean[] {
return [ this.isLoggedIn, this.hasEncounteredAuthError ];
}
/**
* Request data from the Apple Music API
* @param {string} url The URL at the Apple Music API to call (including protocol and url)
* @param {( data: object ) => void} callback A callback function that takes the data and returns nothing
* @returns {void}
*/
apiGetRequest ( url: string, callback: ( data: object ) => void ): void {
if ( this.config.devToken != '' && this.config.userToken != '' ) {
fetch( url, {
method: 'GET',
headers: {
'Authorization': `Bearer ${ this.config.devToken }`,
'Music-User-Token': this.config.userToken
}
} ).then( res => {
if ( res.status === 200 ) {
res.json().then( json => {
try {
callback( { 'status': 'ok', 'data': json } );
} catch( err ) { /* empty */}
} );
} else {
try {
callback( { 'status': 'error', 'error': res.status } );
} catch( err ) { /* empty */}
}
} );
} else return;
}
/**
* Set the playlist to play
* @param {Song[]} playlist The playlist as an array of songs
* @returns {void}
*/
setPlaylist ( playlist: Song[] ): void {
this.playlist = playlist;
this.setShuffle( this.isShuffleEnabled );
}
setPlaylistByID ( id: string ): Promise<void> {
return new Promise( ( resolve, reject ) => {
this.musicKit.setQueue( { playlist: id } ).then( () => {
const pl = this.musicKit.queue.items;
const songs: Song[] = [];
for ( const item in pl ) {
let url = pl[ item ].attributes.artwork.url;
url = url.replace( '{w}', pl[ item ].attributes.artwork.width );
url = url.replace( '{h}', pl[ item ].attributes.artwork.height );
const song: Song = {
artist: pl[ item ].attributes.artistName,
cover: url,
duration: pl[ item ].attributes.durationInMillis / 1000,
id: pl[ item ].id,
origin: 'apple-music',
title: pl[ item ].attributes.name,
genres: pl[ item ].attributes.genreNames
}
songs.push( song );
}
this.playlist = songs;
this.setShuffle( this.isShuffleEnabled );
this.queuePos = 0;
this.playingSongID = this.queue[ 0 ];
this.prepare( this.playingSongID );
resolve();
} ).catch( err => {
console.error( err );
reject( err );
} );
} );
}
/**
* Prepare a specific song in the queue for playing and start playing
* @param {number} playlistID The ID of the song in the playlist to prepare to play
* @returns {boolean} Returns true, if successful, false, if playlist is missing / empty. Set that first
*/
prepare ( playlistID: number ): boolean {
if ( this.playlist.length > 0 ) {
this.playingSongID = playlistID;
this.isPreparedToPlay = true;
for ( const el in this.queue ) {
if ( this.queue[ el ] === playlistID ) {
this.queuePos = parseInt( el );
break;
}
}
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.setQueue( { 'song': this.playlist[ this.playingSongID ].id } ).then( () => {
setTimeout( () => {
this.control( 'play' );
}, 500 );
} ).catch( ( err ) => {
console.log( err );
} );
} else {
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
this.audioPlayer.src = this.playlist[ this.playingSongID ].id;
setTimeout( () => {
this.control( 'play' );
}, 500 );
}
return true;
} else {
return false;
}
}
/**
* Control the player
* @param {ControlAction} action Action to take on the player
* @returns {boolean} returns a boolean indicating if there was a change in song.
*/
control ( action: ControlAction ): boolean {
switch ( action ) {
case "play":
if ( this.isPreparedToPlay ) {
this.control( 'pause' );
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.play();
return false;
} else {
this.audioPlayer.play();
return false;
}
} else {
return false;
}
case "pause":
if ( this.isPreparedToPlay ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.pause();
return false;
} else {
this.audioPlayer.pause();
return false;
}
} else {
return false;
}
case "back-10":
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime > 10 ? this.musicKit.currentPlaybackTime - 10 : 0 );
return false;
} else {
this.audioPlayer.currentTime = this.audioPlayer.currentTime > 10 ? this.audioPlayer.currentTime - 10 : 0;
return false;
}
case "skip-10":
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
if ( this.musicKit.currentPlaybackTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime + 10 );
return false;
} else {
if ( this.repeatMode !== 'once' ) {
this.control( 'next' );
return true;
} else {
this.musicKit.seekToTime( 0 );
return false;
}
}
} else {
if ( this.audioPlayer.currentTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
this.audioPlayer.currentTime = this.audioPlayer.currentTime + 10;
} else {
if ( this.repeatMode !== 'once' ) {
this.control( 'next' );
} else {
this.audioPlayer.currentTime = 0;
}
}
return false;
}
case "next":
this.control( 'pause' );
if ( this.queuePos < this.queue.length - 1 ) {
this.queuePos += 1;
this.prepare( this.queue[ this.queuePos ] );
return true;
} else {
this.queuePos = 0;
if ( this.repeatMode !== 'all' ) {
this.control( 'pause' );
} else {
this.playingSongID = this.queue[ this.queuePos ];
this.prepare( this.queue[ this.queuePos ] );
}
return true;
}
case "previous":
this.control( 'pause' );
if ( this.queuePos > 0 ) {
this.queuePos -= 1;
this.prepare( this.queue[ this.queuePos ] );
return true;
} else {
this.queuePos = this.queue.length - 1;
return true;
}
}
}
setShuffle ( enabled: boolean ) {
this.isShuffleEnabled = enabled;
this.queue = [];
if ( enabled ) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const d = [];
for ( const el in this.playlist ) {
d.push( parseInt( el ) );
}
this.queue = d.map( value => ( { value, sort: Math.random() } ) )
.sort( ( a, b ) => a.sort - b.sort )
.map( ( { value } ) => value );
this.queue.splice( this.queue.indexOf( this.playingSongID ), 1 );
this.queue.push( this.playingSongID );
this.queue.reverse();
} else {
for ( const song in this.playlist ) {
this.queue.push( parseInt( song ) );
}
}
// Find current song ID in queue
for ( const el in this.queue ) {
if ( this.queue[ el ] === this.playingSongID ) {
this.queuePos = parseInt( el );
break;
}
}
}
setRepeatMode ( mode: RepeatMode ) {
this.repeatMode = mode;
}
goToPos ( pos: number ) {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.seekToTime( pos );
} else {
this.audioPlayer.currentTime = pos;
}
}
moveSong ( move: SongMove ) {
const newQueue = [];
const finishedQueue = [];
let songID = 0;
for ( const song in this.playlist ) {
if ( this.playlist[ song ].id === move.songID ) {
songID = parseInt( song );
break;
}
}
for ( const el in this.queue ) {
if ( this.queue[ el ] !== songID ) {
newQueue.push( this.queue[ el ] );
}
}
let hasBeenAdded = false;
for ( const el in newQueue ) {
if ( parseInt( el ) === move.newPos ) {
finishedQueue.push( songID );
hasBeenAdded = true;
}
finishedQueue.push( newQueue[ el ] );
}
if ( !hasBeenAdded ) {
finishedQueue.push( songID );
}
this.queue = finishedQueue;
}
/**
* Get the current position of the play heed. Will return in ms since start of the song
* @returns {number}
*/
getPlaybackPos (): number {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
return this.musicKit.currentPlaybackTime;
} else {
return this.audioPlayer.currentTime;
}
}
/**
* Get details on the currently playing song
* @returns {Song}
*/
getPlayingSong (): Song {
return this.playlist[ this.playingSongID ];
}
/**
* Get the playlist index of the currently playing song
* @returns {number}
*/
getPlayingSongID (): number {
return this.playingSongID;
}
/**
* Get the queue index of the currently playing song
* @returns {number}
*/
getQueueID (): number {
return this.queuePos;
}
/**
* Get the full playlist, as it is set currently, not ordered by queue settings, but as passed in originally
* @returns {Song[]}
*/
getPlaylist (): Song[] {
return this.playlist;
}
/**
* Same as getPlaylist, but returns a ordered playlist, by how it will play according to the queue.
* @returns {Song[]}
*/
getQueue (): Song[] {
const data = [];
for ( const el in this.queue ) {
data.push( this.playlist[ this.queue[ el ] ] );
}
return data;
}
/**
* Get all playlists the authenticated user has on Apple Music. Only available once the user has authenticated!
* @param {( data: object ) => void} cb The callback function called with the results from the API
* @returns {boolean} Returns true, if user is authenticated and request was started, false if not.
*/
getUserPlaylists ( cb: ( data: object ) => void ): boolean {
if ( this.isLoggedIn ) {
this.apiGetRequest( 'https://api.music.apple.com/v1/me/library/playlists', cb );
return true;
} else {
return false;
}
}
getPlaying ( ): boolean {
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
return this.musicKit.isPlaying;
} else {
return !this.audioPlayer.paused;
}
}
findSongOnAppleMusic ( searchTerm: string ): Promise<SearchResult> {
// TODO: Make storefront adjustable
return new Promise( ( resolve, reject ) => {
const queryParameters = {
term: ( searchTerm ),
types: [ 'songs' ],
};
this.musicKit.api.music( `v1/catalog/ch/search`, queryParameters ).then( results => {
resolve( results );
} ).catch( e => {
console.error( e );
reject( e );
} );
} );
}
}
export default MusicKitJSWrapper;

View File

@@ -1,263 +0,0 @@
/*
* MusicPlayerV2 - notificationHandler.ts
*
* Created by Janis Hutz 06/26/2024, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
// These functions handle connections to the backend with socket.io
import { io, type Socket } from "socket.io-client"
import type { SSEMap } from "./song";
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( 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 ) {
document.dispatchEvent( new Event( 'musicplayer:autherror' ) );
reject( 'ERR_UNAUTHORIZED' );
} else {
reject( 'ERR_ROOM_CONNECTING_STATUS_CODE' );
}
} ).catch( () => {
if ( !this.connectionWasSuccessful ) {
reject( 'ERR_ROOM_CONNECTING' );
} else {
this.openConnectionsCount -= 1;
console.log( '[ SSE Connection ] - ' + new Date().toISOString() + ': Reconnecting due to severe connection error!' );
this.eventSource = undefined;
this.reconnectRetryCount += 1;
setTimeout( () => {
this.sseConnect();
}, 1000 * this.reconnectRetryCount );
}
} );
} else {
resolve();
}
} else {
if ( confirm( 'Connection lost and it could not be reestablished. Please click ok to retry or press cancel to stop retrying. Your share will be deleted as a result thereof.' ) ) {
this.reconnectRetryCount = 0;
this.sseConnect();
} else {
this.disconnect();
}
}
} );
}
/**
* Emit an event
* @param {string} event The event to emit
* @param {any} data
* @returns {void}
*/
emit ( event: string, data: any ): void {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit( event, { 'roomToken': this.roomToken, 'roomName': this.roomName, 'data': data } );
} else {
const now = new Date().getTime();
if ( this.lastEmitTimestamp < now - 250 ) {
this.lastEmitTimestamp = now;
this.sendEmitConventionally( event, data );
} else {
this.pendingRequestCount += 1;
setTimeout( () => {
this.pendingRequestCount = 0;
this.lastEmitTimestamp = now;
this.sendEmitConventionally( event, data );
}, 250 * this.pendingRequestCount );
}
}
}
}
sendEmitConventionally ( event: string, data: any ): void {
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
method: 'post',
body: JSON.stringify( { 'event': event, 'roomName': this.roomName, 'roomToken': this.roomToken, 'data': data } ),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
} ).catch( () => {} );
}
/**
* Register a listener function for an event
* @param {string} event The event to listen for
* @param {( data: any ) => void} cb The callback function / listener function
* @returns {void}
*/
registerListener ( event: string, cb: ( data: any ) => void ): void {
if ( this.useSocket ) {
if ( this.isConnected ) {
this.socket.on( event, cb );
}
} else {
this.toBeListenedForItems[ event ] = cb;
}
}
/**
* Disconnect from the server
* @returns {any}
*/
async disconnect (): Promise<void> {
if ( this.isConnected ) {
if ( this.useSocket ) {
this.socket.emit( 'delete-room', {
name: this.roomName,
token: this.roomToken
}, ( res: { status: boolean, msg: string } ) => {
this.socket.disconnect();
if ( !res.status ) {
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
}
return;
} );
} else {
fetch( localStorage.getItem( 'url' ) + '/socket/deleteRoom', {
method: 'post',
body: JSON.stringify( { 'roomName': this.roomName, 'roomToken': this.roomToken } ),
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'charset': 'utf-8'
}
} ).then( res => {
if ( res.status === 200 ) {
this.eventSource!.close();
} else {
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
}
return;
} ).catch( () => {
return;
} );
}
}
}
getRoomName (): string {
return this.roomName;
}
}
export default NotificationHandler;

View File

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

View File

@@ -1,34 +0,0 @@
/*
* LanguageSchoolHossegorBookingSystem - userStore.js
*
* Created by Janis Hutz 10/27/2023, Licensed under a proprietary License
* https://janishutz.com, development@janishutz.com
*
*
*/
import { defineStore } from 'pinia';
// FOSS-VERSION: To enable the UI to be used with the FOSS version, change "isUserAuth" to true, you will be "logged in"
export const useUserStore = defineStore( 'user', {
state: () => ( { 'isUserAuth': true, 'hasSubscribed': false, 'isUsingKeyboard': false, 'username': '', 'isFOSSVersion': false } ),
getters: {
getUserAuthenticated: ( state ) => state.isUserAuth,
getSubscriptionStatus: ( state ) => state.hasSubscribed,
},
actions: {
setUserAuth ( auth: boolean ) {
this.isUserAuth = auth;
},
setSubscriptionStatus ( status: boolean ) {
this.hasSubscribed = status;
},
setUsername ( username: string ) {
this.username = username;
},
setKeyboardUsageStatus ( status: boolean ) {
this.isUsingKeyboard = status;
}
}
} );

View File

@@ -1,24 +0,0 @@
<template>
<div class="home-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<h1>404</h1>
</div>
</template>
<script setup lang="ts">
</script>
<style scoped>
.home-view {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.logo {
height: 50vh;
}
</style>

View File

@@ -1,169 +0,0 @@
<template>
<div class="app-view">
<button id="logout" @click="logout()"><span class="material-symbols-outlined">logout</span></button>
<div class="loading-view" v-if="!hasFinishedLoading">
<h1>Loading...</h1>
</div>
<div class="home-view" v-else-if="hasFinishedLoading && isReady">
<libraryView class="library-view" :playlists="playlists" @selected-playlist="( id ) => { selectPlaylist( id ) }"
:is-logged-in="isLoggedIntoAppleMusic" @custom-playlist="( pl ) => selectCustomPlaylist( pl )"></libraryView>
</div>
<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 :class="'player-view' + ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )" @player-state-change="( state ) => { handlePlayerStateChange( state ) }"
ref="player"></playerView>
<!-- 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 === 404 ) {
userStore.setSubscriptionStatus( false );
router.push( '/get' );
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
} else {
console.log( res.status );
}
} );
const logout = () => {
// location.href = 'http://localhost:8080/logout?return=' + location.href;
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
}
</script>
<style scoped>
#logout {
border: none;
background: none;
position: fixed;
left: calc( 10px + 2rem );
top: 10px;
cursor: pointer;
}
#logout .material-symbols-outlined {
font-size: 1.5rem;
color: var( --primary-color );
}
.library-view {
height: calc( 90vh - 10px );
width: 100%;
}
.app-view {
height: 100%;
width: 100%;
}
.home-view {
height: 100%;
}
.login-view {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.logo {
height: 50vh;
}
.player-view {
height: 13vh;
width: calc( 100vw - 20px );
position: fixed;
bottom: 10px;
left: 10px;
background-color: var( --secondary-color );
transition: all 0.75s ease-in-out;
}
.full-screen-player {
height: 100vh;
width: 100vw;
left: 0;
bottom: 0;
}
.player-hidden {
display: none;
}
</style>

View File

@@ -1,78 +0,0 @@
<template>
<div>
<div class="top-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<h1>MusicPlayer</h1>
<p v-if="reasonForRedirectHere" style="color: red;">{{ reasons[ reasonForRedirectHere ] }}</p>
<p v-if="!reasonForRedirectHere"><i>An Open Source, browser-based MusicPlayer with beautiful graphics</i></p>
<div style="margin-top: 20px;">
<a href="https://store.janishutz.com/product/com.janishutz.MusicPlayer" class="fancy-button" target="_blank">Subscribe</a>
<a href="/" class="fancy-button" style="margin-left: 10px;" v-if="!reasonForRedirectHere">Log in</a>
<button href="/" class="fancy-button" style="margin-left: 10px;" v-if="reasonForRedirectHere" @click="logout()">Log out</button>
<a href="https://github.com/simplePCBuilding/MusicPlayerV2" class="fancy-button" style="margin-left: 10px;" target="_blank">GitHub</a>
</div>
</div>
<div>
<h2>Fully featured Music Player</h2>
<p>All the features you'd expect a Music Player to have are also present here</p>
<h2>Apple Music integration</h2>
<p>Use MusicPlayer in conjunction with Apple Music</p>
<h2>Share your playlist</h2>
<p>You can share your playlist on a beautifully animated public page, so that other people can join in and view your playlist</p>
<h2>Fully browser based</h2>
<p>No installation required when using MusicPlayer on <a href="https://music.janishutz.com">music.janishutz.com</a></p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, type Ref } from 'vue';
interface Reasons {
[key: string]: string;
}
const reasons: Ref<Reasons> = ref( {
'notOwned': 'Please subscribe to use MusicPlayer here, or download and install it manually from GitHub!',
} );
const reasonForRedirectHere = ref( sessionStorage.getItem( 'getRedirectionReason' ) );
sessionStorage.removeItem( 'getRedirectionReason' );
const logout = () => {
// location.href = 'http://localhost:8080/logout?return=' + location.href;
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
}
</script>
<style scoped>
.logo {
height: 60vh;
max-height: 90vw;
border-radius: 20px;
}
.top-view {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.full-height {
height: 100vh;
width: 100vw;
display: flex;
justify-content: center;
align-items: center;
}
.promo-img {
height: 100vh;
width: 100vh;
}
</style>

View File

@@ -1,92 +0,0 @@
<template>
<div class="home-view">
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
<button :class="'fancy-button' + ( isTryingToSignIn ? ' fancy-button-inactive' : '' )" @click="login()"
style="margin-top: 5vh;" title="Sign in or sign up with janishutz.com ID" v-if="status"
>{{ isTryingToSignIn ? 'Signing you in...' : 'Login / Sign up' }}</button>
<p v-else>We are sorry, but we were unable to initialize the login services. Please reload the page if you wish to retry!</p>
<p style="width: 80%;">MusicPlayer is a browser based Music Player, that allows you to connect other devices, simply with another web-browser, where you can see the current playlist with sleek animations. You can log in using your Apple Music account or load a playlist from your local disk, simply by selecting the songs using a file picker.</p>
<router-link to="/get" class="fancy-button">More information</router-link>
<notificationsModule ref="notifications" location="bottomleft" size="bigger"></notificationsModule>
</div>
</template>
<script setup lang="ts">
// TODO: Make possible to install and use without account, if using FOSS version
import router from '@/router';
import { RouterLink } from 'vue-router';
import { useUserStore } from '@/stores/userStore';
import notificationsModule from '@/components/notificationsModule.vue';
import { ref } from 'vue';
const notifications = ref( notificationsModule );
const isTryingToSignIn = ref( true );
interface JanishutzIDSDK {
setLoginSDKURL: ( url: string ) => undefined;
createSession: () => undefined;
verifySession: () => Promise<JHIDSessionStatus>
}
interface JHIDSessionStatus {
status: boolean;
username: string;
}
let sdk: JanishutzIDSDK;
const status = ref( true );
if ( typeof( JanishutzID ) !== 'undefined' ) {
sdk = JanishutzID();
sdk.setLoginSDKURL( localStorage.getItem( 'url' ) ?? '' );
} else {
setTimeout( () => {
notifications.value.createNotification( 'Unable to initialize account services!', 5, 'error' );
}, 1000 );
status.value = false;
}
const login = () => {
sdk.createSession();
}
const store = useUserStore();
if ( store.isUserAuth ) {
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
localStorage.removeItem( 'redirect' );
} else {
if ( typeof( sdk ) !== 'undefined' ) {
sdk.verifySession().then( res => {
if ( res.status ) {
store.isUserAuth = true;
store.username = res.username;
if ( localStorage.getItem( 'close-tab' ) ) {
localStorage.removeItem( 'close-tab' );
window.close();
}
localStorage.setItem( 'login-ok', 'true' );
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
localStorage.removeItem( 'redirect' );
} else {
isTryingToSignIn.value = false;
}
} );
}
}
</script>
<style scoped>
.home-view {
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.logo {
height: 50vh;
border-radius: 50px;
}
</style>

View File

@@ -1,354 +0,0 @@
<template>
<div>
<div class="info">Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a></div>
<div class="remote-view">
<div v-if="hasLoaded && !showCouldNotFindRoom" style="width: 100%">
<div class="current-song-wrapper">
<img v-if="playlist[ playingSong ]" :src="playlist[ playingSong ].cover" class="fancy-view-song-art" id="current-image" 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 class="additional-info" v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false">{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}</p>
<progress max="1000" id="progress" :value="progressBar"></progress>
</div>
</div>
<div class="song-list-wrapper">
<div v-for="song in songQueue" v-bind: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;
} );
// TODO: Handle disconnect from updater (=> have it disconnect)
const getTimeUntil = computed( () => {
return ( song: string ) => {
let timeRemaining = 0;
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
if ( playlist.value[ i ].id == song ) {
break;
}
timeRemaining += playlist.value[ i ].duration;
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - pos.value / 60 ) + 'min';
}
} 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 );
} catch ( err ) { /* empty */ }
timeTracker = setInterval( () => {
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
if ( isNaN( progressBar.value ) ) {
progressBar.value = 0;
}
if ( playlist.value[ playingSong.value ].duration + 10 - pos.value < 0 ) {
stopTimeTracker();
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
location.reload();
}
}, 100 );
}
const stopTimeTracker = () => {
clearInterval( timeTracker );
}
document.addEventListener( 'visibilitychange', () => {
if ( !document.hidden ) {
if ( !conn.getStatus ) {
stopTimeTracker();
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
location.reload();
}
}
} );
</script>
<style>
#themeSelector {
display: none;
}
</style>
<style scoped>
.remote-view {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: justify;
background-color: rgb(2, 16, 61);
color: white;
min-height: 100vh;
}
.loaded {
display: block;
}
.loading {
display: flex;
height: 100vh;
}
.playing-symbols {
position: absolute;
left: 10vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 5vw;
height: 5vw;
background-color: rgba( 0, 0, 0, 0.6 );
}
.playing-symbols-wrapper {
width: 4vw;
height: 5vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.song-list-wrapper {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-bottom: 5%;
}
.song-list {
display: flex;
flex-direction: row;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
border: 1px white solid;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.song-details-wrapper {
margin: 0;
display: block;
margin-left: 10px;
margin-right: auto;
width: 65%;
text-align: left;
}
.pause-icon {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw !important;
user-select: none;
}
.current-song-wrapper {
display: flex;
align-items: center;
flex-direction: column;
width: 100%;
margin-bottom: 2%;
margin-top: 1%;
}
.current-song {
display: flex;
align-items: center;
flex-direction: column;
margin-top: 1vh;
padding: 1vh;
max-width: 80%;
text-align: center;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 10px;
font-size: 30vh !important;
border-radius: 30px;
}
#app {
background-color: rgba( 0, 0, 0, 0 );
}
#progress, #progress::-webkit-progress-bar {
background-color: rgb(82, 82, 82);
color: rgb(82, 82, 82);
width: 30vw;
height: 10px;
border: none;
border-radius: 0px;
accent-color: white;
-webkit-appearance: none;
appearance: none;
border-radius: 10px;
margin-bottom: 5px;
}
#progress::-moz-progress-bar {
background-color: white;
}
#progress::-webkit-progress-value {
background-color: white !important;
}
.mode-selector-wrapper {
opacity: 0;
position: fixed;
right: 0.5%;
top: 0.5%;
padding: 0.5%;
}
.mode-selector-wrapper:hover {
opacity: 1;
}
.additional-info {
font-size: 1.2rem;
margin: 0;
font-weight: bolder;
}
.info {
position: fixed;
font-size: 12px;
transform: rotate(270deg);
left: -150px;
margin: 0;
padding: 0;
top: 50%;
color: white;
}
.time-until {
width: 30%;
text-align: end;
}
</style>

View File

@@ -1,538 +0,0 @@
<template>
<div>
<span class="anti-tamper material-symbols-outlined" v-if="isAntiTamperEnabled" @click="secureModeInfo( 'toggle' )">lock</span>
<div class="anti-tamper-info" v-if="isShowingSecureModeInfo && isAntiTamperEnabled" @click="secureModeInfo( 'hide' )">Anti-Tamper is enabled. Leaving this window will cause a notification to be dispatched to the player!</div>
<div class="info">Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a></div>
<div class="remote-view">
<div v-if="hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
<div class="current-song-wrapper">
<img v-if="playlist[ playingSong ]" :src="playlist[ playingSong ].cover" class="fancy-view-song-art" id="current-image" crossorigin="anonymous">
<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 class="additional-info" v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false">{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}</p>
<progress max="1000" id="progress" :value="progressBar"></progress>
</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" v-bind:key="song.id" class="song-list">
<img :src="song.cover" class="song-image">
<div v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying" class="playing-symbols">
<div class="playing-symbols-wrapper">
<div class="playing-bar" id="bar-1"></div>
<div class="playing-bar" id="bar-2"></div>
<div class="playing-bar" id="bar-3"></div>
</div>
</div>
<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 class="background" id="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 { computed, ref, type 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;
} );
// TODO: Handle disconnect from updater (=> have it disconnect)
const getTimeUntil = computed( () => {
return ( song: string ) => {
let timeRemaining = 0;
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
if ( playlist.value[ i ].id == song ) {
break;
}
timeRemaining += playlist.value[ i ].duration;
}
if ( isPlaying.value ) {
if ( timeRemaining === 0 ) {
return 'Currently playing';
} else {
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - pos.value / 60 ) + 'min';
}
} 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 );
} catch ( err ) { /* empty */ }
setTimeout( () => {
handleAnimationChange();
setBackground();
}, 1000 );
timeTracker = setInterval( () => {
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
if ( isNaN( progressBar.value ) ) {
progressBar.value = 0;
}
}, 100 );
}
const stopTimeTracker = () => {
clearInterval( timeTracker );
handleAnimationChange();
}
const animateBeat = () => {
$( '.beat-manual' ).stop();
const duration = Math.ceil( 60 / 180 * 500 ) - 50;
$( '.beat-manual' ).fadeIn( 50 );
setTimeout( () => {
$( '.beat-manual' ).fadeOut( duration );
setTimeout( () => {
bizualizer.coolDown();
$( '.beat-manual' ).stop();
}, duration );
}, 50 );
}
const handleAnimationChange = () => {
if ( visualizationSettings.value === 'mic' && isPlaying.value ) {
bizualizer.subscribeToBeatUpdate( animateBeat );
} else {
bizualizer.unsubscribeFromBeatUpdate()
}
}
const setBackground = () => {
bizualizer.createBackground().then( bg => {
$( '#background' ).css( 'background', bg );
} );
}
const notifier = () => {
Notification.requestPermission();
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
// Detect if window is currently in focus
window.onblur = () => {
sendNotification();
}
// Detect if browser window becomes hidden (also with blur event)
document.onvisibilitychange = () => {
if ( document.visibilityState === 'hidden' ) {
sendNotification();
}
};
}
const sendNotification = () => {
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
body: 'Please return to the original webpage immediately!',
requireInteraction: true,
} );
conn.emit( 'tampering', '' );
}
const isShowingSecureModeInfo = ref( false );
const secureModeInfo = ( action: string ) => {
if ( action === 'toggle' ) {
isShowingSecureModeInfo.value = !isShowingSecureModeInfo.value;
} else if ( action === 'show' ) {
isShowingSecureModeInfo.value = true;
} else {
isShowingSecureModeInfo.value = false;
}
}
</script>
<style scoped>
.anti-tamper {
position: fixed;
z-index: 10;
bottom: 5px;
right: 5px;
font-size: 2rem;
cursor: default;
}
.anti-tamper-info {
position: fixed;
z-index: 10;
bottom: calc( 10px + 2rem );
right: 5px;
max-width: 20rem;
background-color: black;
color: white;
padding: 5px;
}
.remote-view {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
text-align: left;
color: white;
}
.showcase-wrapper {
width: 100%;
z-index: 5;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.playing-symbols {
position: absolute;
left: 10vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
width: 5vw;
height: 5vw;
background-color: rgba( 0, 0, 0, 0.6 );
border-radius: 10px;
}
.playing-symbols-wrapper {
width: 4vw;
height: 5vw;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
}
.playing-bar {
height: 60%;
background-color: white;
width: 10%;
border-radius: 50px;
margin: auto;
}
#bar-1 {
animation: music-playing 0.9s infinite ease-in-out;
}
#bar-2 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.3s;
}
#bar-3 {
animation: music-playing 0.9s infinite ease-in-out;
animation-delay: 0.6s;
}
@keyframes music-playing {
0% {
transform: scaleY( 1 );
}
50% {
transform: scaleY( 0.5 );
}
100% {
transform: scaleY( 1 );
}
}
.song-list-wrapper {
border-radius: 10px;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
}
.song-list {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 80%;
margin: 2px;
padding: 1vh;
border: 1px white solid;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.song-details-wrapper {
margin: 0;
display: block;
margin-left: 10px;
margin-right: auto;
text-align: left;
}
.song-list .song-image {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw;
border-radius: 10px;
}
.pause-icon {
width: 5vw;
height: 5vw;
object-fit: cover;
object-position: center;
font-size: 5vw !important;
user-select: none;
}
.current-song-wrapper {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
height: 55vh;
width: 100%;
margin-bottom: 0.5%;
margin-top: 0.25%;
}
.current-song {
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
margin-top: 1vh;
padding: 1vh;
text-align: center;
background-color: rgba( 0, 0, 0, 0.4 );
border-radius: 10px;
}
.fancy-view-song-art {
height: 30vh;
width: 30vh;
object-fit: cover;
object-position: center;
margin-bottom: 10px;
font-size: 30vh !important;
border-radius: 30px;
}
#app {
background-color: rgba( 0, 0, 0, 0 );
}
#progress, #progress::-webkit-progress-bar {
background-color: rgb(82, 82, 82);
color: rgb(82, 82, 82);
width: 30vw;
height: 10px;
border: none;
border-radius: 0px;
accent-color: white;
-webkit-appearance: none;
appearance: none;
border-radius: 10px;
margin-bottom: 5px;
}
#progress::-moz-progress-bar {
background-color: white;
}
#progress::-webkit-progress-value {
background-color: white !important;
}
.mode-selector-wrapper {
opacity: 0;
position: fixed;
right: 0.5%;
top: 0.5%;
padding: 0.5%;
}
.mode-selector-wrapper:hover {
opacity: 1;
}
.dancing-style {
font-size: 250%;
margin: 0;
font-weight: bolder;
}
.info {
position: fixed;
font-size: 12px;
transform: rotate(270deg);
left: -150px;
margin: 0;
padding: 0;
top: 50%;
z-index: 100;
color: white;
}
</style>
<style scoped>
.background {
position: fixed;
left: -50vw;
width: 200vw;
height: 200vw;
top: -50vw;
z-index: 1;
filter: blur(10px);
background: conic-gradient( blue, green, red, blue );
animation: gradientAnim 10s infinite linear;
background-position: center;
}
.beat, .beat-manual {
height: 100%;
width: 100%;
background-color: rgba( 0, 0, 0, 0.1 );
display: none;
}
.beat {
animation: beatAnim 0.6s infinite linear;
}
@keyframes beatAnim {
0% {
background-color: rgba( 0, 0, 0, 0.2 );
}
20% {
background-color: rgba( 0, 0, 0, 0 );
}
100% {
background-color: rgba( 0, 0, 0, 0.2 );
}
}
@keyframes gradientAnim {
from {
transform: rotate( 0deg );
}
to {
transform: rotate( 360deg );
}
}
</style>

View File

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

View File

@@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "public/musickit.js"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
"composite": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}

View File

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

View File

@@ -1,19 +0,0 @@
{
"extends": "@tsconfig/node20/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"nightwatch.conf.*",
"playwright.config.*"
],
"compilerOptions": {
"composite": true,
"noEmit": true,
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["node"]
}
}

View File

@@ -1,21 +0,0 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { nodePolyfills } from 'vite-plugin-node-polyfills'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
nodePolyfills(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 8081
}
})

View File

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

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

@@ -0,0 +1,702 @@
import vue from 'eslint-plugin-vue';
import eslint from '@eslint/js';
import globals from 'globals';
import typescript from '@typescript-eslint/eslint-plugin';
import stylistic from '@stylistic/eslint-plugin';
import tseslint from 'typescript-eslint';
const style = {
'plugins': {
'@stylistic': stylistic,
'@stylistic/js': stylistic,
'@stylistic/ts': stylistic,
},
'files': [
'**/*.ts',
'**/*.js',
'**/*.mjs',
'**/*.cjs',
'**/*.tsx',
'**/*.jsx'
],
'rules': {
// Formatting
'@stylistic/array-bracket-newline': [
'error',
{
'multiline': true,
'minItems': 2
}
],
'@stylistic/array-bracket-spacing': [
'error',
'always'
],
'@stylistic/array-element-newline': [
'error',
{
'multiline': true,
'minItems': 2
}
],
'@stylistic/arrow-parens': [
'error',
'as-needed'
],
'@stylistic/arrow-spacing': [
'error',
{
'before': true,
'after': true
}
],
'@stylistic/block-spacing': [
'error',
'always'
],
'@stylistic/brace-style': [
'error',
'1tbs'
],
'@stylistic/comma-spacing': [
'error',
{
'before': false,
'after': true
}
],
'@stylistic/comma-style': [
'error',
'last'
],
'@stylistic/dot-location': [
'error',
'property'
],
'@stylistic/eol-last': [
'error',
'always'
],
'@stylistic/function-call-spacing': [
'error',
'never'
],
'@stylistic/function-paren-newline': [
'error',
'multiline'
],
'@stylistic/function-call-argument-newline': [
'error',
'consistent'
],
'@stylistic/implicit-arrow-linebreak': [
'error',
'beside'
],
'@stylistic/indent': [
'error',
4
],
'@stylistic/key-spacing': [
'error',
{
'beforeColon': false,
'afterColon': true
}
],
'@stylistic/keyword-spacing': [
'error',
{
'before': true,
'after': true
}
],
'@stylistic/lines-between-class-members': [
'error',
'always'
],
'@stylistic/max-len': [
'warn',
{
'code': 90,
'comments': 100,
'ignoreComments': false,
'ignoreUrls': true,
'ignoreStrings': false
}
],
'@stylistic/new-parens': [
'error',
'always'
],
'@stylistic/newline-per-chained-call': [ 'error' ],
'@stylistic/no-extra-parens': [
'error',
'all',
{
'nestedBinaryExpressions': false,
'ternaryOperandBinaryExpressions': false,
'ignoreJSX': 'multi-line',
'nestedConditionalExpressions': false
}
],
'@stylistic/no-extra-semi': 'error',
'@stylistic/no-floating-decimal': 'error',
'@stylistic/no-mixed-operators': 'error',
'@stylistic/no-mixed-spaces-and-tabs': 'error',
'@stylistic/no-multi-spaces': 'error',
'@stylistic/no-multiple-empty-lines': [
'error',
{
'max': 3,
'maxEOF': 2
}
],
'@stylistic/no-tabs': 'error',
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/no-whitespace-before-property': 'error',
'@stylistic/object-curly-newline': [
'error',
{
'multiline': true,
'minProperties': 1
}
],
'@stylistic/object-curly-spacing': [
'error',
'always'
],
'@stylistic/object-property-newline': 'error',
'@stylistic/operator-linebreak': [
'error',
'before'
],
'@stylistic/one-var-declaration-per-line': 'error',
'@stylistic/padded-blocks': [
'error',
{
'blocks': 'never',
'classes': 'always',
'switches': 'never',
}
],
// Padding lines. The most in-depth part of this config
'@stylistic/padding-line-between-statements': [
'error',
// Variables, Constants
{
'blankLine': 'never',
'prev': 'var',
'next': 'var'
},
{
'blankLine': 'never',
'prev': 'let',
'next': 'let'
},
{
'blankLine': 'never',
'prev': 'const',
'next': 'const'
},
{
'blankLine': 'always',
'prev': 'var',
'next': [
'block',
'block-like',
'break',
'cjs-export',
'cjs-import',
'class',
'const',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'import',
'let',
'return',
'switch',
'throw',
'try',
'var',
'with'
]
},
{
'blankLine': 'always',
'prev': 'let',
'next': [
'block',
'block-like',
'break',
'cjs-export',
'cjs-import',
'class',
'const',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'import',
'return',
'switch',
'throw',
'try',
'var',
'while',
'with'
]
},
{
'blankLine': 'always',
'prev': 'const',
'next': [
'block',
'block-like',
'break',
'cjs-export',
'cjs-import',
'class',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'import',
'let',
'return',
'switch',
'throw',
'try',
'var',
'while',
'with'
]
},
// Import
{
'blankLine': 'never',
'prev': 'import',
'next': 'import'
},
{
'blankLine': 'never',
'prev': 'cjs-import',
'next': 'cjs-import'
},
{
'blankLine': 'always',
'prev': [
'block',
'block-like',
'break',
'cjs-export',
'class',
'const',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'let',
'return',
'switch',
'throw',
'try',
'var',
'while',
'with'
],
'next': 'cjs-import'
},
{
'blankLine': 'always',
'prev': 'cjs-import',
'next': [
'block',
'block-like',
'break',
'cjs-export',
'class',
'const',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'let',
'return',
'switch',
'throw',
'try',
'var',
'while',
'with'
]
},
{
'blankLine': 'always',
'prev': [
'block',
'block-like',
'break',
'cjs-export',
'class',
'const',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'let',
'return',
'switch',
'throw',
'try',
'var',
'while',
'with'
],
'next': 'import'
},
{
'blankLine': 'always',
'prev': 'import',
'next': [
'block',
'block-like',
'break',
'cjs-export',
'class',
'const',
'continue',
'debugger',
'directive',
'do',
'empty',
'export',
'expression',
'for',
'function',
'if',
'iife',
'let',
'return',
'switch',
'throw',
'try',
'var',
'while',
'with'
]
},
// If
{
'blankLine': 'always',
'prev': '*',
'next': 'if'
},
{
'blankLine': 'always',
'prev': 'if',
'next': '*'
},
// For
{
'blankLine': 'always',
'prev': '*',
'next': 'for'
},
{
'blankLine': 'always',
'prev': 'for',
'next': '*'
},
// While
{
'blankLine': 'always',
'prev': '*',
'next': 'while'
},
{
'blankLine': 'always',
'prev': 'while',
'next': '*'
},
// Functions
{
'blankLine': 'always',
'prev': '*',
'next': 'function'
},
{
'blankLine': 'always',
'prev': 'function',
'next': '*'
},
// Block Statements
{
'blankLine': 'always',
'prev': '*',
'next': 'block-like'
},
{
'blankLine': 'always',
'prev': 'block-like',
'next': '*'
},
// Switch
{
'blankLine': 'always',
'prev': '*',
'next': 'switch'
},
{
'blankLine': 'always',
'prev': 'switch',
'next': '*'
},
// Try-Catch
{
'blankLine': 'always',
'prev': '*',
'next': 'try'
},
{
'blankLine': 'always',
'prev': 'try',
'next': '*'
},
// Throw
{
'blankLine': 'always',
'prev': '*',
'next': 'throw'
},
{
'blankLine': 'always',
'prev': 'throw',
'next': '*'
},
// Return
{
'blankLine': 'never',
'prev': 'return',
'next': '*'
},
{
'blankLine': 'always',
'prev': '*',
'next': 'return'
},
// Export
{
'blankLine': 'always',
'prev': '*',
'next': 'export'
},
{
'blankLine': 'always',
'prev': 'export',
'next': '*'
},
{
'blankLine': 'always',
'prev': '*',
'next': 'cjs-export'
},
{
'blankLine': 'always',
'prev': 'cjs-export',
'next': '*'
},
// Classes
{
'blankLine': 'always',
'prev': '*',
'next': 'class'
},
{
'blankLine': 'always',
'prev': 'class',
'next': '*'
},
],
'@stylistic/quote-props': [
'error',
'always'
],
'@stylistic/quotes': [
'error',
'single'
],
'@stylistic/rest-spread-spacing': [
'error',
'never'
],
'@stylistic/semi': [
'error',
'always'
],
'@stylistic/semi-spacing': [
'error',
{
'before': false,
'after': true
}
],
'@stylistic/semi-style': [
'error',
'last'
],
'@stylistic/space-before-blocks': [
'error',
'always'
],
'@stylistic/space-before-function-paren': [
'error',
'always'
],
'@stylistic/space-in-parens': [
'error',
'always'
],
'@stylistic/space-infix-ops': [
'error',
{
'int32Hint': false
}
],
'@stylistic/space-unary-ops': 'error',
'@stylistic/spaced-comment': [
'error',
'always'
],
'@stylistic/template-curly-spacing': [
'error',
'always'
],
'@stylistic/switch-colon-spacing': 'error',
'@stylistic/wrap-iife': [
'error',
'inside'
],
'@stylistic/wrap-regex': 'error',
'@stylistic/ts/type-annotation-spacing': 'error',
}
};
/** @type {import('eslint').Linter.Config} */
export default tseslint.config(
// Base JavaScript rules
eslint.configs.recommended,
tseslint.configs.recommended,
style,
// Vue support (including TS and JSX inside SFCs)
{
'files': [ '**/*.vue' ],
'languageOptions': {
'sourceType': 'module',
'ecmaVersion': 'latest',
'globals': globals.browser,
'parserOptions': {
'parser': tseslint.parser,
},
},
'plugins': {
'vue': vue,
'@stylistic': stylistic,
'@stylistic/js': stylistic,
'@stylistic/ts': stylistic,
'@typescript-eslint': typescript,
},
'extends': [
eslint.configs.recommended,
...vue.configs['flat/recommended']
],
'rules': {
...typescript.configs.recommended.rules,
...style.rules,
// Vue specific rules
'@stylistic/indent': 'off',
'vue/html-indent': [
'error',
4
],
'vue/html-comment-indent': [
'error',
4
],
'vue/script-indent': [
'error',
4,
{
'baseIndent': 1,
'switchCase': 1
}
],
'vue/html-self-closing': [
'error',
{
'html': {
'void': 'never',
'normal': 'never',
'component': 'always'
},
'svg': 'always',
'math': 'never'
}
],
'vue/max-attributes-per-line': [
'error',
{
'singleline': 3,
'multiline': 1,
}
],
},
},
);

1503
backend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,38 +0,0 @@
{
"name": "musicplayer-v2-backend",
"version": "1.0.0",
"description": "The backend for MusicPlayerV2",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"repository": {
"type": "git",
"url": "git+https://github.com/simplePCBuilding/MusicPlayerV2.git"
},
"author": "Janis Hutz",
"license": "GPL-3.0-or-later",
"bugs": {
"url": "https://github.com/simplePCBuilding/MusicPlayerV2/issues"
},
"homepage": "https://github.com/simplePCBuilding/MusicPlayerV2#readme",
"devDependencies": {
"@types/express-session": "^1.18.0",
"typescript": "^5.4.5"
},
"dependencies": {
"@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",
"oauth-janishutz-client-server": "file:../../oauth/client/server/dist",
"socket.io": "^4.7.5",
"store.janishutz.com-sdk": "file:../../store/sdk/dist"
}
}

View File

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

View File

@@ -1,450 +0,0 @@
import express from 'express';
import path from 'path';
import fs from 'fs';
import jwt from 'jsonwebtoken';
import cors from 'cors';
import account from './account';
import sdk from 'oauth-janishutz-client-server';
import { createServer } from 'node:http';
import { Server } from 'socket.io';
import crypto from 'node:crypto';
import type { Room, Song } from './definitions';
import storeSDK from 'store.janishutz.com-sdk';
import bodyParser from 'body-parser';
const isFossVersion = true;
declare let __dirname: string | undefined
if ( typeof( __dirname ) === 'undefined' ) {
__dirname = path.resolve( path.dirname( '' ) );
}
// TODO: Change config file, as well as in main.ts, index.html, oauth, if deploying there
// const sdkConfig = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/sdk.config.testing.json' ) ) );
const sdkConfig = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/sdk.config.secret.json' ) ) );
const run = () => {
let app = express();
app.use( cors( {
credentials: true,
origin: true
} ) );
if ( !isFossVersion ) {
// storeSDK.configure( JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/store-sdk.config.testing.json' ) ) ) );
storeSDK.configure( JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/store-sdk.config.secret.json' ) ) ) );
}
const httpServer = createServer( app );
if ( !isFossVersion ) {
// Load id.janishutz.com SDK and allow signing in
sdk.routes( app, ( uid: string ) => {
return new Promise( ( resolve, reject ) => {
account.checkUser( uid ).then( stat => {
resolve( stat );
} ).catch( e => {
reject( e );
} );
} );
},
( uid: string, email: string, username: string ) => {
return new Promise( ( resolve, reject ) => {
account.createUser( uid, username, email ).then( stat => {
resolve( stat );
} ).catch( e => {
reject( e );
} );
} );
}, sdkConfig );
}
// Websocket for events
interface SocketData {
[key: string]: Room;
}
const socketData: SocketData = {};
const io = new Server( httpServer, {
cors: {
origin: true,
credentials: true,
}
} );
io.on( 'connection', ( socket ) => {
socket.on( 'create-room', ( room: { name: string, token: string }, cb: ( res: { status: boolean, msg: string } ) => void ) => {
if ( socketData[ room.name ] ) {
if ( room.token === socketData[ room.name ].roomToken ) {
socket.join( room.name );
cb( {
status: true,
msg: 'ADDED_TO_ROOM'
} );
} else {
cb( {
status: false,
msg: 'ERR_TOKEN_INVALID'
} );
}
} else {
cb( {
status: false,
msg: 'ERR_NAME_INVALID'
} );
}
} );
socket.on( 'delete-room', ( room: { name: string, token: string }, cb: ( res: { status: boolean, msg: string } ) => void ) => {
if ( socketData[ room.name ] ) {
if ( room.token === socketData[ room.name ].roomToken ) {
socket.leave( room.name );
socket.to( room.name ).emit( 'delete-share', room.name );
socketData[ room.name ] = undefined;
cb( {
status: true,
msg: 'ROOM_DELETED'
} );
} else {
cb( {
status: false,
msg: 'ERR_TOKEN_INVALID'
} );
}
} else {
cb( {
status: false,
msg: 'ERR_NAME_INVALID'
} );
}
} );
socket.on( 'join-room', ( room: string, cb: ( res: { status: boolean, msg: string, data?: { playbackStatus: boolean, playbackStart: number, playlist: Song[], playlistIndex: number, useAntiTamper: boolean } } ) => void ) => {
if ( socketData[ room ] ) {
socket.join( room );
cb( {
data: {
playbackStart: socketData[ room ].playbackStart,
playbackStatus: socketData[ room ].playbackStatus,
playlist: socketData[ room ].playlist,
playlistIndex: socketData[ room ].playlistIndex,
useAntiTamper: socketData[ room ].useAntiTamper,
},
msg: 'STATUS_OK',
status: true,
} )
} else {
cb( {
msg: 'ERR_NO_ROOM_WITH_THIS_ID',
status: false,
} );
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 );
}
}
} );
} );
/*
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', ( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
if ( socketData[ String( request.query.room ) ] ) {
response.writeHead( 200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
} );
response.status( 200 );
response.flushHeaders();
response.write( `data: ${ JSON.stringify( { 'type': 'basics', 'data': socketData[ String( request.query.room ) ] } ) }\n\n` );
const sid = sdk.getSessionID( request );
if ( sdk.checkAuth( request ) ) {
importantClients[ sid ] = { 'response': response, 'room': String( request.query.room ) };
}
connectedClients[ sid ] = { 'response': response, 'room': String( request.query.room ) };
if ( !clientReference[ String( request.query.room ) ] ) {
clientReference[ String( request.query.room ) ] = [];
}
if ( !clientReference[ String( request.query.room ) ].includes( sid ) ) {
clientReference[ String( request.query.room ) ].push( sid );
}
request.on( 'close', () => {
try {
importantClients[ sid ] = undefined;
} catch ( e ) { /* empty */ }
const cl = clientReference[ String( request.query.room ) ];
for ( let c in cl ) {
if ( cl[ c ] === sid ) {
cl.splice( parseInt( c ), 1 );
break;
}
}
connectedClients[ sid ] = undefined;
} );
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
} );
app.get( '/socket/getData', ( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
response.send( socketData[ String( request.query.room ) ] );
} else {
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
} );
app.get( '/socket/joinRoom', ( request: express.Request, response: express.Response ) => {
if ( request.query.room ) {
if ( socketData[ String( request.query.room ) ] ) {
response.send( 'ok' );
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' );
}
} );
app.post( '/socket/update', bodyParser.json(), ( request: express.Request, response: express.Response ) => {
if ( socketData[ request.body.roomName ] ) {
if ( request.body.event === 'tampering' ) {
const clients = clientReference[ request.body.roomName ];
for ( let client in clients ) {
if ( importantClients[ clients[ client ] ] ) {
importantClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'type': 'tampering-msg', 'data': true } ) + '\n\n' );
}
}
response.send( 'ok' );
} else {
if ( socketData[ request.body.roomName ].roomToken === request.body.roomToken ) {
let send = false;
let update = '';
if ( request.body.event === 'playback-start-update' ) {
send = true;
update = 'playback-start';
socketData[ request.body.roomName ].playbackStart = request.body.data;
} else if ( request.body.event === 'playback-update' ) {
send = true;
update = 'playback';
socketData[ request.body.roomName ].playbackStatus = request.body.data;
} else if ( request.body.event === 'playlist-update' ) {
send = true;
update = 'playlist';
socketData[ request.body.roomName ].playlist = request.body.data;
} else if ( request.body.event === 'playlist-index-update' ) {
send = true;
update = 'playlist-index';
socketData[ request.body.roomName ].playlistIndex = request.body.data;
}
if ( send ) {
const clients = clientReference[ request.body.roomName ];
for ( let client in clients ) {
if ( connectedClients[ clients[ client ] ] ) {
connectedClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'type': update, 'data': request.body.data } ) + '\n\n' );
}
}
response.send( 'ok' );
} else {
response.status( 404 ).send( 'ERR_CANNOT_SEND' );
}
} else {
response.status( 403 ).send( 'ERR_UNAUTHORIZED' );
}
}
} else {
response.status( 400 ).send( 'ERR_WRONG_REQUEST' );
}
} );
app.post( '/socket/deleteRoom', bodyParser.json(), ( request: express.Request, response: express.Response ) => {
if ( request.body.roomName ) {
if ( socketData[ request.body.roomName ] ) {
if ( socketData[ request.body.roomName ].roomToken === request.body.roomToken ) {
socketData[ request.body.roomName ] = undefined;
const clients = clientReference[ request.body.roomName ];
for ( let client in clients ) {
if ( connectedClients[ clients[ client ] ] ) {
connectedClients[ clients[ client ] ].response.write( 'data: ' + JSON.stringify( { 'type': 'delete-share', 'data': true } ) + '\n\n' );
}
}
} else {
response.send( 403 ).send( 'ERR_UNAUTHORIZED' );
}
} else {
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
}
} else {
response.status( 400 ).send( 'ERR_NO_ROOM_NAME' );
}
} );
/*
GENERAL ROUTES
*/
app.get( '/', ( request: express.Request, response: express.Response ) => {
response.send( 'Please visit <a href="https://music.janishutz.com">https://music.janishutz.com</a> to use this service' );
} );
app.get( '/createRoomToken', ( request: express.Request, response: express.Response ) => {
if ( sdk.checkAuth( request ) ) {
const roomName = String( request.query.roomName ) ?? '';
if ( !socketData[ roomName ] ) {
const roomToken = crypto.randomUUID();
socketData[ roomName ] = {
playbackStart: 0,
playbackStatus: false,
playlist: [],
playlistIndex: 0,
roomName: roomName,
roomToken: roomToken,
ownerUID: sdk.getUserData( request ).uid,
useAntiTamper: request.query.useAntiTamper === 'true' ? true : false,
};
response.send( roomToken );
} else {
if ( socketData[ roomName ].ownerUID === sdk.getUserData( request ).uid ) {
response.send( socketData[ roomName ].roomToken );
} else {
response.status( 409 ).send( 'ERR_CONFLICT' );
}
}
} else {
response.status( 403 ).send( 'ERR_FORBIDDEN' );
}
} );
app.get( '/getAppleMusicDevToken', ( req, res ) => {
// sign dev token
const privateKey = fs.readFileSync( path.join( __dirname + '/config/apple_private_key.p8' ) ).toString();
// TODO: Remove secret
const config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/apple-music-api.config.secret.json' ) ) );
const 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 );
} );
// TODO: Get user's subscriptions using store sdk
app.get( '/checkUserStatus', ( request: express.Request, response: express.Response ) => {
if ( sdk.checkAuth( request ) ) {
storeSDK.getSubscriptions( sdk.getUserData( request ).uid ).then( stat => {
let owned = false;
const now = new Date().getTime();
for ( let sub in stat ) {
if ( stat[ sub ].expires - now > 0
&& ( stat[ sub ].id === 'com.janishutz.MusicPlayer.subscription' || stat[ sub ].id === 'com.janishutz.MusicPlayer.subscription-month' ) ) {
owned = true;
}
}
if ( owned ) {
response.send( 'ok' );
} else {
response.send( 'ERR_NOT_OWNED' );
}
} ).catch( e => {
console.error( e );
response.status( 404 ).send( 'ERR_NOT_OWNED' );
} );
} else {
response.status( 401 ).send( 'ERR_AUTH_REQUIRED' );
}
} );
app.use( ( request: express.Request, response: express.Response, next: express.NextFunction ) => {
response.status( 404 ).send( 'ERR_NOT_FOUND' );
// response.sendFile( path.join( __dirname + '' ) )
} );
const PORT = process.env.PORT || 8082;
httpServer.listen( PORT );
}
export default {
run
}

View File

@@ -1,18 +0,0 @@
export interface Room {
playbackStatus: boolean;
playbackStart: number;
playlist: Song[];
playlistIndex: number;
roomName: string;
roomToken: string;
ownerUID: string;
useAntiTamper: boolean;
}
export interface Song {
title: string;
artist: string;
duration: number;
cover: string;
additionalInfo?: string;
}

104
backend/src/logger.ts Normal file
View File

@@ -0,0 +1,104 @@
import {
writeFile
} from 'node:fs';
const log = ( ...msg: string[] ) => {
output( 'log', log.caller.toString(), ...msg );
};
const info = ( ...msg: string[] ) => {
output( 'info', log.caller.toString(), ...msg );
};
const debug = ( ...msg: string[] ) => {
output( 'debug', log.caller.toString(), ...msg );
};
const warn = ( ...msg: string[] ) => {
output( 'warn', log.caller.toString(), ...msg );
};
const error = ( ...msg: string[] ) => {
output( 'error', log.caller.toString(), ...msg );
};
const fatal = ( ...msg: string[] ) => {
output( 'fatal', log.caller.toString(), ...msg );
};
let loc = 'stderr';
let lev = 0;
type LogLevel = 'debug' | 'info' | 'log' | 'warn' | 'error' | 'fatal';
const levels = [
'debug',
'info',
'log',
'warn',
'error',
'fatal'
];
const configure = ( location: 'stderr' | 'file', minLevel: LogLevel, file?: string ) => {
if ( location === 'file' && !file ) {
throw new Error( 'File parameter required when location is "file"' );
}
loc = location === 'stderr' ? 'stderr' : file;
lev = levels.indexOf( minLevel );
};
const logfile: string[] = [];
const output = ( level: LogLevel, caller: string, ...message: string[] ) => {
if ( levels.indexOf( level ) < lev ) {
return;
}
const msg = message.join( ' ' );
const out = `[${ level.toUpperCase() }] (${ new Date().toISOString() }) in ${ caller }: ${ msg }`;
if ( loc === 'stderr' ) {
console.error( out );
} else {
logfile.push( out );
save();
}
};
let isSaving = false;
let waitingOnSave = false;
const save = () => {
if ( isSaving ) {
waitingOnSave = true;
return;
}
isSaving = true;
writeFile( loc, JSON.stringify( logfile ), err => {
if ( err )
console.error( '[LOGGER] Failed to save with error ' + err );
if ( waitingOnSave ) {
waitingOnSave = false;
isSaving = false;
save();
}
isSaving = false;
} );
};
export default {
log,
info,
debug,
warn,
error,
fatal,
configure
};

View File

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

View File

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

View File

@@ -1,330 +0,0 @@
/*
* libreevent - db.js
*
* Created by Janis Hutz 03/26/2023, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
import path from 'path';
import fs from 'fs';
import * as sqlDB from './mysqldb.js';
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',
};
let dbh = new sqlDB.SQLDB();
dbh.connect();
/**
* Initialize database (create tables, etc)
* @returns {undefined}
*/
const initDB = (): undefined => {
( async() => {
console.log( '[ DB ] Setting up...' );
dbh.setupDB();
console.log( '[ 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 ) => {
let settings = {
'command': 'LeftJoin',
'property': column,
'searchQuery': searchQuery,
'selection': '',
'secondTable': dbRef[ secondTable ],
'matchingParam': dbRef[ db ] + '.' + nameOfMatchingParam + '=' + dbRef[ secondTable ] + '.' + nameOfMatchingParam,
}
for ( let 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> => {
let allFiles = {};
for ( let file in files ) {
try {
allFiles[ files[ file ] ] = await getJSONData( files[ file ] );
} catch( err ) {
allFiles[ files[ file ] ] = 'ERROR: ' + err;
}
}
return allFiles;
}
/**
* Load all data from a JSON file
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
* @returns {Promise<object>} The data that was loaded
*/
const getJSONData = ( file: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
if ( data.byteLength > 0 ) {
resolve( JSON.parse( data.toString() ) ?? {} );
} else {
resolve( { } );
}
}
} );
} );
};
/**
* Load some data from a JSON file
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
* @param {string} identifier The identifier of the element which should be loaded
* @returns {Promise<object>} The data that was loaded
*/
const getJSONDataSimple = ( file: string, identifier: string ): Promise<object> => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
if ( data.byteLength > 0 ) {
resolve( JSON.parse( data.toString() )[ identifier ] ?? {} );
} else {
resolve( { } );
}
}
} );
} );
};
/**
* Get JSON data, but synchronous (blocking)
* @param {string} file The file to be loaded (path relative to root)
* @returns {object} Returns the JSON file
*/
const getJSONDataSync = ( file: string ): Object => {
return JSON.parse( fs.readFileSync( path.join( __dirname + '/' + file ) ).toString() );
};
/**
* Description
* @param {any} db:string
* @param {any} identifier:string
* @param {any} values:any
* @returns {any}
*/
const writeJSONDataSimple = ( db: string, identifier: string, values: any ) => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
let dat = {};
if ( data.byteLength > 0 ) {
dat = JSON.parse( data.toString() ) ?? {};
}
dat[ identifier ] = values;
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), ( error ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
}
resolve( true );
} );
}
} );
} );
};
/**
* Write data to a JSON file
* @param {string} db The database to write into
* @param {object} data The data to write
* @returns {Promise<boolean>}
*/
const writeJSONData = ( db: string, data: object ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( data ), ( error ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
resolve( true );
}
} );
} );
};
/**
* Delete data from a JSON file
* @param {string} db The file to delete from (just filename, has to be in "/data/" folder, no file extension)
* @param {string} identifier The identifier of the element to delete
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean
*/
const deleteJSONDataSimple = ( db: string, identifier: string ): Promise<boolean> => {
return new Promise( ( resolve, reject ) => {
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
} else {
let dat = {};
if ( data.byteLength > 0 ) {
dat = JSON.parse( data.toString() ) ?? {};
}
delete dat[ identifier ];
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), ( error ) => {
if ( error ) {
reject( 'Error occurred: Error trace: ' + error );
}
resolve( true );
} );
}
} );
} );
};
export default { initDB, checkDataAvailability, deleteDataSimple, deleteJSONDataSimple, getData,
getDataSimple, getDataWithLeftJoinFunction, getJSONData, getJSONDataBatch, getJSONDataSimple,
getJSONDataSync, writeDataSimple, writeJSONData, writeJSONDataSimple
};

View File

@@ -1,189 +0,0 @@
/*
* libreevent - mysqldb.js
*
* Created by Janis Hutz 07/12/2023, Licensed under the GPL V3 License
* https://janishutz.com, development@janishutz.com
*
*
*/
import mysql from 'mysql';
import fs from 'fs';
import path from 'path';
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 ) {
console.log( '[ SQL ] Attempting to recover from critical error' );
this.sqlConnection = mysql.createConnection( this.config );
this.isRecovering = false;
}
this.sqlConnection.connect( ( err ) => {
if ( err ) {
console.error( '[ SQL ]: An error ocurred whilst connecting: ' + err.stack );
reject( err );
return;
}
console.log( '[ SQL ] Connected to database successfully' );
self.sqlConnection.on( 'error', ( err ) => {
if ( err.code === 'ECONNRESET' ) {
self.isRecovering = true;
setTimeout( () => {
self.disconnect();
self.connect();
}, 1000 );
} else {
console.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 ( let 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 ( let value in operation.newValues ) {
updatedValues += value + ' = ' + this.sqlConnection.escape( String( operation.newValues[ value ] ) ) + ', ';
}
command += updatedValues.slice( 0, updatedValues.length - 2 );
command += ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
}
} else if ( operation.command === 'deleteData' ) {
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
else {
command = 'DELETE FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
}
} else if ( operation.command === 'InnerJoin' ) {
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' INNER JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
} else if ( operation.command === 'LeftJoin' ) {
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' LEFT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
} else if ( operation.command === 'RightJoin' ) {
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' RIGHT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
}
this.sqlConnection.query( command, ( error, results ) => {
if ( error ) reject( error );
resolve( results );
} );
} );
}
}
export { SQLConfig, SQLDB };

View File

@@ -1,12 +0,0 @@
import db from './db.js';
// import hash from '../security/hash.js';
db.initDB();
// setTimeout( () => {
// console.log( 'Setting up admin account' );
// hash.hashPassword( 'test' ).then( hash => {
// db.writeDataSimple( 'admin', 'email', 'info@janishutz.com', { email: 'info@janishutz.com', pass: hash, two_fa: 'enhanced' } );
// console.log( 'Complete!' );
// } );
// }, 5000 );

View File

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