mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
Compare commits
58
Commits
V3.0.0
..
01526034ce
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01526034ce | ||
|
|
faa271edd6 | ||
|
|
3a0f8210dd | ||
|
|
a2b93e9624 | ||
|
|
f3d9251eee | ||
|
|
72dada18eb | ||
|
|
5f6e034dac | ||
|
|
eead8b558a | ||
|
|
4753d998b3 | ||
|
|
9bf88e033e | ||
|
|
0e155831d7 | ||
|
|
1d492d5922 | ||
|
|
4d9064f5c2 | ||
|
|
5b223875bd | ||
|
|
ce35371c06 | ||
|
|
4c5e1a864b | ||
|
|
94906c3499 | ||
|
|
c38b8d3115 | ||
|
|
d03fbe5e66 | ||
|
|
494eef6cd2 | ||
|
|
cb0db033ea | ||
|
|
d7433e95a4 | ||
|
|
576b6f9490 | ||
|
|
e0dcfa6964 | ||
|
|
69d2db8c37 | ||
|
|
9a347c9206 | ||
|
|
60ea4669ab | ||
|
|
669cc620bf | ||
|
|
64d086dec4 | ||
|
|
1d714da494 | ||
|
|
d63df5898b | ||
|
|
bd18636141 | ||
|
|
51f0b5639a | ||
|
|
6a6b06a994 | ||
|
|
e2e27e640f | ||
|
|
ac575adedb | ||
|
|
d15366e572 | ||
|
|
25cfc80925 | ||
|
|
952ef0ee85 | ||
|
|
80c497b80a | ||
|
|
8778740454 | ||
|
|
74075c2919 | ||
|
|
3e13a4ebf5 | ||
|
|
84b640ee5e | ||
|
|
0315241d76 | ||
|
|
6e93cfdf2c | ||
|
|
6b9d556e57 | ||
|
|
025c7ed111 | ||
|
|
057ea67a95 | ||
|
|
16543fb577 | ||
|
|
f0c538126d | ||
|
|
b0a1f9a538 | ||
|
|
9fac37203d | ||
|
|
b10aeed1f8 | ||
|
|
b8faeef189 | ||
|
|
4ef98f80cf | ||
|
|
c9f487b981 | ||
|
|
dc2327af33 |
+2
-1
@@ -3,4 +3,5 @@ node_modules
|
|||||||
*.secret.json
|
*.secret.json
|
||||||
apple_private_key.p8
|
apple_private_key.p8
|
||||||
musicplayerv2-server.zip
|
musicplayerv2-server.zip
|
||||||
dist
|
dist
|
||||||
|
package-lock.json
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
# Logs
|
|
||||||
logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
pnpm-debug.log*
|
|
||||||
lerna-debug.log*
|
|
||||||
|
|
||||||
node_modules
|
|
||||||
.DS_Store
|
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
coverage
|
|
||||||
*.local
|
|
||||||
|
|
||||||
/cypress/videos/
|
|
||||||
/cypress/screenshots/
|
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
|
||||||
!.vscode/extensions.json
|
|
||||||
.idea
|
|
||||||
*.suo
|
|
||||||
*.ntvs*
|
|
||||||
*.njsproj
|
|
||||||
*.sln
|
|
||||||
*.sw?
|
|
||||||
|
|
||||||
*.tsbuildinfo
|
|
||||||
-7
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": [
|
|
||||||
"Vue.volar",
|
|
||||||
"Vue.vscode-typescript-vue-plugin",
|
|
||||||
"dbaeumer.vscode-eslint"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
# MusicPlayerV2-GUI
|
|
||||||
|
|
||||||
This template should help get you started developing with Vue 3 in Vite.
|
|
||||||
|
|
||||||
## Recommended IDE Setup
|
|
||||||
|
|
||||||
[VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin).
|
|
||||||
|
|
||||||
## Type Support for `.vue` Imports in TS
|
|
||||||
|
|
||||||
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types.
|
|
||||||
|
|
||||||
If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps:
|
|
||||||
|
|
||||||
1. Disable the built-in TypeScript Extension
|
|
||||||
1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette
|
|
||||||
2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)`
|
|
||||||
2. Reload the VSCode window by running `Developer: Reload Window` from the command palette.
|
|
||||||
|
|
||||||
## Customize configuration
|
|
||||||
|
|
||||||
See [Vite Configuration Reference](https://vitejs.dev/config/).
|
|
||||||
|
|
||||||
## Project Setup
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Compile and Hot-Reload for Development
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type-Check, Compile and Minify for Production
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
### Lint with [ESLint](https://eslint.org/)
|
|
||||||
|
|
||||||
```sh
|
|
||||||
npm run lint
|
|
||||||
```
|
|
||||||
@@ -1,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>
|
|
||||||
Generated
-5014
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -1,56 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 24.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" id="Artwork" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
|
||||||
width="361px" height="361px" viewBox="0 0 361 361" style="enable-background:new 0 0 361 361;" xml:space="preserve">
|
|
||||||
<style type="text/css">
|
|
||||||
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#SVGID_1_);}
|
|
||||||
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:#FFFFFF;}
|
|
||||||
</style>
|
|
||||||
<g id="Layer_5">
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="180" y1="358.6047" x2="180" y2="7.7586">
|
|
||||||
<stop offset="0" style="stop-color:#FA233B"/>
|
|
||||||
<stop offset="1" style="stop-color:#FB5C74"/>
|
|
||||||
</linearGradient>
|
|
||||||
<path class="st0" d="M360,112.61c0-4.3,0-8.6-0.02-12.9c-0.02-3.62-0.06-7.24-0.16-10.86c-0.21-7.89-0.68-15.84-2.08-23.64
|
|
||||||
c-1.42-7.92-3.75-15.29-7.41-22.49c-3.6-7.07-8.3-13.53-13.91-19.14c-5.61-5.61-12.08-10.31-19.15-13.91
|
|
||||||
c-7.19-3.66-14.56-5.98-22.47-7.41c-7.8-1.4-15.76-1.87-23.65-2.08c-3.62-0.1-7.24-0.14-10.86-0.16C255.99,0,251.69,0,247.39,0
|
|
||||||
H112.61c-4.3,0-8.6,0-12.9,0.02c-3.62,0.02-7.24,0.06-10.86,0.16C80.96,0.4,73,0.86,65.2,2.27c-7.92,1.42-15.28,3.75-22.47,7.41
|
|
||||||
c-7.07,3.6-13.54,8.3-19.15,13.91c-5.61,5.61-10.31,12.07-13.91,19.14c-3.66,7.2-5.99,14.57-7.41,22.49
|
|
||||||
c-1.4,7.8-1.87,15.76-2.08,23.64c-0.1,3.62-0.14,7.24-0.16,10.86C0,104.01,0,108.31,0,112.61v134.77c0,4.3,0,8.6,0.02,12.9
|
|
||||||
c0.02,3.62,0.06,7.24,0.16,10.86c0.21,7.89,0.68,15.84,2.08,23.64c1.42,7.92,3.75,15.29,7.41,22.49c3.6,7.07,8.3,13.53,13.91,19.14
|
|
||||||
c5.61,5.61,12.08,10.31,19.15,13.91c7.19,3.66,14.56,5.98,22.47,7.41c7.8,1.4,15.76,1.87,23.65,2.08c3.62,0.1,7.24,0.14,10.86,0.16
|
|
||||||
c4.3,0.03,8.6,0.02,12.9,0.02h134.77c4.3,0,8.6,0,12.9-0.02c3.62-0.02,7.24-0.06,10.86-0.16c7.89-0.21,15.85-0.68,23.65-2.08
|
|
||||||
c7.92-1.42,15.28-3.75,22.47-7.41c7.07-3.6,13.54-8.3,19.15-13.91c5.61-5.61,10.31-12.07,13.91-19.14
|
|
||||||
c3.66-7.2,5.99-14.57,7.41-22.49c1.4-7.8,1.87-15.76,2.08-23.64c0.1-3.62,0.14-7.24,0.16-10.86c0.03-4.3,0.02-8.6,0.02-12.9V112.61
|
|
||||||
z"/>
|
|
||||||
</g>
|
|
||||||
<g id="Glyph_2_">
|
|
||||||
<g>
|
|
||||||
<path class="st1" d="M254.5,55c-0.87,0.08-8.6,1.45-9.53,1.64l-107,21.59l-0.04,0.01c-2.79,0.59-4.98,1.58-6.67,3
|
|
||||||
c-2.04,1.71-3.17,4.13-3.6,6.95c-0.09,0.6-0.24,1.82-0.24,3.62c0,0,0,109.32,0,133.92c0,3.13-0.25,6.17-2.37,8.76
|
|
||||||
c-2.12,2.59-4.74,3.37-7.81,3.99c-2.33,0.47-4.66,0.94-6.99,1.41c-8.84,1.78-14.59,2.99-19.8,5.01
|
|
||||||
c-4.98,1.93-8.71,4.39-11.68,7.51c-5.89,6.17-8.28,14.54-7.46,22.38c0.7,6.69,3.71,13.09,8.88,17.82
|
|
||||||
c3.49,3.2,7.85,5.63,12.99,6.66c5.33,1.07,11.01,0.7,19.31-0.98c4.42-0.89,8.56-2.28,12.5-4.61c3.9-2.3,7.24-5.37,9.85-9.11
|
|
||||||
c2.62-3.75,4.31-7.92,5.24-12.35c0.96-4.57,1.19-8.7,1.19-13.26l0-116.15c0-6.22,1.76-7.86,6.78-9.08c0,0,88.94-17.94,93.09-18.75
|
|
||||||
c5.79-1.11,8.52,0.54,8.52,6.61l0,79.29c0,3.14-0.03,6.32-2.17,8.92c-2.12,2.59-4.74,3.37-7.81,3.99
|
|
||||||
c-2.33,0.47-4.66,0.94-6.99,1.41c-8.84,1.78-14.59,2.99-19.8,5.01c-4.98,1.93-8.71,4.39-11.68,7.51
|
|
||||||
c-5.89,6.17-8.49,14.54-7.67,22.38c0.7,6.69,3.92,13.09,9.09,17.82c3.49,3.2,7.85,5.56,12.99,6.6c5.33,1.07,11.01,0.69,19.31-0.98
|
|
||||||
c4.42-0.89,8.56-2.22,12.5-4.55c3.9-2.3,7.24-5.37,9.85-9.11c2.62-3.75,4.31-7.92,5.24-12.35c0.96-4.57,1-8.7,1-13.26V64.46
|
|
||||||
C263.54,58.3,260.29,54.5,254.5,55z"/>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
<g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
|
||||||
|
Before Width: | Height: | Size: 276 B |
@@ -1,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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,304 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div :class="'popup-backdrop' + ( isShowingPopup ? '' : ' hidden' )" :style="'transform-origin: ' + transformOriginVertical + ';'">
|
|
||||||
<div class="popup-main">
|
|
||||||
<span class="material-symbols-outlined close-icon" @click="closePopup()">close</span>
|
|
||||||
<h2>{{ popupContent.title }}</h2>
|
|
||||||
<div v-if="popupContent.popupType === 'information' || popupContent.popupType === 'confirmation'" class="popup-content">
|
|
||||||
<p v-html="popupContent.subtitle"></p>
|
|
||||||
</div>
|
|
||||||
<div v-else class="popup-content">
|
|
||||||
<p v-if="isShowingIncomplete" class="incomplete-message">{{ popupContent.incompleteMessage ? popupContent.incompleteMessage : 'Some entries are not filled out. Please fill them out to proceed.' }}</p>
|
|
||||||
<p v-html="popupContent.subtitle"></p>
|
|
||||||
<div v-for="content in popupContent.data" v-bind:key="content.id" class="popup-content-wrapper">
|
|
||||||
<div v-if="content.dataType === 'text'">
|
|
||||||
<label :for="'text-' + content.id">{{ content.name }}</label><br>
|
|
||||||
<input type="text" :id="'text-' + content.id" v-model="data[ content.id ]" class="input">
|
|
||||||
</div>
|
|
||||||
<div v-else-if="content.dataType === 'number'">
|
|
||||||
<label :for="'number-' + content.id">{{ content.name }}</label><br>
|
|
||||||
<input type="number" :id="'number-' + content.id" v-model="data[ content.id ]" class="input">
|
|
||||||
</div>
|
|
||||||
<div v-else-if="content.dataType === 'checkbox'">
|
|
||||||
<label :for="'checkbox-' + content.id">{{ content.name }}</label><br>
|
|
||||||
<label class="switch">
|
|
||||||
<input type="checkbox" v-model="data[ content.id ]">
|
|
||||||
<span class="slider round"></span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="content.dataType === 'textbox'">
|
|
||||||
<label :for="'textarea-' + content.id">{{ content.name }}</label><br>
|
|
||||||
<textarea :id="'textarea-' + content.id" v-model="data[ content.id ]" class="textarea"></textarea>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="content.dataType === 'colour'">
|
|
||||||
<label :for="'colour-' + content.id">{{ content.name }}</label><br>
|
|
||||||
<input type="text" :id="'colour-' + content.id" v-model="data[ content.id ]" class="input">
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top: 20px;">
|
|
||||||
<button @click="closePopup()" style="margin-right: 10px;" class="fancy-button" v-if="popupContent.popupType === 'confirmation'">Cancel</button>
|
|
||||||
<button @click="closePopupReturn()" class="fancy-button">Ok</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
type PopupType = 'confirmation' | 'information' | 'input';
|
|
||||||
|
|
||||||
interface PopupData {
|
|
||||||
/**
|
|
||||||
* What to display to the user in front of the input field
|
|
||||||
*/
|
|
||||||
name: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of data to display
|
|
||||||
*/
|
|
||||||
dataType: 'text' | 'number' | 'checkbox' | 'textbox' | 'colour';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ID that is used for internal usage only. May only contain alphanumerical characters, as well as dashes and underscores
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PopupContent {
|
|
||||||
/**
|
|
||||||
* The title shown in big letters at the top of the popup
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (OPTIONAL) The subtitle shown to the user in normal sized letters below title
|
|
||||||
*/
|
|
||||||
subtitle?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (OPTIONAL) The message to show, if the user has not filled out all fields
|
|
||||||
*/
|
|
||||||
incompleteMessage?: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The type of popup (i.e. what it is for)
|
|
||||||
*/
|
|
||||||
popupType: PopupType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (REQUIRED ONLY when popupType === 'input') The input fields to show
|
|
||||||
*/
|
|
||||||
data?: PopupData[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Data {
|
|
||||||
[key: string]: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
import "@melloware/coloris/dist/coloris.css";
|
|
||||||
import { ref, type Ref } from 'vue';
|
|
||||||
import Coloris from '@melloware/coloris';
|
|
||||||
|
|
||||||
Coloris.init();
|
|
||||||
|
|
||||||
const isShowingPopup = ref( false );
|
|
||||||
const transformOriginVertical = ref( '50% 50%' );
|
|
||||||
const data: Ref<Data> = ref( {} );
|
|
||||||
const isShowingIncomplete = ref( false );
|
|
||||||
|
|
||||||
const popupContent: Ref<PopupContent> = ref( {
|
|
||||||
title: 'Undefined popup title',
|
|
||||||
popupType: 'information',
|
|
||||||
subtitle: 'This popup was not configured correctly during development. Please send a bug-report at <a href="https://support.janishutz.com/index.php?a=add&category=7" target="_blank">support.janishutz.com</a> and inform us about where exactly you encountered this popup! We are sorry for the inconvenience'
|
|
||||||
} );
|
|
||||||
|
|
||||||
const closePopup = () => {
|
|
||||||
isShowingPopup.value = false;
|
|
||||||
Coloris.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const closePopupReturn = () => {
|
|
||||||
for ( let el in popupContent.value.data ) {
|
|
||||||
if ( !data.value[ popupContent.value.data[ parseInt( el ) ].id ] && popupContent.value.data[ parseInt( el ) ].dataType !== 'checkbox' ) {
|
|
||||||
isShowingIncomplete.value = true;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
closePopup();
|
|
||||||
if ( popupContent.value.popupType === 'confirmation' ) {
|
|
||||||
emit( 'update', true );
|
|
||||||
} else {
|
|
||||||
emit( 'update', data.value );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const openPopup = ( popupConfig: PopupContent, transformOrigin?: string ) => {
|
|
||||||
if ( transformOrigin ) {
|
|
||||||
transformOriginVertical.value = transformOrigin;
|
|
||||||
} else {
|
|
||||||
transformOriginVertical.value = '50% 50%';
|
|
||||||
}
|
|
||||||
data.value = {};
|
|
||||||
for ( let el in popupConfig.data ) {
|
|
||||||
if ( !popupConfig.data[ parseInt( el ) ].id ) {
|
|
||||||
console.warn( '[ popup ] Missing ID for input with name "' + popupConfig.data[ parseInt( el ) ].name + '"!' );
|
|
||||||
} else if ( popupConfig.data[ parseInt( el ) ].dataType === 'colour' ) {
|
|
||||||
Coloris( { el: '#colour-' + popupConfig.data[ parseInt( el ) ].id } );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
isShowingPopup.value = true;
|
|
||||||
popupContent.value = popupConfig;
|
|
||||||
for ( const el in popupContent.value.data ) {
|
|
||||||
if ( popupContent.value.data[ parseInt( el ) ].dataType === 'checkbox' ) {
|
|
||||||
data.value[ popupContent.value.data[ parseInt( el ) ].id ] = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
defineExpose( {
|
|
||||||
openPopup,
|
|
||||||
} );
|
|
||||||
|
|
||||||
const emit = defineEmits( [ 'update' ] );
|
|
||||||
</script>
|
|
||||||
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.popup-backdrop {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
position: fixed;
|
|
||||||
background-color: var( --overlay-color );
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
overflow: hidden;
|
|
||||||
transition: all 0.5s;
|
|
||||||
transform: scale(1);
|
|
||||||
z-index: 99;
|
|
||||||
}
|
|
||||||
|
|
||||||
.incomplete-message {
|
|
||||||
color: red;
|
|
||||||
font-weight: 300;
|
|
||||||
font-style: italic;
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 0.8rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hidden {
|
|
||||||
transform: scale(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-main {
|
|
||||||
width: 40%;
|
|
||||||
height: 50%;
|
|
||||||
background-color: var( --secondary-color );
|
|
||||||
padding: 2.5%;
|
|
||||||
border-radius: 20px;
|
|
||||||
position: relative;
|
|
||||||
overflow-y: scroll;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.close-icon {
|
|
||||||
position: absolute;
|
|
||||||
top: 20px;
|
|
||||||
right: 20px;
|
|
||||||
font-size: 2rem;
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-content {
|
|
||||||
position: unset;
|
|
||||||
height: 60%;
|
|
||||||
overflow-y: scroll;
|
|
||||||
}
|
|
||||||
|
|
||||||
.textarea {
|
|
||||||
width: 80%;
|
|
||||||
resize: vertical;
|
|
||||||
min-height: 30px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: solid var( --primary-color ) 1px;
|
|
||||||
padding: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input {
|
|
||||||
padding: 5px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: solid var( --primary-color ) 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.popup-content-wrapper {
|
|
||||||
margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* https://www.w3schools.com/howto/tryit.asp?filename=tryhow_css_switch */
|
|
||||||
.switch {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 60px;
|
|
||||||
height: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.switch input {
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider {
|
|
||||||
position: absolute;
|
|
||||||
cursor: pointer;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background-color: #ccc;
|
|
||||||
-webkit-transition: .4s;
|
|
||||||
transition: .4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider:before {
|
|
||||||
position: absolute;
|
|
||||||
content: "";
|
|
||||||
height: 26px;
|
|
||||||
width: 26px;
|
|
||||||
left: 4px;
|
|
||||||
bottom: 4px;
|
|
||||||
background-color: white;
|
|
||||||
-webkit-transition: .4s;
|
|
||||||
transition: .4s;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + .slider {
|
|
||||||
background-color: #2196F3;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:focus + .slider {
|
|
||||||
box-shadow: 0 0 1px #2196F3;
|
|
||||||
}
|
|
||||||
|
|
||||||
input:checked + .slider:before {
|
|
||||||
-webkit-transform: translateX(26px);
|
|
||||||
-ms-transform: translateX(26px);
|
|
||||||
transform: translateX(26px);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Rounded sliders */
|
|
||||||
.slider.round {
|
|
||||||
border-radius: 34px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider.round:before {
|
|
||||||
border-radius: 50%;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div>
|
|
||||||
<div id="search-bar" :class="showsSearch ? 'search-shown' : ''">
|
|
||||||
<div id="search-box-wrapper">
|
|
||||||
<input type="text" v-model="searchText" id="search-box" placeholder="Type to search..." @keyup="e => { keyHandler( e ) }">
|
|
||||||
<div class="symbol-wrapper" id="search-symbol-wrapper">
|
|
||||||
<span class="material-symbols-outlined search-symbol" @click="search()">search</span>
|
|
||||||
</div>
|
|
||||||
<div :class="'search-result-wrapper' + ( searchText.length > 0 ? ' show-search-results' : '' )">
|
|
||||||
<div v-for="result in searchResults" v-bind:key="result.id"
|
|
||||||
:class="'search-result' + ( selectedProduct === result.id ? ' prod-selected' : '' )"
|
|
||||||
@mouseenter="removeSelection()" @click="select( result )">
|
|
||||||
<div :style="'background-image: url(' + result.attributes.artwork.url.replace( '{w}', '500' ).replace( '{h}', '500' ) + ');'" class="search-product-image"></div>
|
|
||||||
<div class="search-product-name"><p><b>{{ result.attributes.name }}</b> <i>by {{ result.attributes.artistName }}</i></p></div>
|
|
||||||
</div>
|
|
||||||
<div v-if="searchResults.length === 0 && searchText.length < 3">
|
|
||||||
<p>No results to show</p>
|
|
||||||
</div>
|
|
||||||
<div v-else-if="searchText.length < 3">
|
|
||||||
<p>Enter at least three characters to start searching</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="symbol-wrapper">
|
|
||||||
<span class="material-symbols-outlined search-symbol" @click="controlSearch( 'hide' )" id="close-icon">close</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import MusicKitJSWrapper from '@/scripts/music-player';
|
|
||||||
import type { AppleMusicSongData } from '@/scripts/song';
|
|
||||||
import { useUserStore } from '@/stores/userStore';
|
|
||||||
import { ref, type Ref } from 'vue';
|
|
||||||
|
|
||||||
const showsSearch = ref( false );
|
|
||||||
const searchText = ref( '' );
|
|
||||||
const selectedProduct = ref( '' );
|
|
||||||
let selectedProductIndex = -1;
|
|
||||||
const player = new MusicKitJSWrapper();
|
|
||||||
|
|
||||||
const updateSearchResults = () => {
|
|
||||||
if ( searchText.value.length > 2 ) {
|
|
||||||
player.findSongOnAppleMusic( searchText.value ).then( data => {
|
|
||||||
searchResults.value = data.data.results.songs.data ?? [];
|
|
||||||
selectedProductIndex = -1;
|
|
||||||
selectedProduct.value = '';
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchResults: Ref<AppleMusicSongData[]> = ref( [] );
|
|
||||||
const userStore = useUserStore();
|
|
||||||
|
|
||||||
const controlSearch = ( action: string ) => {
|
|
||||||
if ( action === 'show' ) {
|
|
||||||
userStore.setKeyboardUsageStatus( true );
|
|
||||||
showsSearch.value = true;
|
|
||||||
setTimeout( () => {
|
|
||||||
const searchBox = document.getElementById( 'search-box' ) as HTMLInputElement;
|
|
||||||
searchBox.focus();
|
|
||||||
}, 500 );
|
|
||||||
} else if ( action === 'hide' ) {
|
|
||||||
userStore.setKeyboardUsageStatus( false );
|
|
||||||
showsSearch.value = false;
|
|
||||||
}
|
|
||||||
searchText.value = '';
|
|
||||||
removeSelection();
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSelection = () => {
|
|
||||||
selectedProduct.value = '';
|
|
||||||
selectedProductIndex = -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyHandler = ( e: KeyboardEvent ) => {
|
|
||||||
if ( e.key === 'Escape' ) {
|
|
||||||
controlSearch( 'hide' );
|
|
||||||
} else if ( e.key === 'Enter' ) {
|
|
||||||
e.preventDefault();
|
|
||||||
if ( selectedProductIndex >= 0 ) {
|
|
||||||
select( searchResults.value[ selectedProductIndex ] );
|
|
||||||
controlSearch( 'hide' );
|
|
||||||
} else {
|
|
||||||
search();
|
|
||||||
}
|
|
||||||
} else if ( e.key === 'ArrowDown' ) {
|
|
||||||
e.preventDefault();
|
|
||||||
if ( selectedProductIndex < searchResults.value.length - 1 ) {
|
|
||||||
selectedProductIndex += 1;
|
|
||||||
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
|
|
||||||
}
|
|
||||||
} else if ( e.key === 'ArrowUp' ) {
|
|
||||||
e.preventDefault();
|
|
||||||
if ( selectedProductIndex > 0 ) {
|
|
||||||
selectedProductIndex -= 1;
|
|
||||||
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
|
|
||||||
} else {
|
|
||||||
removeSelection();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
updateSearchResults();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const select = ( song: AppleMusicSongData ) => {
|
|
||||||
emits( 'selected-song', song );
|
|
||||||
controlSearch( 'hide' );
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = () => {
|
|
||||||
emits( 'selected-song', searchResults.value[ 0 ] );
|
|
||||||
controlSearch( 'hide' );
|
|
||||||
}
|
|
||||||
|
|
||||||
const emits = defineEmits( [ 'selected-song' ] );
|
|
||||||
|
|
||||||
|
|
||||||
defineExpose( {
|
|
||||||
controlSearch
|
|
||||||
} );
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
#search-bar {
|
|
||||||
position: fixed;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
top: -15vh;
|
|
||||||
background-color: var( --accent-background );
|
|
||||||
height: 15vh;
|
|
||||||
width: 100vw;
|
|
||||||
left: 0;
|
|
||||||
transition: all 1s;
|
|
||||||
z-index: 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-bar.search-shown {
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-box {
|
|
||||||
width: calc(100% - 20px);
|
|
||||||
padding: 10px;
|
|
||||||
font-size: 150%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-box-wrapper {
|
|
||||||
width: 60vw;
|
|
||||||
position: relative;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (min-width: 1000px) {
|
|
||||||
#search-box-wrapper {
|
|
||||||
width: 45vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media only screen and (min-width: 1500px) {
|
|
||||||
#search-box-wrapper {
|
|
||||||
width: 30vw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#search-symbol-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
right: 10px;
|
|
||||||
top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.symbol-wrapper {
|
|
||||||
display: flex;
|
|
||||||
height: 3rem;
|
|
||||||
width: 3rem;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-symbol {
|
|
||||||
color: black;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
font-size: 200%;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-symbol:hover {
|
|
||||||
font-size: 250%;
|
|
||||||
}
|
|
||||||
|
|
||||||
#close-icon {
|
|
||||||
margin-left: 5px;
|
|
||||||
color: var( --primary-color );
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.search-result-wrapper {
|
|
||||||
position: absolute;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 100%;
|
|
||||||
padding: 2px;
|
|
||||||
padding-bottom: 5px;
|
|
||||||
left: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
background-color: white;
|
|
||||||
border-bottom-left-radius: 10px;
|
|
||||||
border-bottom-right-radius: 10px;
|
|
||||||
transform-origin: top;
|
|
||||||
transform: scaleY( 0 );
|
|
||||||
transition: all 0.5s;
|
|
||||||
color: black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.show-search-results {
|
|
||||||
transform: scaleY( 1 );
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result {
|
|
||||||
padding: 3px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
width: 98%;
|
|
||||||
text-decoration: none;
|
|
||||||
transition: all 0.5s;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-result:hover, .prod-selected {
|
|
||||||
text-decoration: underline black;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-product-image {
|
|
||||||
height: 4rem;
|
|
||||||
width: 4rem;
|
|
||||||
background-position: center;
|
|
||||||
background-size: cover;
|
|
||||||
margin-right: 20px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.search-product-name {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
text-decoration: none;
|
|
||||||
color: black;
|
|
||||||
font-weight: bold;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
width: calc( 100% - 4rem - 20px );
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div style="width: 100%; height: 100%;">
|
|
||||||
<progress :id="'progress-slider-' + name" class="progress-slider" :value="sliderProgress" max="1000" @mousedown="( e ) => { setPos( e ) }"
|
|
||||||
:class="active ? '' : 'slider-inactive'"></progress>
|
|
||||||
<div v-if="active" id="slider-knob" @mousedown="( e ) => { startMove( e ) }"
|
|
||||||
:style="'left: ' + ( originalPos + sliderPos ) + 'px;'">
|
|
||||||
<div id="slider-knob-style"></div>
|
|
||||||
</div>
|
|
||||||
<div v-else id="slider-knob" class="slider-inactive" style="left: 0;">
|
|
||||||
<div id="slider-knob-style"></div>
|
|
||||||
</div>
|
|
||||||
<div id="drag-support" @mousemove="e => { handleDrag( e ) }" @mouseup="() => { stopMove(); }"></div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.progress-slider {
|
|
||||||
width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
bottom: 0;
|
|
||||||
height: 5px;
|
|
||||||
cursor: pointer;
|
|
||||||
background-color: var( --slider-color );
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-slider::-webkit-progress-value {
|
|
||||||
background-color: var( --slider-color );
|
|
||||||
}
|
|
||||||
|
|
||||||
#slider-knob {
|
|
||||||
height: 20px;
|
|
||||||
width: 10px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: flex-start;
|
|
||||||
align-items: flex-end;
|
|
||||||
position: absolute;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 2;
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
|
|
||||||
#slider-knob-style {
|
|
||||||
background-color: var( --slider-color );
|
|
||||||
height: 15px;
|
|
||||||
width: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#drag-support {
|
|
||||||
display: none;
|
|
||||||
opacity: 0;
|
|
||||||
height: 100vh;
|
|
||||||
width: 100vw;
|
|
||||||
position: fixed;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
z-index: 10;
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-support-active {
|
|
||||||
display: block !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider-inactive {
|
|
||||||
cursor: default !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue';
|
|
||||||
|
|
||||||
const props = defineProps( {
|
|
||||||
style: {
|
|
||||||
type: Object,
|
|
||||||
},
|
|
||||||
position: {
|
|
||||||
type: Number,
|
|
||||||
default: 0,
|
|
||||||
},
|
|
||||||
duration: {
|
|
||||||
type: Number,
|
|
||||||
default: 100
|
|
||||||
},
|
|
||||||
active: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
name: {
|
|
||||||
type: String,
|
|
||||||
default: '1',
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
|
|
||||||
const offset = ref( 0 );
|
|
||||||
const isDragging = ref( false );
|
|
||||||
const sliderPos = ref( 0 );
|
|
||||||
const originalPos= ref( 0 );
|
|
||||||
const sliderProgress = ref( 0 );
|
|
||||||
|
|
||||||
const handleDrag = ( e: MouseEvent ) => {
|
|
||||||
if ( isDragging.value ) {
|
|
||||||
if ( 0 < originalPos.value + e.screenX - offset.value && originalPos.value + e.screenX - offset.value < ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) {
|
|
||||||
sliderPos.value = e.screenX - offset.value;
|
|
||||||
calcProgressPos();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const startMove = ( e: MouseEvent ) => {
|
|
||||||
offset.value = e.screenX;
|
|
||||||
isDragging.value = true;
|
|
||||||
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.add( 'drag-support-active' );
|
|
||||||
}
|
|
||||||
const stopMove = () => {
|
|
||||||
originalPos.value += sliderPos.value;
|
|
||||||
isDragging.value = false;
|
|
||||||
offset.value = 0;
|
|
||||||
sliderPos.value = 0;
|
|
||||||
( document.getElementById( 'drag-support' ) as HTMLDivElement ).classList.remove( 'drag-support-active' );
|
|
||||||
calcPlaybackPos();
|
|
||||||
}
|
|
||||||
const setPos = ( e: MouseEvent ) => {
|
|
||||||
if ( props.active ) {
|
|
||||||
originalPos.value = e.offsetX;
|
|
||||||
calcProgressPos();
|
|
||||||
calcPlaybackPos();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const calcProgressPos = () => {
|
|
||||||
sliderProgress.value = Math.ceil( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * 1000 );
|
|
||||||
}
|
|
||||||
const calcPlaybackPos = () => {
|
|
||||||
emits( 'pos', Math.round( ( originalPos.value + sliderPos.value ) / ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).clientWidth - 5 ) * props.duration ) );
|
|
||||||
}
|
|
||||||
watch( () => props.position, () => {
|
|
||||||
if ( !isDragging.value ) {
|
|
||||||
sliderProgress.value = Math.ceil( props.position / props.duration * 1000 + 2 );
|
|
||||||
originalPos.value = Math.ceil( props.position / props.duration * ( ( document.getElementById( 'progress-slider-' + props.name ) as HTMLProgressElement ).scrollWidth - 5 ) );
|
|
||||||
}
|
|
||||||
} )
|
|
||||||
|
|
||||||
const emits = defineEmits( [ 'pos' ] );
|
|
||||||
</script>
|
|
||||||
@@ -1,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')
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
-95
@@ -1,95 +0,0 @@
|
|||||||
export type Origin = 'apple-music' | 'disk';
|
|
||||||
|
|
||||||
export interface Song {
|
|
||||||
/**
|
|
||||||
* The ID. Either the apple music ID, or if from local disk, an ID starting in local_
|
|
||||||
*/
|
|
||||||
id: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Origin of the song
|
|
||||||
*/
|
|
||||||
origin: Origin;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The cover image as a URL
|
|
||||||
*/
|
|
||||||
cover: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The artist of the song
|
|
||||||
*/
|
|
||||||
artist: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the song
|
|
||||||
*/
|
|
||||||
title: string;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Duration of the song in milliseconds
|
|
||||||
*/
|
|
||||||
duration: number;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (OPTIONAL) The genres this song belongs to. Can be displayed on the showcase screen, but requires settings there
|
|
||||||
*/
|
|
||||||
genres?: string[];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* (OPTIONAL) This will be displayed in brackets on the showcase screens
|
|
||||||
*/
|
|
||||||
additionalInfo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SongTransmitted {
|
|
||||||
title: string;
|
|
||||||
artist: string;
|
|
||||||
duration: number;
|
|
||||||
cover: string;
|
|
||||||
additionalInfo?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface ReadFile {
|
|
||||||
url: string;
|
|
||||||
filename: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SearchResult {
|
|
||||||
data: {
|
|
||||||
results: {
|
|
||||||
songs: {
|
|
||||||
data: AppleMusicSongData[],
|
|
||||||
href: string;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AppleMusicSongData {
|
|
||||||
id: string,
|
|
||||||
type: string;
|
|
||||||
href: string;
|
|
||||||
attributes: {
|
|
||||||
albumName: string;
|
|
||||||
artistName: string;
|
|
||||||
artwork: {
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
url: string
|
|
||||||
},
|
|
||||||
name: string;
|
|
||||||
genreNames: string[];
|
|
||||||
durationInMillis: number;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SongMove {
|
|
||||||
songID: string;
|
|
||||||
newPos: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SSEMap {
|
|
||||||
[key: string]: ( data: any ) => void;
|
|
||||||
}
|
|
||||||
@@ -1,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="home-view">
|
|
||||||
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
|
|
||||||
<h1>404</h1>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.home-view {
|
|
||||||
height: 100vh;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.logo {
|
|
||||||
height: 50vh;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
|
||||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue", "public/musickit.js"],
|
|
||||||
"exclude": ["src/**/__tests__/*"],
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
|
||||||
|
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./src/*"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,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"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -21,9 +21,17 @@
|
|||||||
<img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version">
|
<img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
# Development Branch
|
||||||
|
This branch is for the upcoming version 4 of Music Player and is in early stages of development
|
||||||
|
|
||||||
|
It uses my manually created [types for MusicKitJS](https://github.com/janishutz/musickit-v3-types)
|
||||||
|
|
||||||
|
|
||||||
|
# MusicPlayer
|
||||||
|
|
||||||
A music player, specifically created for displaying song information on multiple different displays that are connected to the same network, just from the browser.
|
A music player, specifically created for displaying song information on multiple different displays that are connected to the same network, just from the browser.
|
||||||
|
|
||||||
The [hosted version](https://music.janishutz.com) of this MusicPlayer, which is fully set up for you 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>
|
||||||
|
|||||||
@@ -0,0 +1,744 @@
|
|||||||
|
import eslint from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import stylistic from '@stylistic/eslint-plugin';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import typescript from '@typescript-eslint/eslint-plugin';
|
||||||
|
import vue from 'eslint-plugin-vue';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
'plugins': {
|
||||||
|
'@stylistic': stylistic,
|
||||||
|
'@stylistic/js': stylistic,
|
||||||
|
'@stylistic/ts': stylistic
|
||||||
|
},
|
||||||
|
'files': [
|
||||||
|
'**/*.ts',
|
||||||
|
'**/*.js',
|
||||||
|
'**/*.mjs',
|
||||||
|
'**/*.cjs',
|
||||||
|
'**/*.tsx',
|
||||||
|
'**/*.jsx'
|
||||||
|
],
|
||||||
|
'rules': {
|
||||||
|
'sort-imports': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
'ignoreCase': false,
|
||||||
|
'ignoreDeclarationSort': false,
|
||||||
|
'ignoreMemberSort': false,
|
||||||
|
'memberSyntaxSortOrder': [
|
||||||
|
'none',
|
||||||
|
'all',
|
||||||
|
'multiple',
|
||||||
|
'single'
|
||||||
|
],
|
||||||
|
'allowSeparatedGroups': false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Formatting
|
||||||
|
'@stylistic/array-bracket-newline': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'multiline': false,
|
||||||
|
'minItems': 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/array-bracket-spacing': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/array-element-newline': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'consistent': false,
|
||||||
|
'multiline': false,
|
||||||
|
'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',
|
||||||
|
{
|
||||||
|
'allowSingleLine': false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/comma-dangle': [
|
||||||
|
'error',
|
||||||
|
'never'
|
||||||
|
],
|
||||||
|
'@stylistic/comma-spacing': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'before': false,
|
||||||
|
'after': true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/comma-style': [
|
||||||
|
'error',
|
||||||
|
'last'
|
||||||
|
],
|
||||||
|
'@stylistic/dot-location': [
|
||||||
|
'error',
|
||||||
|
'property'
|
||||||
|
],
|
||||||
|
'@stylistic/function-call-argument-newline': [
|
||||||
|
'error',
|
||||||
|
'consistent'
|
||||||
|
],
|
||||||
|
'@stylistic/function-call-spacing': [
|
||||||
|
'error',
|
||||||
|
'never'
|
||||||
|
],
|
||||||
|
'@stylistic/function-paren-newline': [
|
||||||
|
'error',
|
||||||
|
'multiline-arguments'
|
||||||
|
],
|
||||||
|
'@stylistic/implicit-arrow-linebreak': [
|
||||||
|
'error',
|
||||||
|
'beside'
|
||||||
|
],
|
||||||
|
'@stylistic/indent': [
|
||||||
|
'error',
|
||||||
|
4
|
||||||
|
],
|
||||||
|
'@stylistic/indent-binary-ops': [
|
||||||
|
'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': 140,
|
||||||
|
'comments': 160,
|
||||||
|
'ignoreComments': false,
|
||||||
|
'ignoreUrls': true,
|
||||||
|
'ignoreStrings': true,
|
||||||
|
'ignoreTemplateLiterals': true,
|
||||||
|
'ignoreRegExpLiterals': true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/max-statements-per-line': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'max': 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/multiline-ternary': [
|
||||||
|
'error',
|
||||||
|
'always-multiline'
|
||||||
|
],
|
||||||
|
'@stylistic/new-parens': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/newline-per-chained-call': 'error',
|
||||||
|
'@stylistic/no-confusing-arrow': '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/one-var-declaration-per-line': 'error',
|
||||||
|
'@stylistic/operator-linebreak': [
|
||||||
|
'error',
|
||||||
|
'before'
|
||||||
|
],
|
||||||
|
'@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/switch-colon-spacing': 'error',
|
||||||
|
'@stylistic/template-curly-spacing': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/template-tag-spacing': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/type-generic-spacing': 'error',
|
||||||
|
'@stylistic/type-named-tuple-spacing': 'error',
|
||||||
|
'@stylistic/wrap-iife': [
|
||||||
|
'error',
|
||||||
|
'inside'
|
||||||
|
],
|
||||||
|
'@stylistic/wrap-regex': 'error',
|
||||||
|
'@stylistic/ts/type-annotation-spacing': 'error'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config} */
|
||||||
|
export default tseslint.config(
|
||||||
|
// Base JavaScript rules
|
||||||
|
eslint.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
style,
|
||||||
|
|
||||||
|
// Vue support (including TS and JSX inside SFCs)
|
||||||
|
{
|
||||||
|
'files': [ '**/*.vue' ],
|
||||||
|
'languageOptions': {
|
||||||
|
'sourceType': 'module',
|
||||||
|
'ecmaVersion': 'latest',
|
||||||
|
'globals': globals.browser,
|
||||||
|
'parserOptions': {
|
||||||
|
'parser': tseslint.parser
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'plugins': {
|
||||||
|
'vue': vue,
|
||||||
|
'@stylistic': stylistic,
|
||||||
|
'@stylistic/js': stylistic,
|
||||||
|
'@stylistic/ts': stylistic,
|
||||||
|
'@typescript-eslint': typescript
|
||||||
|
},
|
||||||
|
'extends': [
|
||||||
|
eslint.configs.recommended,
|
||||||
|
...vue.configs['flat/recommended']
|
||||||
|
],
|
||||||
|
'rules': {
|
||||||
|
...typescript.configs.recommended.rules,
|
||||||
|
...style.rules,
|
||||||
|
|
||||||
|
// Vue specific rules
|
||||||
|
'@stylistic/indent': 'off',
|
||||||
|
'vue/html-indent': [
|
||||||
|
'error',
|
||||||
|
4
|
||||||
|
],
|
||||||
|
'vue/html-comment-indent': [
|
||||||
|
'error',
|
||||||
|
4
|
||||||
|
],
|
||||||
|
'vue/script-indent': [
|
||||||
|
'error',
|
||||||
|
4,
|
||||||
|
{
|
||||||
|
'baseIndent': 1,
|
||||||
|
'switchCase': 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'vue/html-self-closing': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'html': {
|
||||||
|
'void': 'never',
|
||||||
|
'normal': 'never',
|
||||||
|
'component': 'always'
|
||||||
|
},
|
||||||
|
'svg': 'always',
|
||||||
|
'math': 'never'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'vue/max-attributes-per-line': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'singleline': 3,
|
||||||
|
'multiline': 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
const app = require( './dist/app.js' ).default;
|
const app = require( './dist/app.js' ).default;
|
||||||
app.run();
|
app.run();
|
||||||
|
|||||||
Generated
-1503
File diff suppressed because it is too large
Load Diff
+17
-34
@@ -1,38 +1,21 @@
|
|||||||
{
|
{
|
||||||
"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": {
|
"dependencies": {
|
||||||
"@types/body-parser": "^1.19.5",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/cors": "^2.8.17",
|
"cors": "^2.8.6",
|
||||||
"@types/express": "^4.17.21",
|
"express": "^5.2.1",
|
||||||
"@types/jsonwebtoken": "^9.0.6",
|
"express-session": "^1.19.0",
|
||||||
"body-parser": "^1.20.2",
|
"jsonwebtoken": "^9.0.3"
|
||||||
"cors": "^2.8.5",
|
},
|
||||||
"express": "^4.19.2",
|
"devDependencies": {
|
||||||
"express-session": "^1.18.0",
|
"@eslint/js": "^10.0.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
"node-mysql": "^0.4.2",
|
"@types/express": "^5.0.6",
|
||||||
"oauth-janishutz-client-server": "file:../../oauth/client/server/dist",
|
"@types/express-session": "^1.19.0",
|
||||||
"socket.io": "^4.7.5",
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
"store.janishutz.com-sdk": "file:../../store/sdk/dist"
|
"@types/node": "^26.2.0",
|
||||||
|
"eslint": "^10.9.0",
|
||||||
|
"eslint-plugin-vue": "^10.10.0",
|
||||||
|
"globals": "^17.11.0",
|
||||||
|
"typescript-eslint": "^8.67.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
+36
-421
@@ -1,450 +1,65 @@
|
|||||||
|
import cors from 'cors';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import path from 'path';
|
import expressSession from 'express-session';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import cors from 'cors';
|
import path from 'path';
|
||||||
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 = () => {
|
const run = () => {
|
||||||
let app = express();
|
const app = express();
|
||||||
app.use( cors( {
|
|
||||||
credentials: true,
|
app.use( expressSession( {
|
||||||
origin: true
|
'secret': 'dev',
|
||||||
|
'resave': true,
|
||||||
|
'saveUninitialized': false
|
||||||
} ) );
|
} ) );
|
||||||
|
|
||||||
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 ) => {
|
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' );
|
response.send( 'HELLO WORLD' );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
|
||||||
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' );
|
|
||||||
}
|
|
||||||
} );
|
|
||||||
|
|
||||||
|
const privateKey = fs.readFileSync( path.join(
|
||||||
|
__dirname,
|
||||||
|
'/config/apple_private_key.p8'
|
||||||
|
) ).toString();
|
||||||
|
const musicApiConfig = JSON.parse( fs.readFileSync( path.join(
|
||||||
|
__dirname,
|
||||||
|
'/config/apple-music-api.config.secret.json'
|
||||||
|
) ).toString() );
|
||||||
|
|
||||||
app.get( '/getAppleMusicDevToken', ( req, res ) => {
|
app.get( '/dev-token', cors( {
|
||||||
|
'origin': 'http://localhost:8081',
|
||||||
|
'credentials': true
|
||||||
|
} ), ( _request: express.Request, response: express.Response ) => {
|
||||||
// sign dev token
|
// 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 now = new Date().getTime();
|
||||||
const tomorrow = now + 24 * 3600 * 1000;
|
const tomorrow = now + ( 24 * 3600 * 1000 );
|
||||||
const jwtToken = jwt.sign( {
|
const jwtToken = jwt.sign( {
|
||||||
'iss': config.teamID,
|
'iss': musicApiConfig.teamID,
|
||||||
'iat': Math.floor( now / 1000 ),
|
'iat': Math.floor( now / 1000 ),
|
||||||
'exp': Math.floor( tomorrow / 1000 ),
|
'exp': Math.floor( tomorrow / 1000 )
|
||||||
}, privateKey, {
|
}, privateKey, {
|
||||||
algorithm: "ES256",
|
'algorithm': 'ES256',
|
||||||
keyid: config.keyID
|
'keyid': musicApiConfig.keyID
|
||||||
} );
|
} );
|
||||||
res.send( jwtToken );
|
|
||||||
|
response.send( jwtToken );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// TODO: Get user's subscriptions using store sdk
|
app.use( ( _request: express.Request, response: express.Response ) => {
|
||||||
|
response.sendFile( path.join( __dirname, '' ) );
|
||||||
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;
|
const PORT = process.env.PORT || 8080;
|
||||||
httpServer.listen( PORT );
|
|
||||||
}
|
app.listen( PORT );
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
run
|
run
|
||||||
}
|
};
|
||||||
|
|||||||
Vendored
-18
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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 ?? 'musicplayer.log' );
|
||||||
|
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
|
||||||
|
};
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
const getSubscriptions = ( uid: string ) => {
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
return [ {
|
const getSubscriptions = ( _uid: string ) => {
|
||||||
'id': 'com.janishutz.MusicPlayer.subscription',
|
return [
|
||||||
'expires': new Date().getTime() + 200000,
|
{
|
||||||
'status': true
|
'id': 'com.janishutz.MusicPlayer.subscription',
|
||||||
} ];
|
'expires': new Date().getTime() + 200000,
|
||||||
}
|
'status': true
|
||||||
|
}
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
}
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
|
||||||
@@ -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 };
|
|
||||||
@@ -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 );
|
|
||||||
|
|
||||||
+17
-12
@@ -1,13 +1,18 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
"allowJs": true,
|
"rootDir": "./src",
|
||||||
"target": "ES6",
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"target": "ES6",
|
||||||
"allowSyntheticDefaultImports": true,
|
"skipLibCheck": true,
|
||||||
"types": ["node"],
|
"allowSyntheticDefaultImports": true,
|
||||||
"module": "NodeNext",
|
"types": [
|
||||||
"moduleResolution": "NodeNext"
|
"node"
|
||||||
},
|
],
|
||||||
"include": [ "./src/**/*" ],
|
"module": "NodeNext",
|
||||||
}
|
"moduleResolution": "NodeNext"
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"./src/**/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "MusicPlayer",
|
"name": "MusicPlayer",
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"private": false,
|
"private": false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# MusicPlayer Webapp
|
||||||
|
More notes to come
|
||||||
@@ -0,0 +1,744 @@
|
|||||||
|
import eslint from '@eslint/js';
|
||||||
|
import globals from 'globals';
|
||||||
|
import stylistic from '@stylistic/eslint-plugin';
|
||||||
|
import tseslint from 'typescript-eslint';
|
||||||
|
import typescript from '@typescript-eslint/eslint-plugin';
|
||||||
|
import vue from 'eslint-plugin-vue';
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
'plugins': {
|
||||||
|
'@stylistic': stylistic,
|
||||||
|
'@stylistic/js': stylistic,
|
||||||
|
'@stylistic/ts': stylistic
|
||||||
|
},
|
||||||
|
'files': [
|
||||||
|
'**/*.ts',
|
||||||
|
'**/*.js',
|
||||||
|
'**/*.mjs',
|
||||||
|
'**/*.cjs',
|
||||||
|
'**/*.tsx',
|
||||||
|
'**/*.jsx'
|
||||||
|
],
|
||||||
|
'rules': {
|
||||||
|
'sort-imports': [
|
||||||
|
'warn',
|
||||||
|
{
|
||||||
|
'ignoreCase': false,
|
||||||
|
'ignoreDeclarationSort': false,
|
||||||
|
'ignoreMemberSort': false,
|
||||||
|
'memberSyntaxSortOrder': [
|
||||||
|
'none',
|
||||||
|
'all',
|
||||||
|
'multiple',
|
||||||
|
'single'
|
||||||
|
],
|
||||||
|
'allowSeparatedGroups': false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// Formatting
|
||||||
|
'@stylistic/array-bracket-newline': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'multiline': false,
|
||||||
|
'minItems': 2
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/array-bracket-spacing': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/array-element-newline': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'consistent': false,
|
||||||
|
'multiline': false,
|
||||||
|
'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',
|
||||||
|
{
|
||||||
|
'allowSingleLine': false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/comma-dangle': [
|
||||||
|
'error',
|
||||||
|
'never'
|
||||||
|
],
|
||||||
|
'@stylistic/comma-spacing': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'before': false,
|
||||||
|
'after': true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/comma-style': [
|
||||||
|
'error',
|
||||||
|
'last'
|
||||||
|
],
|
||||||
|
'@stylistic/dot-location': [
|
||||||
|
'error',
|
||||||
|
'property'
|
||||||
|
],
|
||||||
|
'@stylistic/function-call-argument-newline': [
|
||||||
|
'error',
|
||||||
|
'consistent'
|
||||||
|
],
|
||||||
|
'@stylistic/function-call-spacing': [
|
||||||
|
'error',
|
||||||
|
'never'
|
||||||
|
],
|
||||||
|
'@stylistic/function-paren-newline': [
|
||||||
|
'error',
|
||||||
|
'multiline-arguments'
|
||||||
|
],
|
||||||
|
'@stylistic/implicit-arrow-linebreak': [
|
||||||
|
'error',
|
||||||
|
'beside'
|
||||||
|
],
|
||||||
|
'@stylistic/indent': [
|
||||||
|
'error',
|
||||||
|
4
|
||||||
|
],
|
||||||
|
'@stylistic/indent-binary-ops': [
|
||||||
|
'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': 140,
|
||||||
|
'comments': 160,
|
||||||
|
'ignoreComments': false,
|
||||||
|
'ignoreUrls': true,
|
||||||
|
'ignoreStrings': true,
|
||||||
|
'ignoreTemplateLiterals': true,
|
||||||
|
'ignoreRegExpLiterals': true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/max-statements-per-line': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
'max': 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
'@stylistic/multiline-ternary': [
|
||||||
|
'error',
|
||||||
|
'always-multiline'
|
||||||
|
],
|
||||||
|
'@stylistic/new-parens': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/newline-per-chained-call': 'error',
|
||||||
|
'@stylistic/no-confusing-arrow': '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/one-var-declaration-per-line': 'error',
|
||||||
|
'@stylistic/operator-linebreak': [
|
||||||
|
'error',
|
||||||
|
'before'
|
||||||
|
],
|
||||||
|
'@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/switch-colon-spacing': 'error',
|
||||||
|
'@stylistic/template-curly-spacing': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/template-tag-spacing': [
|
||||||
|
'error',
|
||||||
|
'always'
|
||||||
|
],
|
||||||
|
'@stylistic/type-generic-spacing': 'error',
|
||||||
|
'@stylistic/type-named-tuple-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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!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">
|
||||||
|
<title>MusicPlayer</title>
|
||||||
|
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<noscript>This application requires JavaScript to work!</noscript>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
{
|
||||||
|
"name": "vue-project",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^7.3.1",
|
||||||
|
"@fortawesome/fontawesome-svg-core": "^7.3.1",
|
||||||
|
"@fortawesome/free-regular-svg-icons": "^7.3.1",
|
||||||
|
"@globalhive/vuejs-tour": "^3.0.1",
|
||||||
|
"@kyvg/vue3-notification": "^3.4.2",
|
||||||
|
"nprogress": "^0.2.0",
|
||||||
|
"pinia": "^4.0.2",
|
||||||
|
"vue": "^3.5.40",
|
||||||
|
"vue-router": "^5.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^10.0.1",
|
||||||
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/node": "^24.13.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.8",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint-plugin-vue": "^10.10.0",
|
||||||
|
"globals": "^17.11.0",
|
||||||
|
"musickitjs-v3-types": "^1.2.1",
|
||||||
|
"npm-run-all2": "^9.0.2",
|
||||||
|
"sass-embedded": "^1.103.1",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"typescript-eslint": "^8.67.0",
|
||||||
|
"vite": "^8.1.5",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.5",
|
||||||
|
"vue-tsc": "^3.3.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^22.18.0 || >=24.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
{
|
||||||
|
"ages": {
|
||||||
|
"below": "Orange",
|
||||||
|
"16-18": "Yellow",
|
||||||
|
"18+": "Turquoise"
|
||||||
|
},
|
||||||
|
"offering": {
|
||||||
|
"big-bar": {
|
||||||
|
"offering": {
|
||||||
|
"softdrinks": {
|
||||||
|
"name": "Softdrinks",
|
||||||
|
"price": 400,
|
||||||
|
"id": "softdrinks"
|
||||||
|
},
|
||||||
|
"energy": {
|
||||||
|
"name": "Energy",
|
||||||
|
"price": 400,
|
||||||
|
"id": "energy"
|
||||||
|
},
|
||||||
|
"mate": {
|
||||||
|
"name": "Mate",
|
||||||
|
"price": 500,
|
||||||
|
"id": "mate",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"sparkly-water": {
|
||||||
|
"name": "Mineralwasser mit",
|
||||||
|
"price": 300,
|
||||||
|
"id": "sparkly-water",
|
||||||
|
"showLine": true
|
||||||
|
},
|
||||||
|
"rose": {
|
||||||
|
"name": "Rosé",
|
||||||
|
"price": 1500,
|
||||||
|
"id": "rose",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"red": {
|
||||||
|
"name": "Rotwein",
|
||||||
|
"price": 2000,
|
||||||
|
"id": "red",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"wine-glasses": {
|
||||||
|
"name": "Weingläser",
|
||||||
|
"price": 0,
|
||||||
|
"id": "wine-glasses",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"appenzeller": {
|
||||||
|
"name": "Appenzeller Vollmond",
|
||||||
|
"price": 500,
|
||||||
|
"id": "appenzeller",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"feldschloesschen": {
|
||||||
|
"name": "Feldschlösschen",
|
||||||
|
"price": 500,
|
||||||
|
"id": "feldschloesschen",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"sommersby": {
|
||||||
|
"name": "Sommersby",
|
||||||
|
"price": 500,
|
||||||
|
"id": "sommersby",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"jever-fun": {
|
||||||
|
"name": "Jever Fun",
|
||||||
|
"price": 400,
|
||||||
|
"id": "jever-fun",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"trojka-ice": {
|
||||||
|
"name": "Trojka Ice",
|
||||||
|
"price": 600,
|
||||||
|
"id": "trojka-ice",
|
||||||
|
"depot": 200,
|
||||||
|
"showLine": true
|
||||||
|
},
|
||||||
|
"vodka-red-energy": {
|
||||||
|
"name": "Vodka Rot Energy",
|
||||||
|
"price": 800,
|
||||||
|
"id": "vodka-red-energy"
|
||||||
|
},
|
||||||
|
"vodka-green-citro": {
|
||||||
|
"name": "Vodka Grün Citro",
|
||||||
|
"price": 800,
|
||||||
|
"id": "vodka-green-citro"
|
||||||
|
},
|
||||||
|
"vodka-white-energy": {
|
||||||
|
"name": "Vodka White Energy",
|
||||||
|
"price": 900,
|
||||||
|
"id": "vodka-white-energy"
|
||||||
|
},
|
||||||
|
"gin-tonic": {
|
||||||
|
"name": "Gin Tonic",
|
||||||
|
"price": 900,
|
||||||
|
"id": "gin-tonic"
|
||||||
|
},
|
||||||
|
"rum-cola": {
|
||||||
|
"name": "Rum Cola",
|
||||||
|
"price": 900,
|
||||||
|
"id": "rum-cola"
|
||||||
|
},
|
||||||
|
"whiskey-cola": {
|
||||||
|
"name": "Whiskey Cola",
|
||||||
|
"price": 900,
|
||||||
|
"id": "whiskey-cola"
|
||||||
|
},
|
||||||
|
"mate-mit-schuss": {
|
||||||
|
"name": "Mate mit Schuss",
|
||||||
|
"price": 1200,
|
||||||
|
"id": "mate-mit-schuss",
|
||||||
|
"depot": 200,
|
||||||
|
"showLine": true
|
||||||
|
},
|
||||||
|
"poseidon": {
|
||||||
|
"name": "Poseidon",
|
||||||
|
"price": 900,
|
||||||
|
"id": "poseidon"
|
||||||
|
},
|
||||||
|
"arielle": {
|
||||||
|
"name": "Arielle",
|
||||||
|
"price": 900,
|
||||||
|
"id": "arielle"
|
||||||
|
},
|
||||||
|
"pearl-driver": {
|
||||||
|
"name": "Pearl Driver",
|
||||||
|
"price": 400,
|
||||||
|
"id": "pearl-driver"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Poseidon's Quelle",
|
||||||
|
"id": "big-bar"
|
||||||
|
},
|
||||||
|
"small-bar": {
|
||||||
|
"offering": {
|
||||||
|
"softdrinks": {
|
||||||
|
"name": "Softdrinks (Alle)",
|
||||||
|
"price": 300,
|
||||||
|
"id": "softdrinks",
|
||||||
|
"showLine": true
|
||||||
|
},
|
||||||
|
"appenzeller": {
|
||||||
|
"name": "Appenzeller Vollmond",
|
||||||
|
"price": 500,
|
||||||
|
"id": "appenzeller",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"feldschloesschen": {
|
||||||
|
"name": "Feldschlösschen",
|
||||||
|
"price": 500,
|
||||||
|
"id": "feldschloesschen",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"sommersby": {
|
||||||
|
"name": "Sommersby",
|
||||||
|
"price": 500,
|
||||||
|
"id": "sommersby",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"jever-fun": {
|
||||||
|
"name": "Jever Fun",
|
||||||
|
"price": 400,
|
||||||
|
"id": "jever-fun",
|
||||||
|
"depot": 200,
|
||||||
|
"showLine": true
|
||||||
|
},
|
||||||
|
"rose": {
|
||||||
|
"name": "Rosé",
|
||||||
|
"price": 1500,
|
||||||
|
"id": "rose",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"red": {
|
||||||
|
"name": "Rotwein",
|
||||||
|
"price": 2000,
|
||||||
|
"id": "red",
|
||||||
|
"depot": 200
|
||||||
|
},
|
||||||
|
"wine-glasses": {
|
||||||
|
"name": "Weingläser",
|
||||||
|
"price": 0,
|
||||||
|
"id": "wine-glasses",
|
||||||
|
"depot": 200
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"name": "Seepferdchenbar",
|
||||||
|
"id": "small-bar"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,18 +1,46 @@
|
|||||||
<!--
|
<script setup lang="ts">
|
||||||
* libreevent - App.vue
|
import {
|
||||||
*
|
RouterView
|
||||||
* Created by Janis Hutz 05/14/2023, Licensed under the GPL V3 License
|
} from 'vue-router';
|
||||||
* https://janishutz.com, development@janishutz.com
|
import {
|
||||||
*
|
ref
|
||||||
*
|
} from 'vue';
|
||||||
-->
|
|
||||||
|
const theme = ref( 'dark' );
|
||||||
|
|
||||||
|
const changeTheme = () => {
|
||||||
|
if ( theme.value === 'moon' ) {
|
||||||
|
document.documentElement.classList.remove( 'dark' );
|
||||||
|
document.documentElement.classList.add( 'light' );
|
||||||
|
localStorage.setItem( 'theme', 'light' );
|
||||||
|
theme.value = 'sun';
|
||||||
|
} else if ( theme.value === 'sun' ) {
|
||||||
|
document.documentElement.classList.remove( 'light' );
|
||||||
|
document.documentElement.classList.add( 'dark' );
|
||||||
|
localStorage.setItem( 'theme', 'dark' );
|
||||||
|
theme.value = 'moon';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
theme.value = localStorage.getItem( 'theme' ) ?? '';
|
||||||
|
|
||||||
|
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === 'dark' ) {
|
||||||
|
document.documentElement.classList.add( 'dark' );
|
||||||
|
theme.value = 'moon';
|
||||||
|
} else {
|
||||||
|
document.documentElement.classList.add( 'light' );
|
||||||
|
theme.value = 'sun';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<button @click="changeTheme();" id="themeSelector" title="Toggle between light and dark mode"><span class="material-symbols-outlined" v-html="theme"></span></button>
|
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||||
<router-view v-slot="{ Component, route }" id="main-view">
|
<i :class="['fa-solid', 'fa-' + theme]"></i>
|
||||||
|
</button>
|
||||||
|
<router-view id="main-view" v-slot="{ Component, route }">
|
||||||
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
|
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
|
||||||
<component :is="Component"></component>
|
<component :is="Component" />
|
||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +56,7 @@
|
|||||||
--secondary-color: white;
|
--secondary-color: white;
|
||||||
--background-color: rgb(221, 221, 221);
|
--background-color: rgb(221, 221, 221);
|
||||||
--nav-background: white;
|
--nav-background: white;
|
||||||
--hover-color: #00457a;
|
--accent-color: #00457a;
|
||||||
--popup-color: rgb(224, 224, 224);
|
--popup-color: rgb(224, 224, 224);
|
||||||
--overlay-color: rgba(0, 0, 0, 0.7);
|
--overlay-color: rgba(0, 0, 0, 0.7);
|
||||||
--PI: 3.14159265358979;
|
--PI: 3.14159265358979;
|
||||||
@@ -45,7 +73,7 @@
|
|||||||
--background-color: rgb(32, 32, 32);
|
--background-color: rgb(32, 32, 32);
|
||||||
--nav-background: rgb(54, 54, 54);
|
--nav-background: rgb(54, 54, 54);
|
||||||
--popup-color: rgb(58, 58, 58);
|
--popup-color: rgb(58, 58, 58);
|
||||||
--hover-color: #007ddd;
|
--accent-color: #007ddd;
|
||||||
--overlay-color: rgba(104, 104, 104, 0.575);
|
--overlay-color: rgba(104, 104, 104, 0.575);
|
||||||
--gray-color: rgb(207, 207, 207);
|
--gray-color: rgb(207, 207, 207);
|
||||||
--footer-background: rgb(53, 53, 53);
|
--footer-background: rgb(53, 53, 53);
|
||||||
@@ -61,7 +89,7 @@
|
|||||||
--background-color: rgb(32, 32, 32);
|
--background-color: rgb(32, 32, 32);
|
||||||
--nav-background: rgb(54, 54, 54);
|
--nav-background: rgb(54, 54, 54);
|
||||||
--popup-color: rgb(58, 58, 58);
|
--popup-color: rgb(58, 58, 58);
|
||||||
--hover-color: #007ddd;
|
--accent-color: #007ddd;
|
||||||
--overlay-color: rgba(104, 104, 104, 0.575);
|
--overlay-color: rgba(104, 104, 104, 0.575);
|
||||||
--gray-color: rgb(207, 207, 207);
|
--gray-color: rgb(207, 207, 207);
|
||||||
--footer-background: rgb(53, 53, 53);
|
--footer-background: rgb(53, 53, 53);
|
||||||
@@ -178,33 +206,3 @@
|
|||||||
background-position: 0px;
|
background-position: 0px;
|
||||||
}
|
}
|
||||||
</style>
|
</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>
|
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import player from '@/ts/player';
|
||||||
|
import {
|
||||||
|
sources
|
||||||
|
} from '@/ts/player/state';
|
||||||
|
|
||||||
|
const showPopup = defineModel<boolean>( {
|
||||||
|
'required': true
|
||||||
|
} );
|
||||||
|
|
||||||
|
const addSong = async ( source: string ) => {
|
||||||
|
if ( await player.addSongFromSource( source ) ) {
|
||||||
|
showPopup.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ImportTypePicker />
|
||||||
|
<PopupElement v-model="showPopup" show-close>
|
||||||
|
<div class="title">
|
||||||
|
<h1>Add Song(s)</h1>
|
||||||
|
<p>Pick the source of your songs</p>
|
||||||
|
</div>
|
||||||
|
<div class="song-sources">
|
||||||
|
<div v-for="(source, index) in sources" :key="index" @click="() => addSong( source.id )">
|
||||||
|
<div>
|
||||||
|
<i v-if="source.icon" :class="['fa-solid', 'fa-' + source.icon]"></i>
|
||||||
|
<p>{{ source.name }}</p>
|
||||||
|
</div>
|
||||||
|
<p v-if="!source.authorized.value" class="not-auth-notice">
|
||||||
|
You have not yet logged into this source. Clicking this will start login
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.title {
|
||||||
|
>h1 {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
>p {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.song-sources {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 50vw;
|
||||||
|
height: 40vh;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 32%;
|
||||||
|
margin: 0.5%;
|
||||||
|
height: 50%;
|
||||||
|
background-color: var(--accent-background);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.not-auth-notice {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from './PopupElement.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement>
|
||||||
|
<h2></h2>
|
||||||
|
<button>Ok</button>
|
||||||
|
<button>Cancel</button>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
|
const song = defineModel<Song>( {
|
||||||
|
'required': false
|
||||||
|
} );
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="current-song">
|
||||||
|
<div class="artwork">
|
||||||
|
<img
|
||||||
|
v-if="song?.artwork"
|
||||||
|
:src="song.artwork"
|
||||||
|
alt="Song cover"
|
||||||
|
class="song-cover"
|
||||||
|
>
|
||||||
|
<i v-else class="fa-solid fa-music song-cover"></i>
|
||||||
|
</div>
|
||||||
|
<div class="song-details">
|
||||||
|
<h1>
|
||||||
|
{{ song?.name ?? 'Not playing' }}
|
||||||
|
</h1>
|
||||||
|
<p>{{ song?.artist ?? 'No artist' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.current-song {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.artwork {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
>img, .fa-solid {
|
||||||
|
width: 60%;
|
||||||
|
max-height: 50%;
|
||||||
|
font-size: 15vw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-details {
|
||||||
|
>* {
|
||||||
|
margin: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ProgressBar from './ProgressBar.vue';
|
||||||
|
import {
|
||||||
|
beautifyTime
|
||||||
|
} from '@/ts/util/time';
|
||||||
|
import {
|
||||||
|
computed
|
||||||
|
} from 'vue';
|
||||||
|
import player from '@/ts/player';
|
||||||
|
|
||||||
|
const playbackPercentage = player.playbackPercentage;
|
||||||
|
const repeatMode = player.repeat;
|
||||||
|
const shuffleMode = player.shuffle;
|
||||||
|
|
||||||
|
const seek = () => {
|
||||||
|
player.seekTo( playbackPercentage.value );
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleRepeatMode = () => {
|
||||||
|
switch ( repeatMode.value ) {
|
||||||
|
case 'one':
|
||||||
|
player.setRepeat( 'all' );
|
||||||
|
break;
|
||||||
|
case 'off':
|
||||||
|
player.setRepeat( 'one' );
|
||||||
|
break;
|
||||||
|
case 'all':
|
||||||
|
player.setRepeat( 'off' );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openShareMenu = () => {
|
||||||
|
alert( 'Share menu not yet implemented' );
|
||||||
|
};
|
||||||
|
|
||||||
|
const current = computed( () => {
|
||||||
|
return beautifyTime( player.playbackPercentage.value * player.duration.value );
|
||||||
|
} );
|
||||||
|
const duration = computed( () => {
|
||||||
|
return beautifyTime( player.duration.value );
|
||||||
|
} );
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Button availability
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="mp-player">
|
||||||
|
<div class="controls">
|
||||||
|
<i class="fa-solid fa-backward-step" @click="player.prev"></i>
|
||||||
|
<i class="fa-solid fa-arrow-rotate-left quick-seek" @click="player.back10"></i>
|
||||||
|
<div :class="['play-pause', player.isPlaying.value ? undefined : 'paused']">
|
||||||
|
<i class="fa-solid fa-play" @click="player.play"></i>
|
||||||
|
<i class="fa-solid fa-pause" @click="player.pause"></i>
|
||||||
|
</div>
|
||||||
|
<i class="fa-solid fa-arrow-rotate-right quick-seek" @click="player.skip10"></i>
|
||||||
|
<i class="fa-solid fa-forward-step" @click="player.next"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="time">
|
||||||
|
<p class="current">
|
||||||
|
{{ current }}
|
||||||
|
</p>
|
||||||
|
<p class="duration">
|
||||||
|
{{ duration }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ProgressBar v-model="playbackPercentage" @move-end="seek" />
|
||||||
|
<div class="bottom-bar">
|
||||||
|
<!-- FA being a POS means need to make own mods to it -->
|
||||||
|
<i
|
||||||
|
:class="[
|
||||||
|
'fa-solid',
|
||||||
|
'fa-repeat',
|
||||||
|
'repeat',
|
||||||
|
repeatMode === 'one' ? 'once' : undefined,
|
||||||
|
repeatMode === 'all' ? 'all' : undefined
|
||||||
|
]"
|
||||||
|
@click="toggleRepeatMode"
|
||||||
|
></i>
|
||||||
|
<i class="fa-solid fa-share-from-square" @click="openShareMenu"></i>
|
||||||
|
<i
|
||||||
|
:class="['fa-solid', 'fa-shuffle', shuffleMode ? 'active' : undefined]"
|
||||||
|
@click="player.setShuffle( !shuffleMode )"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/player.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Hello World
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<template>
|
||||||
|
<div :class="['popup', isOpen ? undefined : 'hidden' ]">
|
||||||
|
<div class="popup-backdrop"></div>
|
||||||
|
<div class="popup-main">
|
||||||
|
<i v-if="props.showClose" class="fa-solid fa-xmark close-icon" @click="closePopup()"></i>
|
||||||
|
<slot></slot>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const isOpen = defineModel<boolean>();
|
||||||
|
const props = defineProps<{
|
||||||
|
'showClose'?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const closePopup = () => {
|
||||||
|
isOpen.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.popup {
|
||||||
|
position: fixed;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 1000;
|
||||||
|
transition-delay: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.popup-backdrop {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: absolute;
|
||||||
|
background-color: var( --overlay-color );
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-main {
|
||||||
|
width: fit-content;
|
||||||
|
height: fit-content;
|
||||||
|
background-color: var( --secondary-color );
|
||||||
|
padding: 2.5%;
|
||||||
|
border-radius: 20px;
|
||||||
|
transform: scale(1);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: block;
|
||||||
|
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||||
|
|
||||||
|
>.close-icon {
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
right: 20px;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.hidden {
|
||||||
|
top: -100vh;
|
||||||
|
transition-delay: 0.25s;
|
||||||
|
|
||||||
|
.popup-main {
|
||||||
|
transform: scale(1.25);
|
||||||
|
opacity: 0;
|
||||||
|
transition: transform 0.15s ease, opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.popup-backdrop {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
useTemplateRef
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
const val = defineModel<number>( {
|
||||||
|
'required': true,
|
||||||
|
'default': 0.5
|
||||||
|
} );
|
||||||
|
const offset = ref( -1 );
|
||||||
|
const isMoving = ref( false );
|
||||||
|
const bar = useTemplateRef( 'bar' );
|
||||||
|
|
||||||
|
const start = ( ev: MouseEvent ) => {
|
||||||
|
offset.value = bar.value!.getBoundingClientRect().x;
|
||||||
|
isMoving.value = true;
|
||||||
|
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||||
|
emit( 'move-start' );
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = ( ev: MouseEvent ) => {
|
||||||
|
if ( isMoving.value ) {
|
||||||
|
val.value = Math.max( 0, Math.min( ( ev.x - offset.value ) / bar.value!.clientWidth, 1 ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
if ( !isMoving.value ) return;
|
||||||
|
|
||||||
|
offset.value = -1;
|
||||||
|
isMoving.value = false;
|
||||||
|
emit( 'move-end' );
|
||||||
|
};
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
( e: 'move-end' ): void;
|
||||||
|
( e: 'move-start' ): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="progressbar">
|
||||||
|
<div ref="bar" class="back">
|
||||||
|
<div :style="`width: ${ val * 100 }%;`"></div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
:class="['click-target', offset >= 0 ? 'active' : undefined]"
|
||||||
|
@mousedown="start"
|
||||||
|
@mousemove="move"
|
||||||
|
@mouseup="end"
|
||||||
|
@mouseleave="end"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/progressbar.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
type WritableComputedRef,
|
||||||
|
computed,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import AddSong from './AddSong.vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
import SongEditor from './SongEditor.vue';
|
||||||
|
import SortableList from './SortableList.vue';
|
||||||
|
import {
|
||||||
|
beautifyTime
|
||||||
|
} from '@/ts/util/time';
|
||||||
|
import player from '@/ts/player';
|
||||||
|
import {
|
||||||
|
queueIdx
|
||||||
|
} from '@/ts/player/state';
|
||||||
|
|
||||||
|
const queue: WritableComputedRef<Song[]> = computed( {
|
||||||
|
get () {
|
||||||
|
return player.queue.value.slice( player.queueIdx.value + 1 );
|
||||||
|
},
|
||||||
|
set ( val ) {
|
||||||
|
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value + 1 ).concat( val );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
const showAddSong = ref( false );
|
||||||
|
const showEditSong = ref( false );
|
||||||
|
const editingSong: Ref<null | Song> = ref( null );
|
||||||
|
|
||||||
|
const addSong = () => {
|
||||||
|
showAddSong.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editSong = ( idx: number ) => {
|
||||||
|
showEditSong.value = true;
|
||||||
|
editingSong.value = player.queue.value[ idx + queueIdx.value + 1 ]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSong = ( idx: number ) => {
|
||||||
|
player.removeSong( idx + queueIdx.value + 1 );
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="queue-viewer">
|
||||||
|
<div>
|
||||||
|
<button @click="addSong">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
<button @click="player.clearQueue">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<!-- TODO: On save, copy the current queue into rawQueue! -->
|
||||||
|
<button>
|
||||||
|
<i class="fa-solid fa-save"></i>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
<!-- TODO: Should only appear when sharing -->
|
||||||
|
<button>
|
||||||
|
<i class="fa-solid fa-paper-plane"></i>
|
||||||
|
Transmit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<SongEditor v-model="showEditSong" editing-song="" />
|
||||||
|
<AddSong v-model="showAddSong" />
|
||||||
|
<div v-if="queue.length > 0" class="queue-container">
|
||||||
|
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
||||||
|
<div class="song-list-element">
|
||||||
|
<div class="song-cover-wrapper">
|
||||||
|
<img
|
||||||
|
v-if="song.artwork"
|
||||||
|
:src="song.artwork"
|
||||||
|
alt="Song cover"
|
||||||
|
class="song-cover"
|
||||||
|
>
|
||||||
|
<i v-else class="fa-solid fa-music song-cover"></i>
|
||||||
|
<div class="play-overlay hover">
|
||||||
|
<i class="fa-solid fa-play" @click="() => player.playIndex( index + queueIdx + 1 )"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-details">
|
||||||
|
<h3>{{ song.name }}</h3>
|
||||||
|
<p>{{ song.artist }}</p>
|
||||||
|
<p>{{ song['additional-info'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<p>{{ beautifyTime( song.duration ) }}</p>
|
||||||
|
<i v-if="index !== 0" class="fa-solid fa-trash-can" @click="() => deleteSong( index )"></i>
|
||||||
|
<i class="fa-solid fa-pen-to-square" @click="() => editSong( index )"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SortableList>
|
||||||
|
</div>
|
||||||
|
<div v-else class="queue-container">
|
||||||
|
<p>
|
||||||
|
No more songs in queue
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button @click="addSong">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/queue.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from './PopupElement.vue';
|
||||||
|
|
||||||
|
const model = defineModel<boolean>( {
|
||||||
|
'required': true
|
||||||
|
} );
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="model" show-close>
|
||||||
|
<h2>Edit Song</h2>
|
||||||
|
<!-- TODO: Need a way to use the search feature to replace the song details, as of course as well text editors -->
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts" generic="T">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
ref,
|
||||||
|
useTemplateRef
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
const array = defineModel<T[]>( {
|
||||||
|
'required': true
|
||||||
|
} );
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default( props: {
|
||||||
|
'item': T,
|
||||||
|
'index': number
|
||||||
|
} ): void
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
( e: 'dropped' ): void
|
||||||
|
}>();
|
||||||
|
const scrollContainer = useTemplateRef( 'scroll-container' );
|
||||||
|
const movingIdx = ref( -1 );
|
||||||
|
const movingCurrentIdx = ref( -1 );
|
||||||
|
const movableSize = ref( -1 );
|
||||||
|
const offset = ref( -1 );
|
||||||
|
|
||||||
|
let mover = -1;
|
||||||
|
let moveSpeed = 0;
|
||||||
|
|
||||||
|
const start = ( ev: MouseEvent, idx: number ) => {
|
||||||
|
if ( array.value.length < 2 ) return;
|
||||||
|
|
||||||
|
movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight;
|
||||||
|
offset.value = ev.y - ( movableSize.value / 2 );
|
||||||
|
movingIdx.value = idx;
|
||||||
|
movingCurrentIdx.value = idx;
|
||||||
|
};
|
||||||
|
|
||||||
|
const move = ( ev: MouseEvent ) => {
|
||||||
|
if ( movingIdx.value < 0 ) return;
|
||||||
|
|
||||||
|
offset.value = ev.y - ( movableSize.value / 2 );
|
||||||
|
const relToContainer = ev.y - scrollContainer.value!.offsetTop;
|
||||||
|
const height = scrollContainer.value!.offsetHeight;
|
||||||
|
|
||||||
|
// Scrolling up and down
|
||||||
|
if ( relToContainer < 50 ) {
|
||||||
|
if ( relToContainer < 0 )
|
||||||
|
moveSpeed = -20;
|
||||||
|
else
|
||||||
|
moveSpeed = -Math.ceil( Math.max( 1, Math.min( 5 / relToContainer, 10 ) ) );
|
||||||
|
|
||||||
|
startTracker();
|
||||||
|
} else if ( relToContainer > height - 50 ) {
|
||||||
|
if ( height - relToContainer < 0 )
|
||||||
|
moveSpeed = 20;
|
||||||
|
else
|
||||||
|
moveSpeed = Math.ceil( Math.max( 1, Math.min( 5 / ( height - relToContainer ), 10 ) ) );
|
||||||
|
|
||||||
|
startTracker();
|
||||||
|
} else {
|
||||||
|
if ( mover >= 0 ) {
|
||||||
|
try {
|
||||||
|
clearInterval( mover );
|
||||||
|
mover = -1;
|
||||||
|
} catch { /* empty */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine current index to insert
|
||||||
|
const percentage = ( relToContainer + scrollContainer.value!.scrollTop ) / scrollContainer.value!.scrollHeight;
|
||||||
|
const newIdx = Math.min( Math.max( Math.round( percentage * array.value.length ), 0 ), array.value.length - 1 );
|
||||||
|
|
||||||
|
if ( newIdx !== movingIdx.value )
|
||||||
|
movingCurrentIdx.value = newIdx;
|
||||||
|
else
|
||||||
|
movingCurrentIdx.value = movingIdx.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startTracker = () => {
|
||||||
|
if ( mover < 0 ) {
|
||||||
|
mover = setInterval( () => {
|
||||||
|
scrollContainer.value!.scrollTop += moveSpeed;
|
||||||
|
}, 100 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const end = () => {
|
||||||
|
offset.value = -1;
|
||||||
|
|
||||||
|
try {
|
||||||
|
clearInterval( mover );
|
||||||
|
mover = -1;
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
const before = array.value.slice( 0, movingIdx.value );
|
||||||
|
const after = array.value.slice( movingIdx.value + 1 );
|
||||||
|
const el = array.value[ movingIdx.value ]!;
|
||||||
|
const arr = before.concat( after );
|
||||||
|
|
||||||
|
arr.splice( movingCurrentIdx.value, 0, el );
|
||||||
|
array.value = arr;
|
||||||
|
movingIdx.value = -1;
|
||||||
|
movingCurrentIdx.value = -1;
|
||||||
|
|
||||||
|
emit( 'dropped' );
|
||||||
|
};
|
||||||
|
|
||||||
|
const style = computed( () => {
|
||||||
|
return ( index: number ) => {
|
||||||
|
if ( movingIdx.value === array.value.length - 1 && movingIdx.value === index + 1
|
||||||
|
&& movingCurrentIdx.value === movingIdx.value )
|
||||||
|
return `margin-bottom: ${ movableSize.value }px;`;
|
||||||
|
|
||||||
|
if ( movingIdx.value >= 0 ) {
|
||||||
|
if ( movingIdx.value === index ) {
|
||||||
|
// Current item is moved
|
||||||
|
return `top: ${ offset.value }px;`;
|
||||||
|
} else {
|
||||||
|
// Current item is not moved
|
||||||
|
if ( movingCurrentIdx.value === array.value.length - 1 && index === movingCurrentIdx.value ) {
|
||||||
|
// Create gap below
|
||||||
|
return `margin-bottom: ${ movableSize.value }px;`;
|
||||||
|
} else if ( movingCurrentIdx.value === index
|
||||||
|
|| ( movingCurrentIdx.value === index - 1 && movingIdx.value == index - 1 ) ) {
|
||||||
|
// Create a gap above
|
||||||
|
return `margin-top: ${ movableSize.value }px;`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="scroll-container" class="sortable-list">
|
||||||
|
<div
|
||||||
|
v-if="movingIdx >= 0"
|
||||||
|
class="grip-target"
|
||||||
|
@mousemove="move"
|
||||||
|
@mouseleave="end"
|
||||||
|
@mouseup="end"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(item, index) in array"
|
||||||
|
:id="'movable-' + index"
|
||||||
|
:key="index"
|
||||||
|
:class="[ 'movable', movingIdx === index ? 'moving' : undefined ]"
|
||||||
|
:style="style(index)"
|
||||||
|
>
|
||||||
|
<slot :item="item" :index="index"></slot>
|
||||||
|
<i class="fa-solid fa-grip-vertical" @mousedown="( e ) => start( e, index )"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/sortablelist.scss'
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import {
|
||||||
|
isShowingDiskLoader
|
||||||
|
} from './diskLoader';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="isShowingDiskLoader" show-close>
|
||||||
|
<h3>Load from disk</h3>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type ImportTypes,
|
||||||
|
importers,
|
||||||
|
isShowingImportTypePicker
|
||||||
|
} from './importTypePicker';
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import SearchView from './SearchView.vue';
|
||||||
|
import {
|
||||||
|
openDiskLoaderInterface
|
||||||
|
} from './diskLoader';
|
||||||
|
import {
|
||||||
|
openSearchInterface
|
||||||
|
} from './searchManager';
|
||||||
|
|
||||||
|
const openPicker = ( source: ImportTypes ) => {
|
||||||
|
isShowingImportTypePicker.value = false;
|
||||||
|
|
||||||
|
|
||||||
|
if ( source.type === 'cloud' ) {
|
||||||
|
openSearchInterface( source );
|
||||||
|
} else {
|
||||||
|
openDiskLoaderInterface( source );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchView />
|
||||||
|
<PopupElement v-model="isShowingImportTypePicker" show-close>
|
||||||
|
<h2>What would you like to add?</h2>
|
||||||
|
<div class="import-type-list">
|
||||||
|
<div v-for="(source, index) in importers" :key="index" @click="openPicker( source )">
|
||||||
|
{{ source.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.import-type-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 50vw;
|
||||||
|
height: 35vh;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 32%;
|
||||||
|
margin: 0.5%;
|
||||||
|
height: 50%;
|
||||||
|
background-color: var(--accent-background);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
isShowingSearchView,
|
||||||
|
searchOpts
|
||||||
|
} from './searchManager';
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
|
const results: Ref<Song[]> = ref( [] );
|
||||||
|
const query = ref( '' );
|
||||||
|
const isSearching = ref( false );
|
||||||
|
const addedIndex = ref( -1 );
|
||||||
|
|
||||||
|
let addedTimeout = -1;
|
||||||
|
let timeout = -1;
|
||||||
|
let searchedForQuery = '';
|
||||||
|
|
||||||
|
const search = ( ev: KeyboardEvent ) => {
|
||||||
|
if ( ev.key === 'Enter' ) {
|
||||||
|
// TODO: If no new input, instead of searching, add first song
|
||||||
|
try {
|
||||||
|
clearTimeout( timeout );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
doSearch();
|
||||||
|
} else if ( query.value.length > 2 ) {
|
||||||
|
try {
|
||||||
|
clearTimeout( timeout );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
timeout = setTimeout( doSearch, 250 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
if ( query.value === searchedForQuery ) return;
|
||||||
|
|
||||||
|
isSearching.value = true;
|
||||||
|
searchedForQuery = query.value;
|
||||||
|
results.value = await searchOpts.value?.search( query.value ) ?? [];
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = ( idx: number ) => {
|
||||||
|
try {
|
||||||
|
clearTimeout( addedTimeout );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
addedIndex.value = idx;
|
||||||
|
searchOpts.value?.addSelected( idx );
|
||||||
|
addedTimeout = setTimeout( () => {
|
||||||
|
addedIndex.value = -1;
|
||||||
|
}, 2000 );
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="isShowingSearchView" show-close>
|
||||||
|
<h2>Search {{ searchOpts?.name }}</h2>
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
@keypress="search"
|
||||||
|
>
|
||||||
|
<div v-if="!isSearching && results.length > 0" class="search-results-wrapper">
|
||||||
|
<div v-for="(result, index) in results" :key="index" @click="() => add(index)">
|
||||||
|
<img v-if="result.artwork" :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
|
||||||
|
<div>
|
||||||
|
<h4>{{ result.name }}</h4>
|
||||||
|
<p>{{ result.artist }}</p>
|
||||||
|
</div>
|
||||||
|
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
|
||||||
|
No results found
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-results-wrapper placeholder">
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/search.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
FileImport
|
||||||
|
} from './importTypePicker';
|
||||||
|
|
||||||
|
export const isShowingDiskLoader = ref( false );
|
||||||
|
|
||||||
|
export const diskLoaderOpts: Ref<FileImport | null> = ref( null );
|
||||||
|
|
||||||
|
export const openDiskLoaderInterface = ( options: FileImport ) => {
|
||||||
|
isShowingDiskLoader.value = true;
|
||||||
|
diskLoaderOpts.value = options;
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
|
export interface CloudImport {
|
||||||
|
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
|
||||||
|
'name': string;
|
||||||
|
'type': 'cloud';
|
||||||
|
'search': ( term: string ) => Promise<Song[]>;
|
||||||
|
'addSelected': ( index: number ) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?)
|
||||||
|
export interface FileImport {
|
||||||
|
'name': string;
|
||||||
|
'type': 'file';
|
||||||
|
'process': ( files: File[] ) => Promise<Song[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ImportTypes = CloudImport | FileImport;
|
||||||
|
|
||||||
|
export const isShowingImportTypePicker = ref( false );
|
||||||
|
|
||||||
|
export const importers: Ref<( CloudImport | FileImport )[]> = ref( [] );
|
||||||
|
|
||||||
|
export const openImportTypePicker = ( options: ( CloudImport | FileImport )[] ) => {
|
||||||
|
isShowingImportTypePicker.value = true;
|
||||||
|
importers.value = options;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
CloudImport
|
||||||
|
} from './importTypePicker';
|
||||||
|
|
||||||
|
export const isShowingSearchView = ref( false );
|
||||||
|
|
||||||
|
export const searchOpts: Ref<CloudImport | null> = ref( null );
|
||||||
|
|
||||||
|
export const openSearchInterface = ( options: CloudImport ) => {
|
||||||
|
searchOpts.value = options;
|
||||||
|
isShowingSearchView.value = true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import '@fortawesome/fontawesome-free/css/all.css';
|
||||||
|
import App from './App.vue';
|
||||||
|
import {
|
||||||
|
createApp
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
createPinia
|
||||||
|
} from 'pinia';
|
||||||
|
import router from './router';
|
||||||
|
|
||||||
|
const app = createApp( App );
|
||||||
|
|
||||||
|
app.use( createPinia() );
|
||||||
|
app.use( router );
|
||||||
|
|
||||||
|
app.mount( '#app' );
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
createRouter,
|
||||||
|
createWebHistory
|
||||||
|
} from 'vue-router';
|
||||||
|
import {
|
||||||
|
useAuthStore
|
||||||
|
} from '@/stores/authstore';
|
||||||
|
|
||||||
|
const router = createRouter( {
|
||||||
|
'history': createWebHistory( import.meta.env.BASE_URL ),
|
||||||
|
'routes': [
|
||||||
|
{
|
||||||
|
'path': '/',
|
||||||
|
'name': 'home',
|
||||||
|
'component': () => import( '@/views/StartPage.vue' ),
|
||||||
|
'meta': {
|
||||||
|
'title': 'Home',
|
||||||
|
'auth': false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'path': '/app',
|
||||||
|
'name': 'app',
|
||||||
|
'component': () => import( '@/views/AppView.vue' ),
|
||||||
|
'meta': {
|
||||||
|
'title': 'Player',
|
||||||
|
'auth': true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'path': '/:pathMatch(.*)*',
|
||||||
|
'name': 'NotFound',
|
||||||
|
'component': () => import( '@/views/404View.vue' ),
|
||||||
|
'meta': {
|
||||||
|
'title': '404 :: Page not found',
|
||||||
|
'transition': 'scale'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.beforeEach( to => {
|
||||||
|
const store = useAuthStore();
|
||||||
|
|
||||||
|
if ( to.meta.auth && !store.isAuth ) {
|
||||||
|
return {
|
||||||
|
'name': 'home'
|
||||||
|
};
|
||||||
|
} else if ( to.name === 'home' && store.isAuth ) {
|
||||||
|
return {
|
||||||
|
'name': 'app'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
|
||||||
|
router.afterEach( to => {
|
||||||
|
window.scrollTo( {
|
||||||
|
'top': 0,
|
||||||
|
'behavior': 'smooth'
|
||||||
|
} );
|
||||||
|
document.title = to.meta.title ? to.meta.title + ' - MusicPlayer' : 'MusicPlayer';
|
||||||
|
// NProgress.done();
|
||||||
|
} );
|
||||||
|
|
||||||
|
export default router;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
.mp-player {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.fa-solid {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: font-size ease 0.5s;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
font-size: 2.7rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 3rem;
|
||||||
|
|
||||||
|
>.play-pause {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
>.fa-play {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.paused {
|
||||||
|
>.fa-pause {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-play {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.quick-seek {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "10";
|
||||||
|
font-family: sans-serif;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 40%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 5%;
|
||||||
|
height: 95%;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.time {
|
||||||
|
display: flex;
|
||||||
|
width: 85%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
>p {
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.duration {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
width: 95%;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.bottom-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 3rem;
|
||||||
|
margin-top: 15px;
|
||||||
|
|
||||||
|
>.fa-shuffle {
|
||||||
|
&.active {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--secondary-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.repeat {
|
||||||
|
position: relative;
|
||||||
|
padding: 5px;
|
||||||
|
|
||||||
|
&.once,
|
||||||
|
&.all {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: var(--secondary-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.once::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "1";
|
||||||
|
font-size: 40%;
|
||||||
|
font-family: sans-serif;
|
||||||
|
top: 5px;
|
||||||
|
right: 5px;
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
width: 1.5em;
|
||||||
|
height: 1.5em;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 1em;
|
||||||
|
line-height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
.progressbar {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
>.back {
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--accent-background);
|
||||||
|
height: 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
background-color: var(--accent-color);
|
||||||
|
height: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.click-target {
|
||||||
|
width: 100%;
|
||||||
|
height: 10px;
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: grabbing;
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
transition: none;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
.queue-viewer {
|
||||||
|
height: 100%;
|
||||||
|
width: 90%;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
>.queue-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
height: 90%;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.song-list-element {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
>.song-cover-wrapper {
|
||||||
|
height: 7rem;
|
||||||
|
width: 7rem;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 20px;
|
||||||
|
|
||||||
|
.song-cover,
|
||||||
|
.fa-solid {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 6rem;
|
||||||
|
line-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.play-overlay {
|
||||||
|
background-color: var(--overlay-color);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.hover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
>.play-overlay.hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.song-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
>* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.song-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
>* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.search-results-wrapper {
|
||||||
|
width: 70vw;
|
||||||
|
height: 60vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.placeholder {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
width: 75%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
>* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>img {
|
||||||
|
height: 7rem;
|
||||||
|
width: 7rem;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
.sortable-list {
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
>.grip-target {
|
||||||
|
width: 100vw;
|
||||||
|
height: 100vh;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
cursor: grabbing;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.movable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
|
&.moving {
|
||||||
|
position: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
>i {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import {
|
||||||
|
defineStore
|
||||||
|
} from 'pinia';
|
||||||
|
import {
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore( 'authstore', () => {
|
||||||
|
const isAuth = ref( true );
|
||||||
|
|
||||||
|
return {
|
||||||
|
isAuth
|
||||||
|
};
|
||||||
|
} );
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import type {
|
||||||
|
MusicKitObject
|
||||||
|
} from 'musickitjs-v3-types/MusicKit';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var MusicKit: MusicKitObject;
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
export type RepeatMode = 'off' | 'one' | 'all';
|
||||||
Vendored
+49
@@ -0,0 +1,49 @@
|
|||||||
|
// NOTE: For re-associating files with details here, can use dropdown in UI
|
||||||
|
|
||||||
|
export type Playlist = Song[];
|
||||||
|
|
||||||
|
export interface UrlToFileMapping {
|
||||||
|
[key: string]: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Song {
|
||||||
|
/**
|
||||||
|
* Where the song can be found
|
||||||
|
*/
|
||||||
|
'source': 'local' | 'applemusic';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identifier for the song so it can be found again (such as Apple Music ID or URL for playback)
|
||||||
|
*/
|
||||||
|
'identifier': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Any additional identifiers (such as filename, etc) to associate
|
||||||
|
*/
|
||||||
|
'additional-identifier'?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional info, such as dance type to be displayed on remote screens
|
||||||
|
*/
|
||||||
|
'additional-info': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The artist of the song
|
||||||
|
*/
|
||||||
|
'artist': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of the song
|
||||||
|
*/
|
||||||
|
'name': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cover image as a URL
|
||||||
|
*/
|
||||||
|
'artwork': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Song duration
|
||||||
|
*/
|
||||||
|
'duration': number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
addSongList,
|
||||||
|
clearQueue,
|
||||||
|
removeSong,
|
||||||
|
shuffleList
|
||||||
|
} from './playlists/add';
|
||||||
|
import {
|
||||||
|
currentSource,
|
||||||
|
isPlaying,
|
||||||
|
queue,
|
||||||
|
queueIdx,
|
||||||
|
rawQueue,
|
||||||
|
repeat,
|
||||||
|
shuffle,
|
||||||
|
sources
|
||||||
|
} from './state';
|
||||||
|
import {
|
||||||
|
duration,
|
||||||
|
playbackPercentage,
|
||||||
|
startTracking,
|
||||||
|
stopTracking
|
||||||
|
} from './status-tracking';
|
||||||
|
import type {
|
||||||
|
RepeatMode
|
||||||
|
} from '../dtype/player';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '../dtype/playlist';
|
||||||
|
import {
|
||||||
|
playIndex
|
||||||
|
} from './playlists';
|
||||||
|
|
||||||
|
const next = () => {
|
||||||
|
playIndex( ( queueIdx.value + 1 ) % queue.value.length );
|
||||||
|
};
|
||||||
|
|
||||||
|
const prev = () => {
|
||||||
|
// TODO: Back to beginning if more than some seconds have passed?
|
||||||
|
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
|
||||||
|
};
|
||||||
|
|
||||||
|
const play = () => {
|
||||||
|
if ( currentSource.value === '' ) return;
|
||||||
|
|
||||||
|
isPlaying.value = true;
|
||||||
|
|
||||||
|
sources[currentSource.value]?.play();
|
||||||
|
startTracking();
|
||||||
|
};
|
||||||
|
|
||||||
|
const pause = () => {
|
||||||
|
if ( currentSource.value === '' ) return;
|
||||||
|
|
||||||
|
isPlaying.value = false;
|
||||||
|
|
||||||
|
sources[currentSource.value]?.pause();
|
||||||
|
stopTracking();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a specific point in the song
|
||||||
|
* @param pos - Percentage of song, value in [0, 1]
|
||||||
|
*/
|
||||||
|
const seekTo = ( pos: number ) => {
|
||||||
|
if ( currentSource.value === '' ) return;
|
||||||
|
|
||||||
|
sources[currentSource.value]?.seekTo( pos );
|
||||||
|
};
|
||||||
|
|
||||||
|
const skip10 = () => {
|
||||||
|
if ( currentSource.value === '' ) return;
|
||||||
|
|
||||||
|
const source = sources[currentSource.value];
|
||||||
|
const duration = source?.getDuration() ?? -1;
|
||||||
|
|
||||||
|
seekTo( ( ( ( source?.getPlaybackPos() ?? 0 ) * duration ) + 10 ) / duration );
|
||||||
|
};
|
||||||
|
|
||||||
|
const back10 = () => {
|
||||||
|
if ( currentSource.value === '' ) return;
|
||||||
|
|
||||||
|
const source = sources[currentSource.value];
|
||||||
|
const duration = source?.getDuration() ?? -1;
|
||||||
|
|
||||||
|
seekTo( ( ( ( source?.getPlaybackPos() ?? 0 ) * duration ) - 10 ) / duration );
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn on or off shuffle
|
||||||
|
* @param enabled - Whether to enable or disable shuffle
|
||||||
|
*/
|
||||||
|
const setShuffle = ( enabled: boolean ) => {
|
||||||
|
shuffle.value = enabled;
|
||||||
|
|
||||||
|
if ( enabled ) {
|
||||||
|
shuffleList();
|
||||||
|
queueIdx.value = 0;
|
||||||
|
} else {
|
||||||
|
const curr = queue.value[queueIdx.value];
|
||||||
|
|
||||||
|
queue.value = [];
|
||||||
|
|
||||||
|
for ( let i = 0; i < rawQueue.value.length; i++ ) {
|
||||||
|
if ( rawQueue.value[i] === curr )
|
||||||
|
queueIdx.value = i;
|
||||||
|
|
||||||
|
queue.value.push( rawQueue.value[i]! );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change the repeat mode
|
||||||
|
* @param mode - The repeat mode to switch into
|
||||||
|
*/
|
||||||
|
const setRepeat = ( mode: RepeatMode ) => {
|
||||||
|
repeat.value = mode;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSources = (): string[] => {
|
||||||
|
return Object.keys( sources );
|
||||||
|
};
|
||||||
|
|
||||||
|
const addToSongList = ( songs: Song[] ) => {
|
||||||
|
addSongList( songs );
|
||||||
|
|
||||||
|
if ( currentSource.value === '' ) {
|
||||||
|
playIndex( 0 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add songs to the playlist from given source
|
||||||
|
* @param source - The ID of the source to add from
|
||||||
|
*/
|
||||||
|
const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
||||||
|
if ( sources[source]!.authorized.value ) {
|
||||||
|
sources[source]!.addSongsFromThisSource( addToSongList );
|
||||||
|
} else {
|
||||||
|
// TODO: Make this interact with user
|
||||||
|
sources[source]!.login!();
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
play,
|
||||||
|
pause,
|
||||||
|
seekTo,
|
||||||
|
skip10,
|
||||||
|
back10,
|
||||||
|
setShuffle,
|
||||||
|
setRepeat,
|
||||||
|
playIndex,
|
||||||
|
getSources,
|
||||||
|
addSongFromSource,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
clearQueue,
|
||||||
|
removeSong,
|
||||||
|
queue,
|
||||||
|
queueIdx,
|
||||||
|
duration,
|
||||||
|
playbackPercentage,
|
||||||
|
repeat,
|
||||||
|
shuffle,
|
||||||
|
isPlaying
|
||||||
|
};
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import {
|
||||||
|
currentSource,
|
||||||
|
isPlaying,
|
||||||
|
queue,
|
||||||
|
queueIdx,
|
||||||
|
rawQueue,
|
||||||
|
shuffle,
|
||||||
|
sources
|
||||||
|
} from '../state';
|
||||||
|
import {
|
||||||
|
duration,
|
||||||
|
playbackPercentage
|
||||||
|
} from '../status-tracking';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
|
export const addSongList = ( songs: Song[], first: boolean = false ) => {
|
||||||
|
if ( first ) {
|
||||||
|
// FIXME: Needs to insert at current index in queue, but where for rawQueue?
|
||||||
|
// Probably at end if shuffled, else insert into queue, then copy
|
||||||
|
rawQueue.value = songs.concat( rawQueue.value );
|
||||||
|
queue.value = songs.concat( queue.value );
|
||||||
|
} else {
|
||||||
|
rawQueue.value = rawQueue.value.concat( songs );
|
||||||
|
queue.value = queue.value.concat( songs );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearQueue = () => {
|
||||||
|
queue.value = [];
|
||||||
|
rawQueue.value = [];
|
||||||
|
sources[currentSource.value]?.stop();
|
||||||
|
duration.value = -1;
|
||||||
|
playbackPercentage.value = 1;
|
||||||
|
isPlaying.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a song from the given queue index
|
||||||
|
* @param idx - The index to remove at
|
||||||
|
* @returns True if successful, False if not
|
||||||
|
*/
|
||||||
|
export const removeSong = ( idx: number ) => {
|
||||||
|
if ( idx <= queueIdx.value ) return false;
|
||||||
|
|
||||||
|
const removed = queue.value.splice( idx, 1 )[0];
|
||||||
|
|
||||||
|
for ( let i = 0; i < rawQueue.value.length; i++ ) {
|
||||||
|
if ( rawQueue.value[ i ] === removed ) {
|
||||||
|
rawQueue.value.splice( i, 1 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a song in the queue
|
||||||
|
* @param idx - The index to move
|
||||||
|
* @param newIdx - The index to move it to
|
||||||
|
* @returns True if successful, false if constraints were violated
|
||||||
|
*/
|
||||||
|
export const moveSong = ( idx: number, newIdx: number ) => {
|
||||||
|
if ( idx === newIdx ) return true;
|
||||||
|
|
||||||
|
if ( idx <= queueIdx.value || newIdx <= queueIdx.value || idx > queue.value.length || newIdx > queue.value.length )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// Affects only the queue if shuffled, else copy the queue into rawQueue
|
||||||
|
const movedEl = queue.value[ idx ]!;
|
||||||
|
|
||||||
|
if ( idx < newIdx )
|
||||||
|
for ( let i = idx + 1; i < Math.min( queue.value.length, newIdx + 1 ); i++ ) {
|
||||||
|
queue.value[ i - 1 ] = queue.value[ i ]!;
|
||||||
|
}
|
||||||
|
else if ( idx > newIdx )
|
||||||
|
for ( let i = idx + 1; i > Math.max( 0, newIdx + 1 ); i-- ) {
|
||||||
|
queue.value[ i ] = queue.value[ i - 1 ]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.value[ newIdx ] = movedEl;
|
||||||
|
|
||||||
|
if ( !shuffle.value ) {
|
||||||
|
// If we don't shuffle, the rawQueue should also be updated
|
||||||
|
rawQueue.value = [];
|
||||||
|
|
||||||
|
for ( const song of queue.value ) {
|
||||||
|
rawQueue.value.push( song );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shuffle the song list */
|
||||||
|
export const shuffleList = () => {
|
||||||
|
const shuffled = rawQueue.value
|
||||||
|
.map( ( val, idx ) => {
|
||||||
|
return {
|
||||||
|
val,
|
||||||
|
idx,
|
||||||
|
'sort': Math.random()
|
||||||
|
};
|
||||||
|
} ).sort( ( a, b ) => a.sort - b.sort );
|
||||||
|
const q: Song[] = [];
|
||||||
|
|
||||||
|
// Make current song the first in the list
|
||||||
|
q.push( rawQueue.value[queueIdx.value]! );
|
||||||
|
|
||||||
|
for ( const el of shuffled ) {
|
||||||
|
if ( el.idx !== queueIdx.value )
|
||||||
|
q.push( el.val );
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.value = q;
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import {
|
||||||
|
currentSource,
|
||||||
|
isPlaying,
|
||||||
|
queue,
|
||||||
|
queueIdx,
|
||||||
|
repeat,
|
||||||
|
sources
|
||||||
|
} from '../state';
|
||||||
|
import {
|
||||||
|
startTracking,
|
||||||
|
stopTracking
|
||||||
|
} from '../status-tracking';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a song at the given index of the queue. Wraps to 0 and end if index below 0 or above end
|
||||||
|
* @param idx - The index in the queue to play at
|
||||||
|
*/
|
||||||
|
export const playIndex = async ( idx: number ) => {
|
||||||
|
if ( idx >= queue.value.length ) {
|
||||||
|
idx = repeat.value === 'all' ? 0 : -1;
|
||||||
|
} else if ( idx < 0 ) {
|
||||||
|
idx = repeat.value === 'all' ? queue.value.length - 1 : -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
sources[currentSource.value]?.stop();
|
||||||
|
|
||||||
|
if ( idx < 0 ) {
|
||||||
|
isPlaying.value = false;
|
||||||
|
currentSource.value = '';
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
stopTracking();
|
||||||
|
|
||||||
|
const nextSong = queue.value[ idx ]!;
|
||||||
|
|
||||||
|
queueIdx.value = idx;
|
||||||
|
currentSource.value = nextSong.source;
|
||||||
|
sources[currentSource.value]?.playSong( nextSong.identifier );
|
||||||
|
|
||||||
|
startTracking();
|
||||||
|
|
||||||
|
isPlaying.value = true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// TODO: If local files contained, prompt user to select files, then associate based on file names
|
||||||
|
export const load = () => {};
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
import type {
|
||||||
|
Ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
|
// TODO: Add search interface elements to plugin's args (such as a search interface)
|
||||||
|
export type PlayerSourcePluginInitializer = () => Promise<PlayerSourcePlugin>;
|
||||||
|
|
||||||
|
export interface PlayerSourcePlugin {
|
||||||
|
/**
|
||||||
|
* The name of the source that is displayed in the UI
|
||||||
|
*/
|
||||||
|
'name': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The identifier used in the playlist files
|
||||||
|
*/
|
||||||
|
'id': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Font-Awesome icon name (without fa and needs to be of free icons
|
||||||
|
*/
|
||||||
|
'icon'?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Describe how files are loaded here
|
||||||
|
*/
|
||||||
|
'loading': {
|
||||||
|
/**
|
||||||
|
* Set to true if the user needs to load files
|
||||||
|
*/
|
||||||
|
'requiresLocalFiles': true;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The allowed MIME types for loading associated files
|
||||||
|
*/
|
||||||
|
'MIMETypes': string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function used to associate the file to the songs of the playlist
|
||||||
|
* @param files - The files that were picked by the user
|
||||||
|
* @returns A promise resolving to an array of song identifiers (corresponding to Song.identifier, for association)
|
||||||
|
*/
|
||||||
|
'association': ( files: File[] ) => Promise<string[]>;
|
||||||
|
} | {
|
||||||
|
/**
|
||||||
|
* Set to true if the user needs to load files
|
||||||
|
*/
|
||||||
|
'requiresLocalFiles': false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement any login flow here. Optional
|
||||||
|
*/
|
||||||
|
'login'?: () => Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Play a song by its ID. Should start playing it immediately
|
||||||
|
*/
|
||||||
|
'playSong': ( id: string ) => Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Continue playing the song
|
||||||
|
*/
|
||||||
|
'play': () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pause the currently playing song
|
||||||
|
*/
|
||||||
|
'pause': () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop / unload the current song. This indicates that either the playlist was cleared
|
||||||
|
* or a next song is about to be loaded (possibly from another plugin)
|
||||||
|
*/
|
||||||
|
'stop': () => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seek to a specific point in the song.
|
||||||
|
* @param pos is always given as a percentage (pos \in [0, 1])
|
||||||
|
*/
|
||||||
|
'seekTo': ( pos: number ) => void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the playback position as a percentage of the duration. (return value \in [0, 1])
|
||||||
|
*/
|
||||||
|
'getPlaybackPos': () => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the song duration in seconds.
|
||||||
|
*/
|
||||||
|
'getDuration': () => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if source is authorized
|
||||||
|
*/
|
||||||
|
'authorized': Ref<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when user adds another song to the playlist via this source.
|
||||||
|
* You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc
|
||||||
|
*/
|
||||||
|
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type {
|
||||||
|
PlayerSourcePlugin,
|
||||||
|
PlayerSourcePluginInitializer
|
||||||
|
} from '../interface';
|
||||||
|
import {
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
|
||||||
|
const player = document.createElement( 'audio' );
|
||||||
|
|
||||||
|
const playSong = async ( id: string ) => {
|
||||||
|
player.src = id;
|
||||||
|
player.play();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'authorized': ref( true ),
|
||||||
|
'id': 'local',
|
||||||
|
'name': 'Local Disk',
|
||||||
|
'play': player.play,
|
||||||
|
'getPlaybackPos': () => player.currentTime / player.duration,
|
||||||
|
'getDuration': () => player.duration,
|
||||||
|
playSong,
|
||||||
|
'seekTo': pos => player.currentTime = pos,
|
||||||
|
'pause': player.pause,
|
||||||
|
'stop': () => player.src = '',
|
||||||
|
// TODO: Implement
|
||||||
|
'addSongsFromThisSource': async () => [],
|
||||||
|
'loading': {
|
||||||
|
'requiresLocalFiles': true,
|
||||||
|
'MIMETypes': 'audio/mp3,audio/wav',
|
||||||
|
// TODO: Implement
|
||||||
|
'association': async () => []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import type {
|
||||||
|
PlayerSourcePlugin,
|
||||||
|
PlayerSourcePluginInitializer
|
||||||
|
} from '../interface';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
import {
|
||||||
|
addFromAppleMusic
|
||||||
|
} from './search';
|
||||||
|
import {
|
||||||
|
musicKitPlayback
|
||||||
|
} from './playback';
|
||||||
|
|
||||||
|
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
|
||||||
|
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', {
|
||||||
|
'credentials': 'include'
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( res.status === 200 ) {
|
||||||
|
const token = await res.text();
|
||||||
|
const instance = await window.MusicKit.configure( {
|
||||||
|
'developerToken': token,
|
||||||
|
'app': {
|
||||||
|
'name': 'MusicPlayer',
|
||||||
|
'build': '4'
|
||||||
|
// 'icon': 'https://music.janishutz.com/logo.jpg'
|
||||||
|
},
|
||||||
|
'storefrontId': storefront.toUpperCase()
|
||||||
|
} );
|
||||||
|
const controls = musicKitPlayback( instance );
|
||||||
|
|
||||||
|
const login = async (): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
await instance.authorize();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
'authorized': controls.loggedIn,
|
||||||
|
'play': controls.play,
|
||||||
|
'pause': controls.pause,
|
||||||
|
'stop': controls.stop,
|
||||||
|
'playSong': controls.playSong,
|
||||||
|
'getDuration': controls.getDuration,
|
||||||
|
'getPlaybackPos': controls.getPlaybackPos,
|
||||||
|
'id': 'applemusic',
|
||||||
|
'name': 'Apple Music',
|
||||||
|
'seekTo': controls.seekTo,
|
||||||
|
'login': login,
|
||||||
|
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
|
||||||
|
'loading': {
|
||||||
|
'requiresLocalFiles': false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
throw new Error( 'ERR_AUTH' );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if ( !window.MusicKit ) {
|
||||||
|
document.addEventListener( 'musickitloaded', () => {
|
||||||
|
init( storefront )
|
||||||
|
.then( resolve )
|
||||||
|
.catch( reject );
|
||||||
|
} );
|
||||||
|
} else {
|
||||||
|
init( storefront )
|
||||||
|
.then( resolve )
|
||||||
|
.catch( reject );
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user