mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
Compare commits
22
Commits
v3
...
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 |
@@ -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,46 +0,0 @@
|
||||
{
|
||||
"name": "musicplayerv2-gui",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build --force",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esbuild-plugins/node-modules-polyfill": "^0.2.2",
|
||||
"@janishutz/login-sdk-browser": "^1.1.0",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15",
|
||||
"@melloware/coloris": "^0.24.0",
|
||||
"@rollup/plugin-inject": "^5.0.5",
|
||||
"buffer": "^6.0.3",
|
||||
"colorthief": "^2.6.0",
|
||||
"music-metadata": "^11.9.0",
|
||||
"musickit-typescript": "^1.2.4",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.5",
|
||||
"vite-plugin-node-polyfills": "^0.24.0",
|
||||
"vue": "^3.4.15",
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.36.0",
|
||||
"@stylistic/eslint-plugin": "^5.4.0",
|
||||
"@tsconfig/node20": "^20.1.2",
|
||||
"@types/jquery": "^3.5.33",
|
||||
"@types/node": "^20.11.10",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"eslint-plugin-vue": "^10.5.0",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"sass-embedded": "^1.92.0",
|
||||
"typescript": "~5.3.0",
|
||||
"typescript-eslint": "^8.44.1",
|
||||
"vite": "^7.1.4",
|
||||
"vue-tsc": "^2.0.29"
|
||||
}
|
||||
}
|
||||
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,44 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Library</h1>
|
||||
<playlistsView
|
||||
:playlists="$props.playlists"
|
||||
:is-logged-in="$props.isLoggedIn"
|
||||
@selected-playlist="( id ) => selectPlaylist( id )"
|
||||
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import playlistsView from '@/components/playlistsView.vue';
|
||||
import type {
|
||||
ReadFile
|
||||
} from '@/scripts/song';
|
||||
|
||||
const emits = defineEmits( [
|
||||
'selected-playlist',
|
||||
'custom-playlist'
|
||||
] );
|
||||
|
||||
const selectPlaylist = ( id: string ) => {
|
||||
emits( 'selected-playlist', id );
|
||||
};
|
||||
|
||||
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
|
||||
emits( 'custom-playlist', playlist );
|
||||
};
|
||||
|
||||
defineProps( {
|
||||
'playlists': {
|
||||
'default': [],
|
||||
'type': Array<any>,
|
||||
'required': true,
|
||||
},
|
||||
'isLoggedIn': {
|
||||
'default': false,
|
||||
'type': Boolean,
|
||||
'required': true,
|
||||
}
|
||||
} );
|
||||
</script>
|
||||
@@ -1,416 +0,0 @@
|
||||
<!-- eslint-disable no-undef -->
|
||||
<template>
|
||||
<div id="notifications">
|
||||
<div class="message-box" :class="[ location, size ]" :style="'z-index: ' + ( messageType === 'hide' ? '-1' : '1000' )">
|
||||
<div class="message-container" :class="messageType">
|
||||
<button class="close-notification" @click="handleNotifications();">
|
||||
<span class="material-symbols-outlined close-notification-icon">close</span>
|
||||
</button>
|
||||
<span v-if="messageType == 'hide'" class="material-symbols-outlined types hide">question_mark</span>
|
||||
<span v-else-if="messageType == 'ok'" class="material-symbols-outlined types" style="background-color: green;">done</span>
|
||||
<span v-else-if="messageType == 'error'" class="material-symbols-outlined types" style="background-color: red;">close</span>
|
||||
<span v-else-if="messageType == 'progress'" class="material-symbols-outlined types progress-spinner" style="background-color: blue;">progress_activity</span>
|
||||
<span v-else-if="messageType == 'info'" class="material-symbols-outlined types" style="background-color: lightblue;">info</span>
|
||||
<span v-else-if="messageType == 'warning'" class="material-symbols-outlined types" style="background-color: orangered;">warning</span>
|
||||
<p class="message" @click="notificationAction()">
|
||||
{{ notifications[ currentDID ] ? notifications[ currentDID ].message : '' }}
|
||||
</p>
|
||||
<div :class="'countdown countdown-' + messageType" :style="'width: ' + ( 100 - ( currentTime - notificationDisplayStartTime ) / ( notifications[ currentDID ] ? notifications[ currentDID ].showDuration : 1 ) / 10 ) + '%'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import router from '@/router';
|
||||
import {
|
||||
type Ref, onUnmounted, ref
|
||||
} from 'vue';
|
||||
|
||||
defineProps( {
|
||||
'location': {
|
||||
'type': String,
|
||||
'default': 'topleft',
|
||||
},
|
||||
'size': {
|
||||
'type': String,
|
||||
'default': 'default',
|
||||
}
|
||||
// Size options: small, default (default option), big, bigger, huge
|
||||
} );
|
||||
|
||||
interface Notification {
|
||||
'message': string;
|
||||
'showDuration': number;
|
||||
'messageType': string;
|
||||
'priority': string;
|
||||
'id': number;
|
||||
'redirect'?: string;
|
||||
'openInNewTab'?: boolean;
|
||||
}
|
||||
|
||||
interface NotificationList {
|
||||
[ key: string ]: Notification
|
||||
}
|
||||
|
||||
const notifications: Ref<NotificationList> = ref( {} );
|
||||
const queue: Ref<number[]> = ref( [] );
|
||||
const currentDID: Ref<number> = ref( 0 );
|
||||
const messageType: Ref<string> = ref( 'hide' );
|
||||
const currentID = ref( {
|
||||
'critical': 0,
|
||||
'medium': 1000,
|
||||
'low': 10000
|
||||
} );
|
||||
const notificationDisplayStartTime: Ref<number> = ref( 0 );
|
||||
const currentTime: Ref<number> = ref( 0 );
|
||||
|
||||
let progressBar = 0;
|
||||
let notificationTimeout = 0;
|
||||
|
||||
const notificationAction = () => {
|
||||
if ( notifications.value[ currentDID.value ] ) {
|
||||
if ( notifications.value[ currentDID.value ].redirect ) {
|
||||
if ( notifications.value[ currentDID.value ].openInNewTab ) {
|
||||
window.open( notifications.value[ currentDID.value ].redirect ?? '' );
|
||||
} else {
|
||||
router.push( notifications.value[ currentDID.value ].redirect ?? '' );
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a notification that will be displayed using the internal notification scheduler
|
||||
* @param {string} message The message to show. Can only be plain text (no HTML)
|
||||
* @param {number} showDuration The duration in seconds for which to show the notification
|
||||
* @param {string} msgType Type of notification to show. Will dictate how it looks: 'ok', 'error', 'info', 'warn', 'progress'
|
||||
* @param {string} priority The priority of the message: 'low', 'normal', 'critical'
|
||||
* @returns {number}
|
||||
*/
|
||||
const createNotification = (
|
||||
message: string, showDuration: number, msgType: string, priority: string, redirect?: string, openInNewTab?: boolean
|
||||
): number => {
|
||||
/*
|
||||
Takes a notification options array that contains: message, showDuration (in seconds), msgType (ok, error, progress, info) and priority (low, normal, critical).
|
||||
Returns a notification ID which can be used to cancel the notification. The component will throttle notifications and display
|
||||
one at a time and prioritize messages with higher priority. Use vue refs to access these methods.
|
||||
*/
|
||||
let id = 0;
|
||||
|
||||
if ( priority === 'critical' ) {
|
||||
currentID.value[ 'critical' ] += 1;
|
||||
id = currentID.value[ 'critical' ];
|
||||
} else if ( priority === 'normal' ) {
|
||||
currentID.value[ 'medium' ] += 1;
|
||||
id = currentID.value[ 'medium' ];
|
||||
} else if ( priority === 'low' ) {
|
||||
currentID.value[ 'low' ] += 1;
|
||||
id = currentID.value[ 'low' ];
|
||||
}
|
||||
|
||||
notifications.value[ id ] = {
|
||||
'message': message,
|
||||
'showDuration': showDuration,
|
||||
'messageType': msgType,
|
||||
'priority': priority,
|
||||
'id': id,
|
||||
'redirect': redirect,
|
||||
'openInNewTab': openInNewTab
|
||||
};
|
||||
queue.value.push( id );
|
||||
console.log( 'scheduled notification: ' + id + ' (' + message + ')' );
|
||||
|
||||
if ( ( new Date().getTime() - notificationDisplayStartTime.value ) / 1000 >= ( notifications.value[ currentDID.value ] ? notifications.value[ currentDID.value ].showDuration : 0 ) || messageType.value === 'hide' ) {
|
||||
handleNotifications();
|
||||
}
|
||||
|
||||
return id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a notification's message after creating it
|
||||
* @param {number} id The notification ID returned by createNotification
|
||||
* @param {string} message The new message
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateNotification = ( id: number, message: string ): void => {
|
||||
if ( notifications.value[ id ] ) {
|
||||
notifications.value[ id ].message = message;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Delete a previously created notification
|
||||
* @param {string} id The notification ID returned by createNotification
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const cancelNotification = ( id: number ): undefined => {
|
||||
try {
|
||||
delete notifications.value[ id ];
|
||||
} catch ( error ) {
|
||||
console.log( 'notification to be deleted is nonexistent or currently being displayed' );
|
||||
}
|
||||
|
||||
try {
|
||||
queue.value.splice( queue.value.indexOf( id ), 1 );
|
||||
} catch {
|
||||
console.debug( 'queue empty' );
|
||||
}
|
||||
|
||||
if ( currentDID.value == id ) {
|
||||
try {
|
||||
clearTimeout( notificationTimeout );
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
handleNotifications();
|
||||
}
|
||||
};
|
||||
|
||||
const handleNotifications = () => {
|
||||
notificationDisplayStartTime.value = new Date().getTime();
|
||||
queue.value.sort();
|
||||
|
||||
if ( queue.value.length > 0 ) {
|
||||
if ( currentDID.value !== 0 ) {
|
||||
delete notifications.value[ currentDID.value ];
|
||||
}
|
||||
|
||||
currentDID.value = notifications.value[ queue.value[ 0 ] ][ 'id' ];
|
||||
messageType.value = notifications.value[ queue.value[ 0 ] ].messageType;
|
||||
queue.value.reverse();
|
||||
queue.value.pop();
|
||||
progressBar = setInterval( progressBarHandler, 25 );
|
||||
notificationTimeout = setTimeout( () => {
|
||||
handleNotifications();
|
||||
}, notifications.value[ currentDID.value ].showDuration * 1000 );
|
||||
} else {
|
||||
try {
|
||||
clearInterval( progressBar );
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
messageType.value = 'hide';
|
||||
}
|
||||
};
|
||||
|
||||
const progressBarHandler = () => {
|
||||
currentTime.value = new Date().getTime();
|
||||
};
|
||||
|
||||
onUnmounted( () => {
|
||||
try {
|
||||
clearInterval( progressBar );
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
try {
|
||||
clearInterval( notificationTimeout );
|
||||
} catch ( err ) { /* empty */ }
|
||||
} );
|
||||
|
||||
defineExpose( {
|
||||
createNotification,
|
||||
cancelNotification,
|
||||
updateNotification
|
||||
} );
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.message-box {
|
||||
position: fixed;
|
||||
z-index: -100;
|
||||
color: white;
|
||||
transition: all 0.5s;
|
||||
width: 95vw;
|
||||
right: 2.5vw;
|
||||
top: 1vh;
|
||||
height: 10vh;
|
||||
}
|
||||
|
||||
.close-notification {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
background: none;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-notification-icon {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.countdown {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 5px;
|
||||
}
|
||||
|
||||
.message-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
opacity: 1;
|
||||
transition: all 0.5s;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.types {
|
||||
color: white;
|
||||
border-radius: 100%;
|
||||
margin-right: auto;
|
||||
margin-left: 5%;
|
||||
padding: 1.5%;
|
||||
font-size: 200%;
|
||||
}
|
||||
|
||||
.message {
|
||||
margin-right: calc( 5% + 30px );
|
||||
text-align: end;
|
||||
height: 90%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ok {
|
||||
background-color: rgb(1, 71, 1);
|
||||
}
|
||||
|
||||
.countdown-ok {
|
||||
background-color: green;
|
||||
}
|
||||
|
||||
.error {
|
||||
background-color: rgb(114, 1, 1);
|
||||
}
|
||||
|
||||
.countdown-error {
|
||||
background-color: red;
|
||||
}
|
||||
|
||||
.info {
|
||||
background-color: rgb(44, 112, 151);
|
||||
}
|
||||
|
||||
.countdown-info {
|
||||
background-color: blue;
|
||||
}
|
||||
|
||||
.warning {
|
||||
background-color: orange;
|
||||
}
|
||||
|
||||
.countdown-warning {
|
||||
background-color: orangered;
|
||||
}
|
||||
|
||||
.hide {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
z-index: 100;
|
||||
background-color: rgb(0, 0, 99);
|
||||
}
|
||||
|
||||
.countdown-ok {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.progress-spinner {
|
||||
animation: spin 2s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate( 0deg );
|
||||
}
|
||||
to {
|
||||
transform: rotate( 720deg );
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (min-width: 750px) {
|
||||
|
||||
.default {
|
||||
height: 10vh;
|
||||
width: 32vw;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 7vh;
|
||||
width: 27vw;
|
||||
}
|
||||
|
||||
.big {
|
||||
height: 12vh;
|
||||
width: 38vw;
|
||||
}
|
||||
|
||||
.bigger {
|
||||
height: 15vh;
|
||||
width: 43vw;
|
||||
}
|
||||
|
||||
.huge {
|
||||
height: 20vh;
|
||||
width: 50vw;
|
||||
}
|
||||
|
||||
.topleft {
|
||||
top: 3vh;
|
||||
left: 0.5vw;
|
||||
}
|
||||
|
||||
.topright {
|
||||
top: 3vh;
|
||||
right: 0.5vw;
|
||||
}
|
||||
|
||||
.bottomright {
|
||||
bottom: 3vh;
|
||||
right: 0.5vw;
|
||||
}
|
||||
|
||||
.bottomleft {
|
||||
bottom: 3vh;
|
||||
right: 0.5vw;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (min-width: 1500px) {
|
||||
.default {
|
||||
height: 10vh;
|
||||
width: 15vw;
|
||||
}
|
||||
|
||||
.small {
|
||||
height: 7vh;
|
||||
width: 11vw;
|
||||
}
|
||||
|
||||
.big {
|
||||
height: 12vh;
|
||||
width: 17vw;
|
||||
}
|
||||
|
||||
.bigger {
|
||||
height: 15vh;
|
||||
width: 20vw;
|
||||
}
|
||||
|
||||
.huge {
|
||||
height: 20vh;
|
||||
width: 25vw;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,473 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<h1>Queue</h1>
|
||||
<input
|
||||
id="more-songs"
|
||||
type="file"
|
||||
multiple
|
||||
accept="audio/*"
|
||||
class="small-buttons"
|
||||
>
|
||||
<button class="small-buttons" title="Load selected files" @click="addNewSongs()">
|
||||
<span class="material-symbols-outlined">upload</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="$props.isLoggedIntoAppleMusic"
|
||||
class="small-buttons"
|
||||
title="Search Apple Music for the song"
|
||||
@click="openSearch()"
|
||||
>
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
</button>
|
||||
<button class="small-buttons" title="Clear the playlist" @click="clearPlaylist()">
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
<button title="Transmit additional information" class="small-buttons" @click="sendAdditionalInfo()">
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
<p v-if="!hasSelectedSongs">
|
||||
Please select at least one song to proceed
|
||||
</p>
|
||||
<div id="pl-box" class="playlist-box">
|
||||
<!-- TODO: Allow editing additionalInfo. Think also how to make it persist over reloads... Export to JSON and then best-guess add them? Very easy for Apple Music 'cause ID, but how for local songs? Maybe using retrieved ID from Apple Music? -->
|
||||
<!-- TODO: Handle long AppleMusic Playlists, as AppleMusic doesn't automatically load all songs of a playlist -->
|
||||
<div
|
||||
v-for="song in computedPlaylist"
|
||||
:key="song.id"
|
||||
class="song"
|
||||
:class="( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && isPlaying ? 'playing' : ' not-playing' )
|
||||
+ ( ( !isPlaying && ( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) ) ) ? ' active-song' : '' )"
|
||||
>
|
||||
<img
|
||||
v-if="song.cover"
|
||||
:src="song.cover"
|
||||
alt="Song cover"
|
||||
class="song-cover"
|
||||
>
|
||||
<span v-else class="material-symbols-outlined song-cover">music_note</span>
|
||||
<div v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && $props.isPlaying" class="playing-symbols">
|
||||
<div class="playing-symbols-wrapper">
|
||||
<div id="bar-1" class="playing-bar"></div>
|
||||
<div id="bar-2" class="playing-bar"></div>
|
||||
<div id="bar-3" class="playing-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' )" class="material-symbols-outlined play-icon" @click="control( 'play' )">play_arrow</span>
|
||||
<span v-else class="material-symbols-outlined play-icon" @click="play( song.id )">play_arrow</span>
|
||||
<span class="material-symbols-outlined pause-icon" @click="control( 'pause' )">pause</span>
|
||||
<span
|
||||
v-if="canBeMoved( 'up', song.id )"
|
||||
class="material-symbols-outlined move-icon"
|
||||
title="Move song up"
|
||||
@click="moveSong( song.id, 'up' )"
|
||||
>arrow_upward</span>
|
||||
<span
|
||||
v-if="canBeMoved( 'down', song.id )"
|
||||
class="material-symbols-outlined move-icon"
|
||||
title="Move song down"
|
||||
@click="moveSong( song.id, 'down' )"
|
||||
>arrow_downward</span>
|
||||
<h3 class="song-title">
|
||||
{{ song.title }}
|
||||
</h3>
|
||||
<div>
|
||||
<input
|
||||
v-model="song.additionalInfo"
|
||||
type="text"
|
||||
placeholder="Additional information for remote display"
|
||||
title="Additional information for remote display"
|
||||
@focusin="kbControl( 'on' )"
|
||||
@focusout="kbControl( 'off' )"
|
||||
>
|
||||
<p class="playing-in">
|
||||
{{ getTimeUntil( song ) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
v-if="canBeMoved( 'down', song.id ) || canBeMoved( 'up', song.id )"
|
||||
class="small-buttons"
|
||||
title="Remove this song from the queue"
|
||||
@click="deleteSong( song.id )"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<searchView ref="search" @selected-song="( song ) => { addNewSongsAppleMusic( song ) }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Add logout button
|
||||
import type {
|
||||
AppleMusicSongData, ReadFile, Song
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
computed, ref
|
||||
} from 'vue';
|
||||
import searchView from './searchView.vue';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/stores/userStore';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const search = ref( searchView );
|
||||
const props = defineProps( {
|
||||
'playlist': {
|
||||
'default': [],
|
||||
'required': true,
|
||||
'type': Array<Song>
|
||||
},
|
||||
'currentlyPlaying': {
|
||||
'default': 0,
|
||||
'required': true,
|
||||
'type': Number,
|
||||
},
|
||||
'isPlaying': {
|
||||
'default': true,
|
||||
'required': true,
|
||||
'type': Boolean,
|
||||
},
|
||||
'pos': {
|
||||
'default': 0,
|
||||
'required': false,
|
||||
'type': Number,
|
||||
},
|
||||
'isLoggedIntoAppleMusic': {
|
||||
'default': false,
|
||||
'required': true,
|
||||
'type': Boolean,
|
||||
}
|
||||
} );
|
||||
const hasSelectedSongs = ref( true );
|
||||
const computedPlaylist = computed( () => {
|
||||
let pl: Song[] = [];
|
||||
|
||||
// ( document.getElementById( 'pl-box' ) as HTMLDivElement ).scrollTo( { behavior: 'smooth', top: 0 } );
|
||||
for ( let i = props.currentlyPlaying; i < props.playlist.length; i++ ) {
|
||||
pl.push( props.playlist[ i ] );
|
||||
}
|
||||
|
||||
return pl;
|
||||
} );
|
||||
|
||||
const kbControl = ( action: string ) => {
|
||||
if ( action === 'off' ) {
|
||||
userStore.setKeyboardUsageStatus( false );
|
||||
} else {
|
||||
userStore.setKeyboardUsageStatus( true );
|
||||
}
|
||||
};
|
||||
|
||||
const openSearch = () => {
|
||||
if ( search.value ) {
|
||||
search.value.controlSearch( 'show' );
|
||||
}
|
||||
};
|
||||
|
||||
const canBeMoved = computed( () => {
|
||||
return ( direction: movementDirection, songID: string ): boolean => {
|
||||
let id = 0;
|
||||
|
||||
for ( let song in props.playlist ) {
|
||||
if ( props.playlist[ song ].id === songID ) {
|
||||
id = parseInt( song );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( direction === 'up' ) {
|
||||
if ( props.currentlyPlaying + 1 === id || props.currentlyPlaying === id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
if ( id === props.playlist.length - 1 || props.currentlyPlaying === id ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
};
|
||||
} );
|
||||
const getTimeUntil = computed( () => {
|
||||
return ( song: Song ) => {
|
||||
let timeRemaining = 0;
|
||||
|
||||
for ( let i = props.currentlyPlaying; i < Object.keys( props.playlist ).length; i++ ) {
|
||||
if ( props.playlist[ i ] == song ) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeRemaining += props.playlist[ i ].duration;
|
||||
}
|
||||
|
||||
if ( props.isPlaying ) {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Currently playing';
|
||||
} else {
|
||||
return 'Playing in less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min';
|
||||
}
|
||||
} else {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Plays next';
|
||||
} else {
|
||||
return 'Playing less than ' + Math.ceil( timeRemaining / 60 - props.pos / 60 ) + 'min after starting to play';
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
const deleteSong = ( songID: string ) => {
|
||||
for ( const song in props.playlist ) {
|
||||
if ( props.playlist[ song ].id === songID ) {
|
||||
emits( 'delete-song', parseInt( song ) );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const clearPlaylist = () => {
|
||||
emits( 'clear-playlist', '' );
|
||||
};
|
||||
|
||||
|
||||
const control = ( action: string ) => {
|
||||
emits( 'control', action );
|
||||
};
|
||||
|
||||
const play = ( song: string ) => {
|
||||
emits( 'play-song', song );
|
||||
};
|
||||
|
||||
const addNewSongs = () => {
|
||||
const fileURLList: ReadFile[] = [];
|
||||
const allFiles = ( document.getElementById( 'more-songs' ) as HTMLInputElement ).files ?? [];
|
||||
|
||||
if ( allFiles.length > 0 ) {
|
||||
hasSelectedSongs.value = true;
|
||||
|
||||
for ( let file = 0; file < allFiles.length; file++ ) {
|
||||
fileURLList.push( {
|
||||
'url': URL.createObjectURL( allFiles[ file ] ),
|
||||
'filename': allFiles[ file ].name
|
||||
} );
|
||||
}
|
||||
|
||||
emits( 'add-new-songs', fileURLList );
|
||||
} else {
|
||||
hasSelectedSongs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const addNewSongsAppleMusic = ( songData: AppleMusicSongData ) => {
|
||||
const song: Song = {
|
||||
'artist': songData.attributes.artistName,
|
||||
'cover': songData.attributes.artwork.url.replace( '{w}', String( songData.attributes.artwork.width ) ).replace( '{h}', String( songData.attributes.artwork.height ) ),
|
||||
'duration': songData.attributes.durationInMillis / 1000,
|
||||
'id': songData.id,
|
||||
'origin': 'apple-music',
|
||||
'title': songData.attributes.name
|
||||
};
|
||||
|
||||
emits( 'add-new-songs-apple-music', song );
|
||||
};
|
||||
|
||||
type movementDirection = 'up' | 'down';
|
||||
|
||||
const moveSong = ( songID: string, direction: movementDirection ) => {
|
||||
let newSongPos = 0;
|
||||
let hasFoundSongToMove = false;
|
||||
|
||||
for ( let el in props.playlist ) {
|
||||
if ( props.playlist[ el ].id === songID ) {
|
||||
const currPos = parseInt( el );
|
||||
|
||||
newSongPos = currPos + ( direction === 'up' ? -1 : 1 );
|
||||
hasFoundSongToMove = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( hasFoundSongToMove ) {
|
||||
emits( 'playlist-reorder', {
|
||||
'songID': songID,
|
||||
'newPos': newSongPos
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
const sendAdditionalInfo = () => {
|
||||
emits( 'send-additional-info' );
|
||||
};
|
||||
|
||||
const emits = defineEmits( [
|
||||
'play-song',
|
||||
'control',
|
||||
'playlist-reorder',
|
||||
'add-new-songs',
|
||||
'add-new-songs-apple-music',
|
||||
'delete-song',
|
||||
'clear-playlist',
|
||||
'send-additional-info'
|
||||
] );
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlist-box {
|
||||
height: calc( 100% - 150px );
|
||||
width: 100%;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.song {
|
||||
border: solid var( --primary-color ) 1px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
margin: 2px;
|
||||
padding: 1vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.song .song-cover {
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 6rem;
|
||||
}
|
||||
|
||||
.song img.song-cover {
|
||||
font-size: unset;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 1vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
background-color: rgba( 0, 0, 0, 0.6 );
|
||||
}
|
||||
|
||||
.playing-symbols-wrapper {
|
||||
width: 5rem;
|
||||
height: 6rem;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.playing-bar {
|
||||
height: 60%;
|
||||
background-color: white;
|
||||
width: 10%;
|
||||
border-radius: 50px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#bar-1 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#bar-2 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
#bar-3 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes music-playing {
|
||||
0% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
50% {
|
||||
transform: scaleY( 0.5 );
|
||||
}
|
||||
100% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
}
|
||||
|
||||
.play-icon, .pause-icon {
|
||||
display: none;
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 6rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.playing:hover .pause-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.playing:hover .playing-symbols {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.song:hover .song-cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.not-playing:hover .play-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.active-song .pause-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.active-song.not-playing .song-cover {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.active-song .song-image, .active-song:hover .pause-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.move-icon {
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.small-buttons {
|
||||
margin-bottom: 10px;
|
||||
font-size: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.small-buttons .material-symbols-outlined {
|
||||
font-size: 1.5rem;
|
||||
color: var( --primary-color );
|
||||
transition: all 0.5s;
|
||||
}
|
||||
|
||||
.small-buttons:hover .material-symbols-outlined {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<div class="playlists">
|
||||
<h3 style="width: fit-content;">
|
||||
Your playlists
|
||||
</h3>
|
||||
<div v-if="( $props.playlists ? $props.playlists.length < 1 : true ) && $props.isLoggedIn">
|
||||
Loading...
|
||||
<!-- TODO: Make prettier -->
|
||||
</div>
|
||||
<div v-else-if="!$props.isLoggedIn" class="not-logged-in">
|
||||
<p>
|
||||
You are not logged into Apple Music. We therefore can't show you your playlists.
|
||||
<a href="" title="Refreshes the page, allowing you to log in">Change that</a>
|
||||
</p>
|
||||
<p>Use the button below to load songs from your local disk</p>
|
||||
<input
|
||||
id="pl-loader"
|
||||
class="pl-loader-button"
|
||||
type="file"
|
||||
multiple="true"
|
||||
accept="audio/*"
|
||||
><br>
|
||||
<button id="load-button" class="pl-loader-button" @click="loadPlaylistFromDisk()">
|
||||
Load
|
||||
</button>
|
||||
<p v-if="!hasSelectedSongs">
|
||||
Please select at least one song to proceed!
|
||||
</p>
|
||||
</div>
|
||||
<div class="playlist-wrapper">
|
||||
<div
|
||||
v-for="pl in $props.playlists"
|
||||
:key="pl.id"
|
||||
class="playlist"
|
||||
@click="selectPlaylist( pl.id )"
|
||||
>
|
||||
{{ pl.attributes.name }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
ReadFile
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const hasSelectedSongs = ref( true );
|
||||
|
||||
defineProps( {
|
||||
'playlists': {
|
||||
'default': [],
|
||||
'type': Array<any>,
|
||||
'required': true,
|
||||
},
|
||||
'isLoggedIn': {
|
||||
'default': false,
|
||||
'type': Boolean,
|
||||
'required': true,
|
||||
}
|
||||
} );
|
||||
|
||||
const loadPlaylistFromDisk = () => {
|
||||
const fileURLList: ReadFile[] = [];
|
||||
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
|
||||
|
||||
if ( allFiles.length > 0 ) {
|
||||
hasSelectedSongs.value = true;
|
||||
|
||||
for ( let file = 0; file < allFiles.length; file++ ) {
|
||||
fileURLList.push( {
|
||||
'url': URL.createObjectURL( allFiles[ file ] ),
|
||||
'filename': allFiles[ file ].name
|
||||
} );
|
||||
}
|
||||
|
||||
emits( 'custom-playlist', fileURLList );
|
||||
} else {
|
||||
hasSelectedSongs.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const emits = defineEmits( [
|
||||
'selected-playlist',
|
||||
'custom-playlist'
|
||||
] );
|
||||
|
||||
const selectPlaylist = ( id: string ) => {
|
||||
emits( 'selected-playlist', id );
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.playlists {
|
||||
width: 100%;
|
||||
height: 75vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playlist-wrapper {
|
||||
width: 85%;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.playlist {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
border: solid var( --primary-color ) 1px;
|
||||
border-radius: 5px;
|
||||
margin: 1px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.pl-loader-button {
|
||||
background-color: white;
|
||||
border: none;
|
||||
padding: 10px;
|
||||
border-radius: 5px;
|
||||
margin: 5px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#load-button {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.not-logged-in {
|
||||
width: 80%;
|
||||
}
|
||||
</style>
|
||||
@@ -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,26 +0,0 @@
|
||||
import App from './App.vue';
|
||||
import {
|
||||
createApp
|
||||
} from 'vue';
|
||||
import {
|
||||
createPinia
|
||||
} from 'pinia';
|
||||
import router from './router';
|
||||
import sdk from '@janishutz/login-sdk-browser';
|
||||
|
||||
|
||||
const app = createApp( App );
|
||||
|
||||
app.use( createPinia() );
|
||||
app.use( router );
|
||||
|
||||
// localStorage.setItem( 'url', 'http://localhost:8082' );
|
||||
localStorage.setItem( 'url', 'https://music-api.janishutz.com' );
|
||||
sdk.setUp(
|
||||
'jh-music',
|
||||
String( localStorage.getItem( 'url' ) ),
|
||||
'/app',
|
||||
false // Set to false for deploy to actual backend
|
||||
);
|
||||
|
||||
app.mount( '#app' );
|
||||
@@ -1,96 +0,0 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
import HomeView from '@/views/HomeView.vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
|
||||
const router = createRouter( {
|
||||
history: createWebHistory( import.meta.env.BASE_URL ),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView,
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Login'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/app',
|
||||
name: 'app',
|
||||
component: () => import( '../views/AppView.vue' ),
|
||||
meta: {
|
||||
'authRequired': true,
|
||||
'title': 'App'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/get',
|
||||
name: 'get',
|
||||
component: () => import( '../views/GetView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Get'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/share/:name',
|
||||
name: 'share',
|
||||
component: () => import( '../views/RemoteView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Share'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/fancy/:name',
|
||||
name: 'fancy',
|
||||
component: () => import( '../views/ShowcaseView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Fancy View'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/tools/bar',
|
||||
name: 'tool-bar',
|
||||
component: () => import( '../views/BarView.vue' ),
|
||||
meta: {
|
||||
'authRequired': false,
|
||||
'title': 'Bar utility'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'NotFound',
|
||||
component: () => import( '../views/404View.vue' ),
|
||||
meta: {
|
||||
title: '404 :: Page not found',
|
||||
transition: 'scale',
|
||||
}
|
||||
},
|
||||
]
|
||||
} );
|
||||
|
||||
// router.beforeResolve( ( to, _from, next ) => {
|
||||
// if ( to.name ) {
|
||||
// NProgress.start();
|
||||
// }
|
||||
// next();
|
||||
// } );
|
||||
|
||||
router.beforeEach( ( to ) => {
|
||||
const userStore = useUserStore();
|
||||
const isUserAuthenticated = userStore.getUserAuthenticated;
|
||||
if ( !isUserAuthenticated && to.meta.authRequired ) {
|
||||
localStorage.setItem( 'redirect', to.fullPath );
|
||||
return { name: 'home' };
|
||||
}
|
||||
} );
|
||||
|
||||
router.afterEach( ( to ) => {
|
||||
window.scrollTo( { top: 0, behavior: 'smooth' } );
|
||||
document.title = to.meta.title ? to.meta.title + ' - MusicPlayer' : 'MusicPlayer';
|
||||
// NProgress.done();
|
||||
} );
|
||||
|
||||
export default router;
|
||||
@@ -1,161 +0,0 @@
|
||||
import ColorThief from 'colorthief';
|
||||
|
||||
const colorThief = new ColorThief();
|
||||
|
||||
const getImageData = (): Promise<number[][]> => {
|
||||
return new Promise( resolve => {
|
||||
const img = document.getElementById( 'current-image' ) as HTMLImageElement;
|
||||
|
||||
if ( img.complete ) {
|
||||
resolve( colorThief.getPalette( img ) );
|
||||
} else {
|
||||
img.addEventListener( 'load', () => {
|
||||
resolve( colorThief.getPalette( img ) );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
};
|
||||
|
||||
const createBackground = () => {
|
||||
return new Promise( resolve => {
|
||||
getImageData().then( palette => {
|
||||
const colourDetails: number[][] = [];
|
||||
const colours: string[] = [];
|
||||
|
||||
let differentEnough = true;
|
||||
|
||||
if ( palette[ 0 ] ) {
|
||||
for ( const i in palette ) {
|
||||
for ( const colour in colourDetails ) {
|
||||
const colourDiff = ( Math.abs( colourDetails[ colour ][ 0 ] - palette[ i ][ 0 ] ) / 255
|
||||
+ Math.abs( colourDetails[ colour ][ 1 ] - palette[ i ][ 1 ] ) / 255
|
||||
+ Math.abs( colourDetails[ colour ][ 2 ] - palette[ i ][ 2 ] ) / 255 ) / 3 * 100;
|
||||
|
||||
if ( colourDiff > 15 ) {
|
||||
differentEnough = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( differentEnough ) {
|
||||
colourDetails.push( palette[ i ] );
|
||||
colours.push( 'rgb(' + palette[ i ][ 0 ] + ',' + palette[ i ][ 1 ] + ',' + palette[ i ][ 2 ] + ')' );
|
||||
}
|
||||
|
||||
differentEnough = false;
|
||||
}
|
||||
}
|
||||
|
||||
let outColours = 'conic-gradient(';
|
||||
|
||||
if ( colours.length < 3 ) {
|
||||
for ( let i = 0; i < 3; i++ ) {
|
||||
if ( colours[ i ] ) {
|
||||
outColours += colours[ i ] + ',';
|
||||
} else {
|
||||
if ( i === 0 ) {
|
||||
outColours += 'blue,';
|
||||
} else if ( i === 1 ) {
|
||||
outColours += 'green,';
|
||||
} else if ( i === 2 ) {
|
||||
outColours += 'red,';
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ( colours.length < 11 ) {
|
||||
for ( const i in colours ) {
|
||||
outColours += colours[ i ] + ',';
|
||||
}
|
||||
} else {
|
||||
for ( let i = 0; i < 10; i++ ) {
|
||||
outColours += colours[ i ] + ',';
|
||||
}
|
||||
}
|
||||
|
||||
outColours += colours[ 0 ] ?? 'blue' + ')';
|
||||
resolve( outColours );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
let callbackFun = () => {};
|
||||
|
||||
const subscribeToBeatUpdate = ( cb: () => void ) => {
|
||||
callbackFun = cb;
|
||||
micAudioHandler();
|
||||
};
|
||||
|
||||
const unsubscribeFromBeatUpdate = () => {
|
||||
callbackFun = () => {};
|
||||
|
||||
try {
|
||||
clearInterval( micAnalyzer );
|
||||
} catch ( e ) { /* empty */ }
|
||||
};
|
||||
|
||||
const coolDown = () => {
|
||||
beatDetected = false;
|
||||
};
|
||||
|
||||
let micAnalyzer = 0;
|
||||
let beatDetected = false;
|
||||
|
||||
const micAudioHandler = () => {
|
||||
const audioContext = new ( window.AudioContext || window.webkitAudioContext )();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
|
||||
analyser.fftSize = 256;
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array( bufferLength );
|
||||
|
||||
beatDetected = false;
|
||||
|
||||
navigator.mediaDevices.getUserMedia( {
|
||||
'audio': true
|
||||
} ).then( stream => {
|
||||
const mic = audioContext.createMediaStreamSource( stream );
|
||||
|
||||
mic.connect( analyser );
|
||||
analyser.getByteFrequencyData( dataArray );
|
||||
let prevSpectrum: number[] = [];
|
||||
|
||||
const threshold = 10; // Adjust as needed
|
||||
|
||||
micAnalyzer = setInterval( () => {
|
||||
analyser.getByteFrequencyData( dataArray );
|
||||
// Convert the frequency data to a numeric array
|
||||
const currentSpectrum = Array.from( dataArray );
|
||||
|
||||
if ( prevSpectrum ) {
|
||||
// Calculate the spectral flux
|
||||
const flux = calculateSpectralFlux( prevSpectrum, currentSpectrum );
|
||||
|
||||
if ( flux > threshold && !beatDetected ) {
|
||||
// Beat detected
|
||||
beatDetected = true;
|
||||
callbackFun();
|
||||
}
|
||||
}
|
||||
|
||||
prevSpectrum = currentSpectrum;
|
||||
}, 60 / 180 * 250 );
|
||||
} );
|
||||
};
|
||||
|
||||
const calculateSpectralFlux = ( prevSpectrum: number[], currentSpectrum: number[] ) => {
|
||||
let flux = 0;
|
||||
|
||||
for ( let i = 0; i < prevSpectrum.length; i++ ) {
|
||||
const diff = currentSpectrum[ i ] - prevSpectrum[ i ];
|
||||
|
||||
flux += Math.max( 0, diff );
|
||||
}
|
||||
|
||||
return flux;
|
||||
};
|
||||
|
||||
export default {
|
||||
createBackground,
|
||||
subscribeToBeatUpdate,
|
||||
unsubscribeFromBeatUpdate,
|
||||
coolDown,
|
||||
};
|
||||
@@ -1,212 +0,0 @@
|
||||
// These functions handle connections to the backend with socket.io
|
||||
|
||||
import {
|
||||
type Socket, io
|
||||
} from 'socket.io-client';
|
||||
import type {
|
||||
SSEMap
|
||||
} from './song';
|
||||
|
||||
class SocketConnection {
|
||||
|
||||
socket: Socket;
|
||||
|
||||
roomName: string;
|
||||
|
||||
isConnected: boolean;
|
||||
|
||||
useSocket: boolean;
|
||||
|
||||
eventSource?: EventSource;
|
||||
|
||||
toBeListenedForItems: SSEMap;
|
||||
|
||||
reconnectRetryCount: number;
|
||||
|
||||
openConnectionsCount: number;
|
||||
|
||||
constructor () {
|
||||
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
|
||||
'autoConnect': false,
|
||||
} );
|
||||
this.roomName = location.pathname.split( '/' )[ 2 ];
|
||||
this.isConnected = false;
|
||||
this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws';
|
||||
this.toBeListenedForItems = {};
|
||||
this.reconnectRetryCount = 0;
|
||||
this.openConnectionsCount = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room token and connect to
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
connect (): Promise<unknown> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
if ( this.reconnectRetryCount < 5 ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.connect();
|
||||
this.socket.emit(
|
||||
'join-room', this.roomName, ( res: {
|
||||
'status': boolean,
|
||||
'msg': string,
|
||||
'data': unknown
|
||||
} ) => {
|
||||
if ( res.status === true ) {
|
||||
this.isConnected = true;
|
||||
resolve( res.data );
|
||||
} else {
|
||||
console.debug( res.msg );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
if ( this.openConnectionsCount < 1 && !this.isConnected ) {
|
||||
this.openConnectionsCount += 1;
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
this.eventSource
|
||||
= new EventSource( localStorage.getItem( 'url' )
|
||||
+ '/socket/connection?room=' + this.roomName, {
|
||||
'withCredentials': true
|
||||
} );
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectRetryCount = 0;
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString() + ': Connection successfully established!' );
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = e => {
|
||||
const d = JSON.parse( e.data );
|
||||
|
||||
if ( this.toBeListenedForItems[ d.type ] ) {
|
||||
this.toBeListenedForItems[ d.type ]( d.data );
|
||||
} else if ( d.type === 'basics' ) {
|
||||
resolve( d.data );
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = () => {
|
||||
if ( this.isConnected ) {
|
||||
this.isConnected = false;
|
||||
this.openConnectionsCount -= 1;
|
||||
this.eventSource?.close();
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Reconnecting due to connection error!' );
|
||||
// console.debug( e );
|
||||
|
||||
this.eventSource = undefined;
|
||||
|
||||
this.reconnectRetryCount += 1;
|
||||
setTimeout( () => {
|
||||
this.connect();
|
||||
}, 1000 * this.reconnectRetryCount );
|
||||
}
|
||||
};
|
||||
} else {
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Could not connect due to error ' + res.status );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
} )
|
||||
.catch( () => {
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Could not connect due to error.' );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
} );
|
||||
} else {
|
||||
console.log( '[ SSE Connection ]: Trimmed connections' );
|
||||
reject( 'ERR_TOO_MANY_CONNECTIONS' );
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert( 'Could not reconnect to the share. Please reload the page to retry!' );
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
* @param {string} event The event to emit
|
||||
* @param {any} data
|
||||
* @returns {void}
|
||||
*/
|
||||
emit ( event: string, data: unknown ): void {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.emit( event, {
|
||||
'roomName': this.roomName,
|
||||
'data': data
|
||||
} );
|
||||
} else {
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
|
||||
'method': 'post',
|
||||
'body': JSON.stringify( {
|
||||
'event': event,
|
||||
'roomName': this.roomName,
|
||||
'data': data
|
||||
} ),
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8'
|
||||
}
|
||||
} ).catch( () => {} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener function for an event
|
||||
* @param {string} event The event to listen for
|
||||
* @param {( data: any ) => void} cb The callback function / listener function
|
||||
* @returns {void}
|
||||
*/
|
||||
registerListener ( event: string, cb: ( data: unknown ) => void ): void {
|
||||
if ( this.useSocket ) {
|
||||
if ( this.isConnected ) {
|
||||
this.socket.on( event, cb );
|
||||
}
|
||||
} else {
|
||||
this.toBeListenedForItems[ event ] = cb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
* @returns {any}
|
||||
*/
|
||||
disconnect (): void {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.disconnect();
|
||||
} else {
|
||||
this.eventSource!.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getStatus (): boolean {
|
||||
if ( this.useSocket ) {
|
||||
return true;
|
||||
} else {
|
||||
if ( this.eventSource ) {
|
||||
return this.eventSource!.OPEN && this.isConnected;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SocketConnection;
|
||||
@@ -1,571 +0,0 @@
|
||||
import type {
|
||||
SearchResult, Song, SongMove
|
||||
} from './song';
|
||||
|
||||
interface Config {
|
||||
'devToken': string;
|
||||
'userToken': string;
|
||||
}
|
||||
|
||||
type ControlAction = 'play' | 'pause' | 'next' | 'previous' | 'skip-10' | 'back-10';
|
||||
type RepeatMode = 'off' | 'once' | 'all';
|
||||
|
||||
class MusicKitJSWrapper {
|
||||
|
||||
playingSongID: number;
|
||||
|
||||
playlist: Song[];
|
||||
|
||||
queue: number[];
|
||||
|
||||
config: Config;
|
||||
|
||||
musicKit: any;
|
||||
|
||||
isLoggedIn: boolean;
|
||||
|
||||
isPreparedToPlay: boolean;
|
||||
|
||||
repeatMode: RepeatMode;
|
||||
|
||||
isShuffleEnabled: boolean;
|
||||
|
||||
hasEncounteredAuthError: boolean;
|
||||
|
||||
queuePos: number;
|
||||
|
||||
audioPlayer: HTMLAudioElement;
|
||||
|
||||
constructor () {
|
||||
this.playingSongID = 0;
|
||||
this.playlist = [];
|
||||
this.queue = [];
|
||||
this.config = {
|
||||
'devToken': '',
|
||||
'userToken': '',
|
||||
};
|
||||
this.isShuffleEnabled = false;
|
||||
this.repeatMode = 'off';
|
||||
this.isPreparedToPlay = false;
|
||||
this.isLoggedIn = false;
|
||||
this.hasEncounteredAuthError = false;
|
||||
this.queuePos = 0;
|
||||
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
|
||||
|
||||
const self = this;
|
||||
|
||||
if ( !window.MusicKit ) {
|
||||
document.addEventListener( 'musickitloaded', () => {
|
||||
self.init();
|
||||
} );
|
||||
} else {
|
||||
this.init();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log a user into Apple Music. Will automatically initialize MusicKitJS, once user is logged in
|
||||
* @returns {void}
|
||||
*/
|
||||
logIn (): void {
|
||||
if ( !this.musicKit.isAuthorized ) {
|
||||
this.musicKit.authorize().then( () => {
|
||||
this.isLoggedIn = true;
|
||||
this.init();
|
||||
} )
|
||||
.catch( () => {
|
||||
this.hasEncounteredAuthError = true;
|
||||
} );
|
||||
} else {
|
||||
this.musicKit.authorize().then( () => {
|
||||
this.isLoggedIn = true;
|
||||
this.init();
|
||||
} )
|
||||
.catch( () => {
|
||||
this.hasEncounteredAuthError = true;
|
||||
} );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MusicKitJS. Should not be called. Use logIn instead, which first tries to log the user in, then calls this method.
|
||||
* @returns {void}
|
||||
*/
|
||||
init (): void {
|
||||
fetch( localStorage.getItem( 'url' ) + '/getAppleMusicDevToken', {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.text().then( token => {
|
||||
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
|
||||
// MusicKit global is now defined
|
||||
MusicKit.configure( {
|
||||
'developerToken': token,
|
||||
'app': {
|
||||
'name': 'MusicPlayer',
|
||||
'build': '3'
|
||||
},
|
||||
'storefrontId': 'CH',
|
||||
} ).then( () => {
|
||||
this.config.devToken = token;
|
||||
this.musicKit = MusicKit.getInstance();
|
||||
|
||||
if ( this.musicKit.isAuthorized ) {
|
||||
this.isLoggedIn = true;
|
||||
this.config.userToken = this.musicKit.musicUserToken;
|
||||
}
|
||||
|
||||
this.musicKit.shuffleMode = MusicKit.PlayerShuffleMode.off;
|
||||
} );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the authentication status of the user
|
||||
* @returns {boolean[]} Returns an array, where the first element indicates login status, the second one, if an error was encountered
|
||||
*/
|
||||
getAuth (): boolean[] {
|
||||
return [
|
||||
this.isLoggedIn,
|
||||
this.hasEncounteredAuthError
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Request data from the Apple Music API
|
||||
* @param {string} url The URL at the Apple Music API to call (including protocol and url)
|
||||
* @param {( data: object ) => void} callback A callback function that takes the data and returns nothing
|
||||
* @returns {void}
|
||||
*/
|
||||
apiGetRequest ( url: string, callback: ( data: object ) => void ): void {
|
||||
if ( this.config.devToken != '' && this.config.userToken != '' ) {
|
||||
fetch( url, {
|
||||
'method': 'GET',
|
||||
'headers': {
|
||||
'Authorization': `Bearer ${ this.config.devToken }`,
|
||||
'Music-User-Token': this.config.userToken
|
||||
}
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.json().then( json => {
|
||||
try {
|
||||
callback( {
|
||||
'status': 'ok',
|
||||
'data': json
|
||||
} );
|
||||
} catch ( err ) { /* empty */ }
|
||||
} );
|
||||
} else {
|
||||
try {
|
||||
callback( {
|
||||
'status': 'error',
|
||||
'error': res.status
|
||||
} );
|
||||
} catch ( err ) { /* empty */ }
|
||||
}
|
||||
} );
|
||||
} else return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the playlist to play
|
||||
* @param {Song[]} playlist The playlist as an array of songs
|
||||
* @returns {void}
|
||||
*/
|
||||
setPlaylist ( playlist: Song[] ): void {
|
||||
this.playlist = playlist;
|
||||
this.setShuffle( this.isShuffleEnabled );
|
||||
}
|
||||
|
||||
setPlaylistByID ( id: string ): Promise<void> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
this.musicKit.setQueue( {
|
||||
'playlist': id
|
||||
} ).then( () => {
|
||||
const pl = this.musicKit.queue.items;
|
||||
const songs: Song[] = [];
|
||||
|
||||
for ( const item in pl ) {
|
||||
let url = pl[ item ].attributes.artwork.url;
|
||||
|
||||
url = url.replace( '{w}', pl[ item ].attributes.artwork.width );
|
||||
url = url.replace( '{h}', pl[ item ].attributes.artwork.height );
|
||||
const song: Song = {
|
||||
'artist': pl[ item ].attributes.artistName,
|
||||
'cover': url,
|
||||
'duration': pl[ item ].attributes.durationInMillis / 1000,
|
||||
'id': pl[ item ].id,
|
||||
'origin': 'apple-music',
|
||||
'title': pl[ item ].attributes.name,
|
||||
'genres': pl[ item ].attributes.genreNames
|
||||
};
|
||||
|
||||
songs.push( song );
|
||||
}
|
||||
|
||||
this.playlist = songs;
|
||||
this.setShuffle( this.isShuffleEnabled );
|
||||
this.queuePos = 0;
|
||||
this.playingSongID = this.queue[ 0 ];
|
||||
this.prepare( this.playingSongID );
|
||||
resolve();
|
||||
} )
|
||||
.catch( err => {
|
||||
console.error( err );
|
||||
reject( err );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a specific song in the queue for playing and start playing
|
||||
* @param {number} playlistID The ID of the song in the playlist to prepare to play
|
||||
* @returns {boolean} Returns true, if successful, false, if playlist is missing / empty. Set that first
|
||||
*/
|
||||
prepare ( playlistID: number ): boolean {
|
||||
if ( this.playlist.length > 0 ) {
|
||||
this.playingSongID = playlistID;
|
||||
this.isPreparedToPlay = true;
|
||||
|
||||
for ( const el in this.queue ) {
|
||||
if ( this.queue[ el ] === playlistID ) {
|
||||
this.queuePos = parseInt( el );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.setQueue( {
|
||||
'song': this.playlist[ this.playingSongID ].id
|
||||
} ).then( () => {
|
||||
setTimeout( () => {
|
||||
this.control( 'play' );
|
||||
}, 500 );
|
||||
} )
|
||||
.catch( err => {
|
||||
console.log( err );
|
||||
} );
|
||||
} else {
|
||||
this.audioPlayer = document.getElementById( 'local-audio' ) as HTMLAudioElement;
|
||||
this.audioPlayer.src = this.playlist[ this.playingSongID ].id;
|
||||
setTimeout( () => {
|
||||
this.control( 'play' );
|
||||
}, 500 );
|
||||
}
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Control the player
|
||||
* @param {ControlAction} action Action to take on the player
|
||||
* @returns {boolean} returns a boolean indicating if there was a change in song.
|
||||
*/
|
||||
control ( action: ControlAction ): boolean {
|
||||
switch ( action ) {
|
||||
case 'play':
|
||||
if ( this.isPreparedToPlay ) {
|
||||
this.control( 'pause' );
|
||||
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.play();
|
||||
|
||||
return false;
|
||||
} else {
|
||||
this.audioPlayer.play();
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'pause':
|
||||
if ( this.isPreparedToPlay ) {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.pause();
|
||||
|
||||
return false;
|
||||
} else {
|
||||
this.audioPlayer.pause();
|
||||
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'back-10':
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime > 10 ? this.musicKit.currentPlaybackTime - 10 : 0 );
|
||||
|
||||
return false;
|
||||
} else {
|
||||
this.audioPlayer.currentTime = this.audioPlayer.currentTime > 10 ? this.audioPlayer.currentTime - 10 : 0;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'skip-10':
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
if ( this.musicKit.currentPlaybackTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
|
||||
this.musicKit.seekToTime( this.musicKit.currentPlaybackTime + 10 );
|
||||
|
||||
return false;
|
||||
} else {
|
||||
if ( this.repeatMode !== 'once' ) {
|
||||
this.control( 'next' );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
this.musicKit.seekToTime( 0 );
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if ( this.audioPlayer.currentTime < ( this.playlist[ this.playingSongID ].duration - 10 ) ) {
|
||||
this.audioPlayer.currentTime = this.audioPlayer.currentTime + 10;
|
||||
} else {
|
||||
if ( this.repeatMode !== 'once' ) {
|
||||
this.control( 'next' );
|
||||
} else {
|
||||
this.audioPlayer.currentTime = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
case 'next':
|
||||
this.control( 'pause' );
|
||||
|
||||
if ( this.queuePos < this.queue.length - 1 ) {
|
||||
this.queuePos += 1;
|
||||
this.prepare( this.queue[ this.queuePos ] );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
this.queuePos = 0;
|
||||
|
||||
if ( this.repeatMode !== 'all' ) {
|
||||
this.control( 'pause' );
|
||||
} else {
|
||||
this.playingSongID = this.queue[ this.queuePos ];
|
||||
this.prepare( this.queue[ this.queuePos ] );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
case 'previous':
|
||||
this.control( 'pause' );
|
||||
|
||||
if ( this.queuePos > 0 ) {
|
||||
this.queuePos -= 1;
|
||||
this.prepare( this.queue[ this.queuePos ] );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
this.queuePos = this.queue.length - 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setShuffle ( enabled: boolean ) {
|
||||
this.isShuffleEnabled = enabled;
|
||||
this.queue = [];
|
||||
|
||||
if ( enabled ) {
|
||||
const d = [];
|
||||
|
||||
for ( const el in this.playlist ) {
|
||||
d.push( parseInt( el ) );
|
||||
}
|
||||
|
||||
this.queue = d.map( value => ( {
|
||||
value,
|
||||
'sort': Math.random()
|
||||
} ) )
|
||||
.sort( ( a, b ) => a.sort - b.sort )
|
||||
.map( ( {
|
||||
value
|
||||
} ) => value );
|
||||
this.queue.splice( this.queue.indexOf( this.playingSongID ), 1 );
|
||||
this.queue.push( this.playingSongID );
|
||||
this.queue.reverse();
|
||||
} else {
|
||||
for ( const song in this.playlist ) {
|
||||
this.queue.push( parseInt( song ) );
|
||||
}
|
||||
}
|
||||
|
||||
// Find current song ID in queue
|
||||
for ( const el in this.queue ) {
|
||||
if ( this.queue[ el ] === this.playingSongID ) {
|
||||
this.queuePos = parseInt( el );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setRepeatMode ( mode: RepeatMode ) {
|
||||
this.repeatMode = mode;
|
||||
}
|
||||
|
||||
goToPos ( pos: number ) {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
this.musicKit.seekToTime( pos );
|
||||
} else {
|
||||
this.audioPlayer.currentTime = pos;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
moveSong ( move: SongMove ) {
|
||||
const newQueue = [];
|
||||
const finishedQueue = [];
|
||||
|
||||
let songID = 0;
|
||||
|
||||
for ( const song in this.playlist ) {
|
||||
if ( this.playlist[ song ].id === move.songID ) {
|
||||
songID = parseInt( song );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for ( const el in this.queue ) {
|
||||
if ( this.queue[ el ] !== songID ) {
|
||||
newQueue.push( this.queue[ el ] );
|
||||
}
|
||||
}
|
||||
|
||||
let hasBeenAdded = false;
|
||||
|
||||
for ( const el in newQueue ) {
|
||||
if ( parseInt( el ) === move.newPos ) {
|
||||
finishedQueue.push( songID );
|
||||
hasBeenAdded = true;
|
||||
}
|
||||
|
||||
finishedQueue.push( newQueue[ el ] );
|
||||
}
|
||||
|
||||
if ( !hasBeenAdded ) {
|
||||
finishedQueue.push( songID );
|
||||
}
|
||||
|
||||
this.queue = finishedQueue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current position of the play heed. Will return in ms since start of the song
|
||||
* @returns {number}
|
||||
*/
|
||||
getPlaybackPos (): number {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
return this.musicKit.currentPlaybackTime;
|
||||
} else {
|
||||
return this.audioPlayer.currentTime;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get details on the currently playing song
|
||||
* @returns {Song}
|
||||
*/
|
||||
getPlayingSong (): Song {
|
||||
return this.playlist[ this.playingSongID ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the playlist index of the currently playing song
|
||||
* @returns {number}
|
||||
*/
|
||||
getPlayingSongID (): number {
|
||||
return this.playingSongID;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the queue index of the currently playing song
|
||||
* @returns {number}
|
||||
*/
|
||||
getQueueID (): number {
|
||||
return this.queuePos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full playlist, as it is set currently, not ordered by queue settings, but as passed in originally
|
||||
* @returns {Song[]}
|
||||
*/
|
||||
getPlaylist (): Song[] {
|
||||
return this.playlist;
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as getPlaylist, but returns a ordered playlist, by how it will play according to the queue.
|
||||
* @returns {Song[]}
|
||||
*/
|
||||
getQueue (): Song[] {
|
||||
const data = [];
|
||||
|
||||
for ( const el in this.queue ) {
|
||||
data.push( this.playlist[ this.queue[ el ] ] );
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all playlists the authenticated user has on Apple Music. Only available once the user has authenticated!
|
||||
* @param {( data: object ) => void} cb The callback function called with the results from the API
|
||||
* @returns {boolean} Returns true, if user is authenticated and request was started, false if not.
|
||||
*/
|
||||
getUserPlaylists ( cb: ( data: object ) => void ): boolean {
|
||||
if ( this.isLoggedIn ) {
|
||||
this.apiGetRequest( 'https://api.music.apple.com/v1/me/library/playlists', cb );
|
||||
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getPlaying ( ): boolean {
|
||||
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
|
||||
return this.musicKit.isPlaying;
|
||||
} else {
|
||||
return !this.audioPlayer.paused;
|
||||
}
|
||||
}
|
||||
|
||||
findSongOnAppleMusic ( searchTerm: string ): Promise<SearchResult> {
|
||||
// TODO: Make storefront adjustable
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const queryParameters = {
|
||||
'term': searchTerm,
|
||||
'types': [ 'songs' ],
|
||||
};
|
||||
|
||||
this.musicKit.api.music( 'v1/catalog/ch/search', queryParameters )
|
||||
.then( results => {
|
||||
resolve( results );
|
||||
} )
|
||||
.catch( e => {
|
||||
console.error( e );
|
||||
reject( e );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default MusicKitJSWrapper;
|
||||
@@ -1,315 +0,0 @@
|
||||
// These functions handle connections to the backend with socket.io
|
||||
|
||||
import {
|
||||
type Socket, io
|
||||
} from 'socket.io-client';
|
||||
import type {
|
||||
SSEMap
|
||||
} from './song';
|
||||
|
||||
class NotificationHandler {
|
||||
|
||||
socket: Socket;
|
||||
|
||||
roomName: string;
|
||||
|
||||
roomToken: string;
|
||||
|
||||
isConnected: boolean;
|
||||
|
||||
useSocket: boolean;
|
||||
|
||||
eventSource?: EventSource;
|
||||
|
||||
toBeListenedForItems: SSEMap;
|
||||
|
||||
reconnectRetryCount: number;
|
||||
|
||||
lastEmitTimestamp: number;
|
||||
|
||||
openConnectionsCount: number;
|
||||
|
||||
pendingRequestCount: number;
|
||||
|
||||
connectionWasSuccessful: boolean;
|
||||
|
||||
constructor () {
|
||||
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
|
||||
'autoConnect': false,
|
||||
} );
|
||||
this.roomName = '';
|
||||
this.roomToken = '';
|
||||
this.isConnected = false;
|
||||
this.useSocket = localStorage.getItem( 'music-player-config' ) === 'ws';
|
||||
this.toBeListenedForItems = {};
|
||||
this.reconnectRetryCount = 0;
|
||||
this.lastEmitTimestamp = 0;
|
||||
this.pendingRequestCount = 0;
|
||||
this.openConnectionsCount = 0;
|
||||
this.connectionWasSuccessful = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a room token and connect to
|
||||
* @param {string} roomName
|
||||
* @param {boolean} useAntiTamper
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
connect ( roomName: string, useAntiTamper: boolean ): Promise<void> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fetch( localStorage.getItem( 'url' ) + '/createRoomToken?roomName='
|
||||
+ roomName + '&useAntiTamper=' + useAntiTamper, {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.text().then( text => {
|
||||
this.roomToken = text;
|
||||
this.roomName = roomName;
|
||||
|
||||
if ( this.useSocket ) {
|
||||
this.socket.connect();
|
||||
this.socket.emit(
|
||||
'create-room', {
|
||||
'name': this.roomName,
|
||||
'token': this.roomToken
|
||||
}, ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => {
|
||||
if ( res.status === true ) {
|
||||
this.isConnected = true;
|
||||
resolve();
|
||||
} else {
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
}
|
||||
}
|
||||
);
|
||||
} else {
|
||||
this.sseConnect().then( () => {
|
||||
resolve();
|
||||
} )
|
||||
.catch( );
|
||||
}
|
||||
} );
|
||||
} else if ( res.status === 409 ) {
|
||||
reject( 'ERR_CONFLICT' );
|
||||
} else if ( res.status === 403 || res.status === 401 ) {
|
||||
reject( 'ERR_UNAUTHORIZED' );
|
||||
} else {
|
||||
reject( 'ERR_ROOM_CREATING' );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
sseConnect (): Promise<void> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
if ( this.reconnectRetryCount < 5 ) {
|
||||
if ( this.openConnectionsCount < 1 && !this.isConnected ) {
|
||||
this.openConnectionsCount += 1;
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/joinRoom?room=' + this.roomName, {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
this.eventSource = new EventSource( localStorage.getItem( 'url' )
|
||||
+ '/socket/connection?room=' + this.roomName, {
|
||||
'withCredentials': true
|
||||
} );
|
||||
|
||||
this.eventSource.onopen = () => {
|
||||
this.isConnected = true;
|
||||
this.connectionWasSuccessful = true;
|
||||
this.reconnectRetryCount = 0;
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString() + ': Connection successfully established!' );
|
||||
resolve();
|
||||
};
|
||||
|
||||
this.eventSource.onmessage = e => {
|
||||
const d = JSON.parse( e.data );
|
||||
|
||||
if ( this.toBeListenedForItems[ d.type ] ) {
|
||||
this.toBeListenedForItems[ d.type ]( d.data );
|
||||
}
|
||||
};
|
||||
|
||||
this.eventSource.onerror = e => {
|
||||
if ( this.isConnected ) {
|
||||
this.isConnected = false;
|
||||
this.eventSource?.close();
|
||||
this.openConnectionsCount -= 1;
|
||||
console.debug( '[ SSE Connection ] - Error encountered: ', e );
|
||||
console.log( '[ SSE Connection ] - '
|
||||
+ new Date().toISOString()
|
||||
+ ': Reconnecting due to connection error!' );
|
||||
|
||||
this.eventSource = undefined;
|
||||
|
||||
this.reconnectRetryCount += 1;
|
||||
setTimeout( () => {
|
||||
this.sseConnect();
|
||||
}, 1000 * this.reconnectRetryCount );
|
||||
}
|
||||
};
|
||||
} else if ( res.status === 403 || res.status === 401
|
||||
|| res.status === 404 || res.status === 402 ) {
|
||||
document.dispatchEvent( new Event( 'musicplayer:autherror' ) );
|
||||
reject( 'ERR_UNAUTHORIZED' );
|
||||
} else {
|
||||
reject( 'ERR_ROOM_CONNECTING_STATUS_CODE' );
|
||||
}
|
||||
} )
|
||||
.catch( () => {
|
||||
if ( !this.connectionWasSuccessful ) {
|
||||
reject( 'ERR_ROOM_CONNECTING' );
|
||||
} else {
|
||||
this.openConnectionsCount -= 1;
|
||||
console.log( '[ SSE Connection ] - ' + new Date().toISOString()
|
||||
+ ': Reconnecting due to severe connection error!' );
|
||||
|
||||
this.eventSource = undefined;
|
||||
|
||||
this.reconnectRetryCount += 1;
|
||||
setTimeout( () => {
|
||||
this.sseConnect();
|
||||
}, 1000 * this.reconnectRetryCount );
|
||||
}
|
||||
} );
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
} else {
|
||||
if ( confirm( 'Connection lost and it could not be reestablished. Please click ok to retry or press cancel to stop retrying. Your share will be deleted as a result thereof.' ) ) {
|
||||
this.reconnectRetryCount = 0;
|
||||
this.sseConnect();
|
||||
} else {
|
||||
this.disconnect();
|
||||
}
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit an event
|
||||
* @param {string} event The event to emit
|
||||
* @param {any} data
|
||||
* @returns {void}
|
||||
*/
|
||||
emit ( event: string, data: any ): void {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.emit( event, {
|
||||
'roomToken': this.roomToken,
|
||||
'roomName': this.roomName,
|
||||
'data': data
|
||||
} );
|
||||
} else {
|
||||
const now = new Date().getTime();
|
||||
|
||||
if ( this.lastEmitTimestamp < now - 250 ) {
|
||||
this.lastEmitTimestamp = now;
|
||||
this.sendEmitConventionally( event, data );
|
||||
} else {
|
||||
this.pendingRequestCount += 1;
|
||||
setTimeout( () => {
|
||||
this.pendingRequestCount = 0;
|
||||
this.lastEmitTimestamp = now;
|
||||
this.sendEmitConventionally( event, data );
|
||||
}, 250 * this.pendingRequestCount );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendEmitConventionally ( event: string, data: any ): void {
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/update', {
|
||||
'method': 'post',
|
||||
'body': JSON.stringify( {
|
||||
'event': event,
|
||||
'roomName': this.roomName,
|
||||
'roomToken': this.roomToken,
|
||||
'data': data
|
||||
} ),
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8'
|
||||
}
|
||||
} ).catch( () => {} );
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a listener function for an event
|
||||
* @param {string} event The event to listen for
|
||||
* @param {( data: any ) => void} cb The callback function / listener function
|
||||
* @returns {void}
|
||||
*/
|
||||
registerListener ( event: string, cb: ( data: any ) => void ): void {
|
||||
if ( this.useSocket ) {
|
||||
if ( this.isConnected ) {
|
||||
this.socket.on( event, cb );
|
||||
}
|
||||
} else {
|
||||
this.toBeListenedForItems[ event ] = cb;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from the server
|
||||
* @returns {any}
|
||||
*/
|
||||
async disconnect (): Promise<void> {
|
||||
if ( this.isConnected ) {
|
||||
if ( this.useSocket ) {
|
||||
this.socket.emit(
|
||||
'delete-room', {
|
||||
'name': this.roomName,
|
||||
'token': this.roomToken
|
||||
}, ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => {
|
||||
this.socket.disconnect();
|
||||
|
||||
if ( !res.status ) {
|
||||
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
);
|
||||
} else {
|
||||
fetch( localStorage.getItem( 'url' ) + '/socket/deleteRoom', {
|
||||
'method': 'post',
|
||||
'body': JSON.stringify( {
|
||||
'roomName': this.roomName,
|
||||
'roomToken': this.roomToken
|
||||
} ),
|
||||
'credentials': 'include',
|
||||
'headers': {
|
||||
'Content-Type': 'application/json',
|
||||
'charset': 'utf-8'
|
||||
}
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
this.eventSource!.close();
|
||||
} else {
|
||||
alert( 'Unable to delete the room you were just in. The name will be blocked until the next server restart!' );
|
||||
}
|
||||
|
||||
return;
|
||||
} )
|
||||
.catch( () => {
|
||||
return;
|
||||
} );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getRoomName (): string {
|
||||
return this.roomName;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default NotificationHandler;
|
||||
-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,29 +0,0 @@
|
||||
import {
|
||||
defineStore
|
||||
} from 'pinia';
|
||||
|
||||
|
||||
// FOSS-VERSION: To enable the UI to be used with the FOSS version, change "isUserAuth" to true, you will be "logged in"
|
||||
export const useUserStore = defineStore( 'user', {
|
||||
'state': () => ( {
|
||||
'isUserAuth': false,
|
||||
'hasSubscribed': false,
|
||||
'isUsingKeyboard': false,
|
||||
'isFOSSVersion': false
|
||||
} ),
|
||||
'getters': {
|
||||
'getUserAuthenticated': state => state.isUserAuth,
|
||||
'getSubscriptionStatus': state => state.hasSubscribed,
|
||||
},
|
||||
'actions': {
|
||||
setUserAuth ( auth: boolean ) {
|
||||
this.isUserAuth = auth;
|
||||
},
|
||||
setSubscriptionStatus ( status: boolean ) {
|
||||
this.hasSubscribed = status;
|
||||
},
|
||||
setKeyboardUsageStatus ( status: boolean ) {
|
||||
this.isUsingKeyboard = status;
|
||||
}
|
||||
}
|
||||
} );
|
||||
@@ -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,197 +0,0 @@
|
||||
<template>
|
||||
<div class="app-view">
|
||||
<button id="logout" @click="logout()">
|
||||
<span class="material-symbols-outlined">logout</span>
|
||||
</button>
|
||||
<div v-if="!hasFinishedLoading" class="loading-view">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
<div v-else-if="hasFinishedLoading && isReady" class="home-view">
|
||||
<libraryView
|
||||
class="library-view"
|
||||
:playlists="playlists"
|
||||
:is-logged-in="isLoggedIntoAppleMusic"
|
||||
@selected-playlist="( id ) => { selectPlaylist( id ) }"
|
||||
@custom-playlist="( pl ) => selectCustomPlaylist( pl )"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="login-view">
|
||||
<img src="@/assets/appleMusicIcon.svg" alt="Apple Music Icon">
|
||||
<button class="fancy-button" style="margin-top: 20px;" @click="logIntoAppleMusic()">
|
||||
Log into Apple Music
|
||||
</button>
|
||||
<button
|
||||
class="fancy-button"
|
||||
title="This allows you to use local playlists only.
|
||||
Cover images for your songs will be fetched from the apple music api as good as possible"
|
||||
@click="skipLogin()"
|
||||
>
|
||||
Continue without logging in
|
||||
</button>
|
||||
</div>
|
||||
<playerView
|
||||
ref="player"
|
||||
:class="'player-view'
|
||||
+ ( isReady ? ( isShowingFullScreenPlayer ? ' full-screen-player' : '' ) : ' player-hidden' )"
|
||||
@player-state-change="( state ) => { handlePlayerStateChange( state ) }"
|
||||
/>
|
||||
<!-- TODO: Call to backend to check if user has access -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import playerView from '@/components/playerView.vue';
|
||||
import libraryView from '@/components/libraryView.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
ReadFile
|
||||
} from '@/scripts/song';
|
||||
import router from '@/router';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/stores/userStore';
|
||||
|
||||
const isLoggedIntoAppleMusic = ref( false );
|
||||
const isReady = ref( false );
|
||||
const isShowingFullScreenPlayer = ref( false );
|
||||
const player = ref( playerView );
|
||||
const playlists = ref( [] );
|
||||
const hasFinishedLoading = ref( false );
|
||||
const userStore = useUserStore();
|
||||
|
||||
const handlePlayerStateChange = ( newState: string ) => {
|
||||
if ( newState === 'hide' ) {
|
||||
isShowingFullScreenPlayer.value = false;
|
||||
} else {
|
||||
isShowingFullScreenPlayer.value = true;
|
||||
}
|
||||
};
|
||||
|
||||
let loginChecker = 0;
|
||||
|
||||
const logIntoAppleMusic = () => {
|
||||
player.value.logIntoAppleMusic();
|
||||
loginChecker = setInterval( () => {
|
||||
if ( player.value.getAuth()[ 0 ] ) {
|
||||
isLoggedIntoAppleMusic.value = true;
|
||||
isReady.value = true;
|
||||
player.value.getPlaylists( data => {
|
||||
playlists.value = data.data.data;
|
||||
} );
|
||||
clearInterval( loginChecker );
|
||||
} else if ( player.value.getAuth()[ 1 ] ) {
|
||||
clearInterval( loginChecker );
|
||||
alert( 'An error occurred when logging you in. Please try again!' );
|
||||
}
|
||||
}, 500 );
|
||||
};
|
||||
|
||||
const skipLogin = () => {
|
||||
isReady.value = true;
|
||||
isLoggedIntoAppleMusic.value = false;
|
||||
player.value.skipLogin();
|
||||
};
|
||||
|
||||
const selectPlaylist = ( id: string ) => {
|
||||
player.value.selectPlaylist( id );
|
||||
player.value.controlUI( 'show' );
|
||||
};
|
||||
|
||||
const selectCustomPlaylist = ( playlist: ReadFile[] ) => {
|
||||
player.value.selectCustomPlaylist( playlist );
|
||||
player.value.controlUI( 'show' );
|
||||
};
|
||||
|
||||
fetch( localStorage.getItem( 'url' ) + '/checkUserStatus', {
|
||||
'credentials': 'include'
|
||||
} ).then( res => {
|
||||
if ( res.status === 200 ) {
|
||||
res.text().then( text => {
|
||||
if ( text === 'ok' ) {
|
||||
hasFinishedLoading.value = true;
|
||||
userStore.setSubscriptionStatus( true );
|
||||
} else {
|
||||
userStore.setSubscriptionStatus( false );
|
||||
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
|
||||
router.push( '/get' );
|
||||
}
|
||||
} );
|
||||
} else if ( res.status === 402 ) {
|
||||
userStore.setSubscriptionStatus( false );
|
||||
router.push( '/get' );
|
||||
sessionStorage.setItem( 'getRedirectionReason', 'notOwned' );
|
||||
} else {
|
||||
console.log( res.status );
|
||||
}
|
||||
} );
|
||||
|
||||
const logout = () => {
|
||||
// location.href = 'http://localhost:8080/logout?return=' + location.href;
|
||||
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
#logout {
|
||||
border: none;
|
||||
background: none;
|
||||
position: fixed;
|
||||
left: calc( 10px + 2rem );
|
||||
top: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#logout .material-symbols-outlined {
|
||||
font-size: 1.5rem;
|
||||
color: var( --primary-color );
|
||||
}
|
||||
|
||||
.library-view {
|
||||
height: calc( 90vh - 10px );
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.home-view {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.login-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 50vh;
|
||||
}
|
||||
|
||||
.player-view {
|
||||
height: 13vh;
|
||||
width: calc( 100vw - 20px );
|
||||
position: fixed;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
background-color: var( --secondary-color );
|
||||
transition: all 0.75s ease-in-out;
|
||||
}
|
||||
|
||||
.full-screen-player {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.player-hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,106 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="top-view">
|
||||
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
|
||||
<h1>MusicPlayer</h1>
|
||||
<p v-if="reasonForRedirectHere" style="color: red;">
|
||||
{{ reasons[ reasonForRedirectHere ] }}
|
||||
</p>
|
||||
<p v-if="!reasonForRedirectHere">
|
||||
<i>An Open Source, browser-based MusicPlayer with beautiful graphics</i>
|
||||
</p>
|
||||
<div style="margin-top: 20px;">
|
||||
<a href="https://store.janishutz.com/product/com.janishutz.MusicPlayer" class="fancy-button" target="_blank">Subscribe</a>
|
||||
<a
|
||||
v-if="!reasonForRedirectHere"
|
||||
href="/"
|
||||
class="fancy-button"
|
||||
style="margin-left: 10px;"
|
||||
>Log in</a>
|
||||
<button
|
||||
v-if="reasonForRedirectHere"
|
||||
href="/"
|
||||
class="fancy-button"
|
||||
style="margin-left: 10px;"
|
||||
@click="logout()"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
<a
|
||||
href="https://github.com/simplePCBuilding/MusicPlayerV2"
|
||||
class="fancy-button"
|
||||
style="margin-left: 10px;"
|
||||
target="_blank"
|
||||
>GitHub</a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Fully featured Music Player</h2>
|
||||
<p>All the features you'd expect a Music Player to have are also present here</p>
|
||||
|
||||
<h2>Apple Music integration</h2>
|
||||
<p>Use MusicPlayer in conjunction with Apple Music</p>
|
||||
|
||||
<h2>Share your playlist</h2>
|
||||
<p>
|
||||
You can share your playlist on a beautifully animated public page,
|
||||
so that other people can join in and view your playlist
|
||||
</p>
|
||||
|
||||
<h2>Fully browser based</h2>
|
||||
<p>No installation required when using MusicPlayer on <a href="https://music.janishutz.com">music.janishutz.com</a></p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ref, type Ref
|
||||
} from 'vue';
|
||||
|
||||
interface Reasons {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const reasons: Ref<Reasons> = ref( {
|
||||
'notOwned': 'Please subscribe to use MusicPlayer here, or download and install it manually from GitHub!',
|
||||
} );
|
||||
const reasonForRedirectHere = ref( sessionStorage.getItem( 'getRedirectionReason' ) );
|
||||
|
||||
sessionStorage.removeItem( 'getRedirectionReason' );
|
||||
|
||||
const logout = () => {
|
||||
// location.href = 'http://localhost:8080/logout?return=' + location.href;
|
||||
location.href = 'https://id.janishutz.com/logout?return=' + location.href;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 60vh;
|
||||
max-height: 90vw;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.top-view {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.full-height {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.promo-img {
|
||||
height: 100vh;
|
||||
width: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,90 +0,0 @@
|
||||
<template>
|
||||
<div class="home-view">
|
||||
<img src="https://github.com/simplePCBuilding/MusicPlayerV2/raw/master/assets/logo.png" alt="MusicPlayer Logo" class="logo">
|
||||
<button
|
||||
:class="'fancy-button' + ( isTryingToSignIn ? ' fancy-button-inactive' : '' )"
|
||||
style="margin-top: 5vh;"
|
||||
title="Sign in or sign up with janishutz.com ID"
|
||||
@click="login()"
|
||||
>
|
||||
{{ isTryingToSignIn ? 'Signing you in...' : 'Login / Sign up' }}
|
||||
</button>
|
||||
<p style="width: 80%;">
|
||||
MusicPlayer is a browser based Music Player, that allows you to connect other devices,
|
||||
simply with another web-browser, where you can see the current playlist with sleek animations.
|
||||
You can log in using your Apple Music account or load a playlist from your local disk,
|
||||
simply by selecting the songs using a file picker.
|
||||
</p>
|
||||
<router-link to="/get" class="fancy-button">
|
||||
More information
|
||||
</router-link>
|
||||
<notificationsModule ref="notifications" location="bottomleft" size="bigger" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
// TODO: Make possible to install and use without account, if using FOSS version
|
||||
import {
|
||||
RouterLink
|
||||
} from 'vue-router';
|
||||
import notificationsModule from '@/components/notificationsModule.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import router from '@/router';
|
||||
import sdk from '@janishutz/login-sdk-browser';
|
||||
import {
|
||||
useUserStore
|
||||
} from '@/stores/userStore';
|
||||
|
||||
const notifications = ref( notificationsModule );
|
||||
const isTryingToSignIn = ref( true );
|
||||
|
||||
const login = () => {
|
||||
sdk.login();
|
||||
};
|
||||
|
||||
const store = useUserStore();
|
||||
|
||||
if ( store.isUserAuth ) {
|
||||
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
|
||||
localStorage.removeItem( 'redirect' );
|
||||
} else {
|
||||
sdk.verifyFull()
|
||||
.then( res => {
|
||||
if ( res ) {
|
||||
store.isUserAuth = true;
|
||||
|
||||
if ( localStorage.getItem( 'close-tab' ) ) {
|
||||
localStorage.removeItem( 'close-tab' );
|
||||
window.close();
|
||||
}
|
||||
|
||||
localStorage.setItem( 'login-ok', 'true' );
|
||||
router.push( localStorage.getItem( 'redirect' ) ?? '/app' );
|
||||
localStorage.removeItem( 'redirect' );
|
||||
} else {
|
||||
isTryingToSignIn.value = false;
|
||||
}
|
||||
} )
|
||||
.catch( e => {
|
||||
console.debug( e );
|
||||
isTryingToSignIn.value = false;
|
||||
} );
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-view {
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 50vh;
|
||||
border-radius: 50px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,389 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="info">
|
||||
Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a>
|
||||
</div>
|
||||
<div class="remote-view">
|
||||
<div v-if="hasLoaded && !showCouldNotFindRoom" style="width: 100%">
|
||||
<div class="current-song-wrapper">
|
||||
<img
|
||||
v-if="playlist[ playingSong ] && playlist[ playingSong ].cover"
|
||||
id="current-image"
|
||||
:src="playlist[ playingSong ].cover"
|
||||
class="fancy-view-song-art"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<span v-else class="material-symbols-outlined fancy-view-song-art">music_note</span>
|
||||
<div class="current-song">
|
||||
<h1 style="margin-bottom: 5px;">
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
|
||||
</h1>
|
||||
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
|
||||
<p
|
||||
v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false"
|
||||
class="additional-info"
|
||||
>
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}
|
||||
</p>
|
||||
<progress id="progress" max="1000" :value="progressBar"></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-list-wrapper">
|
||||
<div v-for="song in songQueue" :key="song.id" class="song-list">
|
||||
<div class="song-details-wrapper">
|
||||
<h3>{{ song.title }}</h3>
|
||||
<p>{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="time-until">
|
||||
{{ getTimeUntil( song.id ) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <img :src="" alt=""> -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!hasLoaded && !showCouldNotFindRoom">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
<div v-else style="max-width: 80%;">
|
||||
<span class="material-symbols-outlined" style="font-size: 4rem;">wifi_off</span>
|
||||
<h1>Couldn't connect!</h1>
|
||||
<p>
|
||||
There does not appear to be a share with the specified name, or an error occurred when connecting.
|
||||
</p>
|
||||
<p>You may <a href="">reload</a> the page to try again!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SocketConnection from '@/scripts/connection';
|
||||
import type {
|
||||
Song
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
computed, ref, type Ref
|
||||
} from 'vue';
|
||||
|
||||
const isPlaying = ref( false );
|
||||
const playlist: Ref<Song[]> = ref( [] );
|
||||
const pos = ref( 0 );
|
||||
const playingSong = ref( 0 );
|
||||
const progressBar = ref( 0 );
|
||||
const hasLoaded = ref( false );
|
||||
const showCouldNotFindRoom = ref( false );
|
||||
const playbackStart = ref( 0 );
|
||||
|
||||
let timeTracker = 0;
|
||||
|
||||
const conn = new SocketConnection();
|
||||
|
||||
conn.connect().then( d => {
|
||||
playlist.value = d.playlist;
|
||||
isPlaying.value = d.playbackStatus;
|
||||
playingSong.value = d.playlistIndex;
|
||||
playbackStart.value = d.playbackStart;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
}
|
||||
|
||||
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
|
||||
progressBar.value = ( pos.value / ( playlist.value[ playingSong.value ]
|
||||
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
|
||||
hasLoaded.value = true;
|
||||
conn.registerListener( 'playlist', data => {
|
||||
playlist.value = data;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback', data => {
|
||||
isPlaying.value = data;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
} else {
|
||||
stopTimeTracker();
|
||||
}
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback-start', data => {
|
||||
playbackStart.value = data;
|
||||
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playlist-index', data => {
|
||||
playingSong.value = parseInt( data );
|
||||
} );
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
conn.registerListener( 'delete-share', _ => {
|
||||
alert( `This share was just deleted. It is no longer available.
|
||||
The page will reload automatically to try and re-establish connection!` );
|
||||
conn.disconnect();
|
||||
location.reload();
|
||||
} );
|
||||
} )
|
||||
.catch( e => {
|
||||
console.error( e );
|
||||
showCouldNotFindRoom.value = true;
|
||||
} );
|
||||
|
||||
const songQueue = computed( () => {
|
||||
let ret: Song[] = [];
|
||||
let pos = 0;
|
||||
|
||||
for ( let song in playlist.value ) {
|
||||
if ( pos >= playingSong.value ) {
|
||||
ret.push( playlist.value[ song ] );
|
||||
}
|
||||
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
return ret;
|
||||
} );
|
||||
const getTimeUntil = computed( () => {
|
||||
return ( song: string ) => {
|
||||
let timeRemaining = 0;
|
||||
|
||||
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
|
||||
if ( playlist.value[ i ].id == song ) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeRemaining += playlist.value[ i ].duration;
|
||||
}
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Currently playing';
|
||||
} else {
|
||||
return 'Playing in less than ' + Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min';
|
||||
}
|
||||
} else {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Plays next';
|
||||
} else {
|
||||
return 'Playing less than '
|
||||
+ Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min after starting to play';
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
const startTimeTracker = () => {
|
||||
try {
|
||||
clearInterval( timeTracker );
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
timeTracker = setInterval( () => {
|
||||
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
|
||||
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
|
||||
|
||||
if ( isNaN( progressBar.value ) ) {
|
||||
progressBar.value = 0;
|
||||
}
|
||||
|
||||
if ( playlist.value[ playingSong.value ].duration + 10 - pos.value < 0 ) {
|
||||
stopTimeTracker();
|
||||
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
|
||||
location.reload();
|
||||
}
|
||||
}, 100 );
|
||||
};
|
||||
|
||||
const stopTimeTracker = () => {
|
||||
clearInterval( timeTracker );
|
||||
};
|
||||
|
||||
document.addEventListener( 'visibilitychange', () => {
|
||||
if ( !document.hidden ) {
|
||||
if ( !conn.getStatus ) {
|
||||
stopTimeTracker();
|
||||
alert( 'It looks like if you have been disconnected! We are trying to reconnect you now!' );
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
} );
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#themeSelector {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.remote-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-align: justify;
|
||||
background-color: rgb(2, 16, 61);
|
||||
color: white;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.loaded {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 10vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
background-color: rgba( 0, 0, 0, 0.6 );
|
||||
}
|
||||
|
||||
.playing-symbols-wrapper {
|
||||
width: 4vw;
|
||||
height: 5vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.song-list-wrapper {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-bottom: 5%;
|
||||
}
|
||||
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
margin: 2px;
|
||||
padding: 1vh;
|
||||
border: 1px white solid;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.song-details-wrapper {
|
||||
margin: 0;
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
width: 65%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 5vw !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-song-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
margin-bottom: 2%;
|
||||
margin-top: 1%;
|
||||
}
|
||||
|
||||
.current-song {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-top: 1vh;
|
||||
padding: 1vh;
|
||||
max-width: 80%;
|
||||
text-align: center;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.fancy-view-song-art {
|
||||
height: 30vh;
|
||||
width: 30vh;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 30vh !important;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
#app {
|
||||
background-color: rgba( 0, 0, 0, 0 );
|
||||
}
|
||||
|
||||
#progress, #progress::-webkit-progress-bar {
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: rgb(82, 82, 82);
|
||||
width: 30vw;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
accent-color: white;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#progress::-moz-progress-bar {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#progress::-webkit-progress-value {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
right: 0.5%;
|
||||
top: 0.5%;
|
||||
padding: 0.5%;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.additional-info {
|
||||
font-size: 1.2rem;
|
||||
margin: 0;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: fixed;
|
||||
font-size: 12px;
|
||||
transform: rotate(270deg);
|
||||
left: -150px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 50%;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.time-until {
|
||||
width: 30%;
|
||||
text-align: end;
|
||||
}
|
||||
</style>
|
||||
@@ -1,595 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<span
|
||||
v-if="isAntiTamperEnabled"
|
||||
class="anti-tamper material-symbols-outlined"
|
||||
@click="secureModeInfo( 'toggle' )"
|
||||
>lock</span>
|
||||
<div
|
||||
v-if="isShowingSecureModeInfo && isAntiTamperEnabled"
|
||||
class="anti-tamper-info"
|
||||
@click="secureModeInfo( 'hide' )"
|
||||
>
|
||||
Anti-Tamper is enabled. Leaving this window will cause a notification to be dispatched to the player!
|
||||
</div>
|
||||
<div class="info">
|
||||
Designed and developed by Janis Hutz <a href="https://janishutz.com" target="_blank" style="text-decoration: none; color: white;">https://janishutz.com</a>
|
||||
</div>
|
||||
<div class="remote-view">
|
||||
<div v-if="hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
|
||||
<div class="current-song-wrapper">
|
||||
<img
|
||||
v-if="playlist[ playingSong ] && playlist[ playingSong ].cover"
|
||||
id="current-image"
|
||||
:src="playlist[ playingSong ].cover"
|
||||
class="fancy-view-song-art"
|
||||
crossorigin="anonymous"
|
||||
>
|
||||
<span v-else class="material-symbols-outlined fancy-view-song-art">music_note</span>
|
||||
<div class="current-song">
|
||||
<h1 style="margin-bottom: 5px;">
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].title : 'Not playing' }}
|
||||
</h1>
|
||||
<p>{{ playlist[ playingSong ] ? playlist[ playingSong ].artist : '' }}</p>
|
||||
<p
|
||||
v-if="playlist[ playingSong ] ? ( playlist[ playingSong ].additionalInfo !== '' ) : false"
|
||||
class="additional-info"
|
||||
>
|
||||
{{ playlist[ playingSong ] ? playlist[ playingSong ].additionalInfo : '' }}
|
||||
</p>
|
||||
<progress id="progress" max="1000" :value="progressBar"></progress>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mode-selector-wrapper">
|
||||
<select v-model="visualizationSettings" @change="handleAnimationChange()">
|
||||
<option value="mic">
|
||||
Microphone (Mic access required)
|
||||
</option>
|
||||
<option value="off">
|
||||
No visualization except background
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="song-list-wrapper">
|
||||
<div v-for="song in songQueue" :key="song.id" class="song-list">
|
||||
<img v-if="song.cover" :src="song.cover" class="song-image">
|
||||
<span v-else class="material-symbols-outlined song-cover">music_note</span>
|
||||
<div
|
||||
v-if="( playlist[ playingSong ] ? playlist[ playingSong ].id : '' ) === song.id && isPlaying"
|
||||
class="playing-symbols"
|
||||
>
|
||||
<div class="playing-symbols-wrapper">
|
||||
<div id="bar-1" class="playing-bar"></div>
|
||||
<div id="bar-2" class="playing-bar"></div>
|
||||
<div id="bar-3" class="playing-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-details-wrapper">
|
||||
<h3>{{ song.title }}</h3>
|
||||
<p>{{ song.artist }}</p>
|
||||
</div>
|
||||
<div class="time-until">
|
||||
{{ getTimeUntil( song.id ) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <img :src="" alt=""> -->
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!hasLoaded && !showCouldNotFindRoom" class="showcase-wrapper">
|
||||
<h1>Loading...</h1>
|
||||
</div>
|
||||
<div v-else class="showcase-wrapper">
|
||||
<h1>Couldn't connect!</h1>
|
||||
<p>
|
||||
There does not appear to be a share with the specified name, or an error occurred when connecting.
|
||||
</p>
|
||||
<p>You may reload the page to try again!</p>
|
||||
</div>
|
||||
<div id="background" class="background">
|
||||
<div class="beat-manual"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SocketConnection from '@/scripts/connection';
|
||||
import type {
|
||||
Song
|
||||
} from '@/scripts/song';
|
||||
import {
|
||||
type Ref, computed, ref
|
||||
} from 'vue';
|
||||
import bizualizer from '@/scripts/bizualizer';
|
||||
|
||||
const isPlaying = ref( false );
|
||||
const playlist: Ref<Song[]> = ref( [] );
|
||||
const pos = ref( 0 );
|
||||
const playingSong = ref( 0 );
|
||||
const progressBar = ref( 0 );
|
||||
const hasLoaded = ref( false );
|
||||
const showCouldNotFindRoom = ref( false );
|
||||
const playbackStart = ref( 0 );
|
||||
|
||||
let timeTracker = 0;
|
||||
|
||||
const visualizationSettings = ref( 'mic' );
|
||||
const isAntiTamperEnabled = ref( false );
|
||||
const conn = new SocketConnection();
|
||||
|
||||
conn.connect().then( d => {
|
||||
playlist.value = d.playlist;
|
||||
isPlaying.value = d.playbackStatus;
|
||||
playingSong.value = d.playlistIndex;
|
||||
playbackStart.value = d.playbackStart;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
}
|
||||
|
||||
pos.value = ( new Date().getTime() - parseInt( d.playbackStart ) ) / 1000;
|
||||
progressBar.value
|
||||
= ( pos.value / ( playlist.value[ playingSong.value ]
|
||||
? playlist.value[ playingSong.value ].duration : 1 ) ) * 1000;
|
||||
hasLoaded.value = true;
|
||||
|
||||
if ( d.useAntiTamper ) {
|
||||
isAntiTamperEnabled.value = true;
|
||||
notifier();
|
||||
}
|
||||
|
||||
conn.registerListener( 'playlist', data => {
|
||||
playlist.value = data;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback', data => {
|
||||
isPlaying.value = data;
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
startTimeTracker();
|
||||
} else {
|
||||
stopTimeTracker();
|
||||
}
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playback-start', data => {
|
||||
playbackStart.value = data;
|
||||
pos.value = ( new Date().getTime() - parseInt( data ) ) / 1000;
|
||||
} );
|
||||
|
||||
conn.registerListener( 'playlist-index', data => {
|
||||
playingSong.value = parseInt( data );
|
||||
setTimeout( () => {
|
||||
setBackground();
|
||||
}, 1000 );
|
||||
} );
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
conn.registerListener( 'delete-share', _ => {
|
||||
alert( 'This share was just deleted. It is no longer available. This page will reload automatically!' );
|
||||
conn.disconnect();
|
||||
location.reload();
|
||||
} );
|
||||
} )
|
||||
.catch( e => {
|
||||
console.error( e );
|
||||
showCouldNotFindRoom.value = true;
|
||||
} );
|
||||
|
||||
const songQueue = computed( () => {
|
||||
let ret: Song[] = [];
|
||||
let pos = 0;
|
||||
|
||||
for ( let song in playlist.value ) {
|
||||
if ( pos >= playingSong.value ) {
|
||||
ret.push( playlist.value[ song ] );
|
||||
}
|
||||
|
||||
pos += 1;
|
||||
}
|
||||
|
||||
return ret;
|
||||
} );
|
||||
const getTimeUntil = computed( () => {
|
||||
return ( song: string ) => {
|
||||
let timeRemaining = 0;
|
||||
|
||||
for ( let i = playingSong.value; i < Object.keys( playlist.value ).length - 1; i++ ) {
|
||||
if ( playlist.value[ i ].id == song ) {
|
||||
break;
|
||||
}
|
||||
|
||||
timeRemaining += playlist.value[ i ].duration;
|
||||
}
|
||||
|
||||
if ( isPlaying.value ) {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Currently playing';
|
||||
} else {
|
||||
return 'Playing in less than ' + Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min';
|
||||
}
|
||||
} else {
|
||||
if ( timeRemaining === 0 ) {
|
||||
return 'Plays next';
|
||||
} else {
|
||||
return 'Playing less than '
|
||||
+ Math.ceil( ( timeRemaining / 60 ) - ( pos.value / 60 ) ) + 'min after starting to play';
|
||||
}
|
||||
}
|
||||
};
|
||||
} );
|
||||
|
||||
const startTimeTracker = () => {
|
||||
try {
|
||||
clearInterval( timeTracker );
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( err ) { /* empty */ }
|
||||
|
||||
setTimeout( () => {
|
||||
handleAnimationChange();
|
||||
setBackground();
|
||||
}, 1000 );
|
||||
timeTracker = setInterval( () => {
|
||||
pos.value = ( new Date().getTime() - playbackStart.value ) / 1000;
|
||||
progressBar.value = ( pos.value / playlist.value[ playingSong.value ].duration ) * 1000;
|
||||
|
||||
if ( isNaN( progressBar.value ) ) {
|
||||
progressBar.value = 0;
|
||||
}
|
||||
}, 100 );
|
||||
};
|
||||
|
||||
const stopTimeTracker = () => {
|
||||
clearInterval( timeTracker );
|
||||
|
||||
handleAnimationChange();
|
||||
};
|
||||
|
||||
const animateBeat = () => {
|
||||
$( '.beat-manual' ).stop();
|
||||
const duration = Math.ceil( 60 / 180 * 500 ) - 50;
|
||||
|
||||
$( '.beat-manual' ).fadeIn( 50 );
|
||||
setTimeout( () => {
|
||||
$( '.beat-manual' ).fadeOut( duration );
|
||||
setTimeout( () => {
|
||||
bizualizer.coolDown();
|
||||
$( '.beat-manual' ).stop();
|
||||
}, duration );
|
||||
}, 50 );
|
||||
};
|
||||
|
||||
const handleAnimationChange = () => {
|
||||
if ( visualizationSettings.value === 'mic' && isPlaying.value ) {
|
||||
bizualizer.subscribeToBeatUpdate( animateBeat );
|
||||
} else {
|
||||
bizualizer.unsubscribeFromBeatUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
const setBackground = () => {
|
||||
bizualizer.createBackground().then( bg => {
|
||||
$( '#background' ).css( 'background', bg );
|
||||
} );
|
||||
};
|
||||
|
||||
const notifier = () => {
|
||||
Notification.requestPermission();
|
||||
|
||||
console.warn( '[ notifier ]: Status is now enabled \n\n-> Any leaving or tampering with the website will send a notification to the host' );
|
||||
|
||||
// Detect if window is currently in focus
|
||||
window.onblur = () => {
|
||||
sendNotification();
|
||||
};
|
||||
|
||||
// Detect if browser window becomes hidden (also with blur event)
|
||||
document.onvisibilitychange = () => {
|
||||
if ( document.visibilityState === 'hidden' ) {
|
||||
sendNotification();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const sendNotification = () => {
|
||||
new Notification( 'YOU ARE UNDER SURVEILLANCE', {
|
||||
'body': 'Please return to the original webpage immediately!',
|
||||
'requireInteraction': true,
|
||||
} );
|
||||
|
||||
conn.emit( 'tampering', '' );
|
||||
};
|
||||
|
||||
const isShowingSecureModeInfo = ref( false );
|
||||
|
||||
const secureModeInfo = ( action: string ) => {
|
||||
if ( action === 'toggle' ) {
|
||||
isShowingSecureModeInfo.value = !isShowingSecureModeInfo.value;
|
||||
} else if ( action === 'show' ) {
|
||||
isShowingSecureModeInfo.value = true;
|
||||
} else {
|
||||
isShowingSecureModeInfo.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.anti-tamper {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
bottom: 5px;
|
||||
right: 5px;
|
||||
font-size: 2rem;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.anti-tamper-info {
|
||||
position: fixed;
|
||||
z-index: 10;
|
||||
bottom: calc( 10px + 2rem );
|
||||
right: 5px;
|
||||
max-width: 20rem;
|
||||
background-color: black;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.remote-view {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.showcase-wrapper {
|
||||
width: 100%;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 10vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
background-color: rgba( 0, 0, 0, 0.6 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.playing-symbols-wrapper {
|
||||
width: 4vw;
|
||||
height: 5vw;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.playing-bar {
|
||||
height: 60%;
|
||||
background-color: white;
|
||||
width: 10%;
|
||||
border-radius: 50px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#bar-1 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#bar-2 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
#bar-3 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes music-playing {
|
||||
0% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
50% {
|
||||
transform: scaleY( 0.5 );
|
||||
}
|
||||
100% {
|
||||
transform: scaleY( 1 );
|
||||
}
|
||||
}
|
||||
|
||||
.song-list-wrapper {
|
||||
border-radius: 10px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.song-list {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 80%;
|
||||
margin: 2px;
|
||||
padding: 1vh;
|
||||
border: 1px white solid;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.song-details-wrapper {
|
||||
margin: 0;
|
||||
display: block;
|
||||
margin-left: 10px;
|
||||
margin-right: auto;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.song-list .song-image {
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 5vw;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.pause-icon {
|
||||
width: 5vw;
|
||||
height: 5vw;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
font-size: 5vw !important;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.current-song-wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
height: 55vh;
|
||||
width: 100%;
|
||||
margin-bottom: 0.5%;
|
||||
margin-top: 0.25%;
|
||||
}
|
||||
|
||||
.current-song {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
margin-top: 1vh;
|
||||
padding: 1vh;
|
||||
text-align: center;
|
||||
background-color: rgba( 0, 0, 0, 0.4 );
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.fancy-view-song-art {
|
||||
height: 30vh;
|
||||
width: 30vh;
|
||||
object-fit: cover;
|
||||
object-position: center;
|
||||
margin-bottom: 10px;
|
||||
font-size: 30vh !important;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
#app {
|
||||
background-color: rgba( 0, 0, 0, 0 );
|
||||
}
|
||||
|
||||
#progress, #progress::-webkit-progress-bar {
|
||||
background-color: rgb(82, 82, 82);
|
||||
color: rgb(82, 82, 82);
|
||||
width: 30vw;
|
||||
height: 10px;
|
||||
border: none;
|
||||
border-radius: 0px;
|
||||
accent-color: white;
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
#progress::-moz-progress-bar {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
#progress::-webkit-progress-value {
|
||||
background-color: white !important;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper {
|
||||
opacity: 0;
|
||||
position: fixed;
|
||||
right: 0.5%;
|
||||
top: 0.5%;
|
||||
padding: 0.5%;
|
||||
}
|
||||
|
||||
.mode-selector-wrapper:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dancing-style {
|
||||
font-size: 250%;
|
||||
margin: 0;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.info {
|
||||
position: fixed;
|
||||
font-size: 12px;
|
||||
transform: rotate(270deg);
|
||||
left: -150px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
top: 50%;
|
||||
z-index: 100;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.background {
|
||||
position: fixed;
|
||||
left: -50vw;
|
||||
width: 200vw;
|
||||
height: 200vw;
|
||||
top: -50vw;
|
||||
z-index: 1;
|
||||
filter: blur(10px);
|
||||
background: conic-gradient( blue, green, red, blue );
|
||||
animation: gradientAnim 10s infinite linear;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.beat, .beat-manual {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: rgba( 0, 0, 0, 0.1 );
|
||||
display: none;
|
||||
}
|
||||
|
||||
.beat {
|
||||
animation: beatAnim 0.6s infinite linear;
|
||||
}
|
||||
|
||||
@keyframes beatAnim {
|
||||
0% {
|
||||
background-color: rgba( 0, 0, 0, 0.2 );
|
||||
}
|
||||
20% {
|
||||
background-color: rgba( 0, 0, 0, 0 );
|
||||
}
|
||||
100% {
|
||||
background-color: rgba( 0, 0, 0, 0.2 );
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gradientAnim {
|
||||
from {
|
||||
transform: rotate( 0deg );
|
||||
}
|
||||
to {
|
||||
transform: rotate( 360deg );
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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,18 +0,0 @@
|
||||
{
|
||||
"extends": "@tsconfig/node20/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node", "jquery"]
|
||||
}
|
||||
}
|
||||
@@ -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,6 +21,14 @@
|
||||
<img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version">
|
||||
</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.
|
||||
|
||||
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).
|
||||
|
||||
+71
-29
@@ -1,15 +1,15 @@
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
|
||||
const style = {
|
||||
'plugins': {
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic/js': stylistic,
|
||||
'@stylistic/ts': stylistic,
|
||||
'@stylistic/ts': stylistic
|
||||
},
|
||||
'files': [
|
||||
'**/*.ts',
|
||||
@@ -20,11 +20,26 @@ const style = {
|
||||
'**/*.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': true,
|
||||
'multiline': false,
|
||||
'minItems': 2
|
||||
}
|
||||
],
|
||||
@@ -35,7 +50,8 @@ const style = {
|
||||
'@stylistic/array-element-newline': [
|
||||
'error',
|
||||
{
|
||||
'multiline': true,
|
||||
'consistent': false,
|
||||
'multiline': false,
|
||||
'minItems': 2
|
||||
}
|
||||
],
|
||||
@@ -56,7 +72,14 @@ const style = {
|
||||
],
|
||||
'@stylistic/brace-style': [
|
||||
'error',
|
||||
'1tbs'
|
||||
'1tbs',
|
||||
{
|
||||
'allowSingleLine': false
|
||||
}
|
||||
],
|
||||
'@stylistic/comma-dangle': [
|
||||
'error',
|
||||
'never'
|
||||
],
|
||||
'@stylistic/comma-spacing': [
|
||||
'error',
|
||||
@@ -73,9 +96,9 @@ const style = {
|
||||
'error',
|
||||
'property'
|
||||
],
|
||||
'@stylistic/eol-last': [
|
||||
'@stylistic/function-call-argument-newline': [
|
||||
'error',
|
||||
'always'
|
||||
'consistent'
|
||||
],
|
||||
'@stylistic/function-call-spacing': [
|
||||
'error',
|
||||
@@ -83,11 +106,7 @@ const style = {
|
||||
],
|
||||
'@stylistic/function-paren-newline': [
|
||||
'error',
|
||||
'multiline'
|
||||
],
|
||||
'@stylistic/function-call-argument-newline': [
|
||||
'error',
|
||||
'consistent'
|
||||
'multiline-arguments'
|
||||
],
|
||||
'@stylistic/implicit-arrow-linebreak': [
|
||||
'error',
|
||||
@@ -97,6 +116,10 @@ const style = {
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'@stylistic/indent-binary-ops': [
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'@stylistic/key-spacing': [
|
||||
'error',
|
||||
{
|
||||
@@ -118,18 +141,31 @@ const style = {
|
||||
'@stylistic/max-len': [
|
||||
'warn',
|
||||
{
|
||||
'code': 90,
|
||||
'comments': 100,
|
||||
'code': 140,
|
||||
'comments': 160,
|
||||
'ignoreComments': false,
|
||||
'ignoreUrls': true,
|
||||
'ignoreStrings': false
|
||||
'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/newline-per-chained-call': 'error',
|
||||
'@stylistic/no-confusing-arrow': 'error',
|
||||
'@stylistic/no-extra-parens': [
|
||||
'error',
|
||||
'all',
|
||||
@@ -167,17 +203,17 @@ const style = {
|
||||
'always'
|
||||
],
|
||||
'@stylistic/object-property-newline': 'error',
|
||||
'@stylistic/one-var-declaration-per-line': 'error',
|
||||
'@stylistic/operator-linebreak': [
|
||||
'error',
|
||||
'before'
|
||||
],
|
||||
'@stylistic/one-var-declaration-per-line': 'error',
|
||||
'@stylistic/padded-blocks': [
|
||||
'error',
|
||||
{
|
||||
'blocks': 'never',
|
||||
'classes': 'always',
|
||||
'switches': 'never',
|
||||
'switches': 'never'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -561,7 +597,7 @@ const style = {
|
||||
'blankLine': 'always',
|
||||
'prev': 'class',
|
||||
'next': '*'
|
||||
},
|
||||
}
|
||||
],
|
||||
'@stylistic/quote-props': [
|
||||
'error',
|
||||
@@ -613,17 +649,23 @@ const style = {
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@stylistic/template-curly-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@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',
|
||||
'@stylistic/ts/type-annotation-spacing': 'error'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -642,15 +684,15 @@ export default tseslint.config(
|
||||
'ecmaVersion': 'latest',
|
||||
'globals': globals.browser,
|
||||
'parserOptions': {
|
||||
'parser': tseslint.parser,
|
||||
},
|
||||
'parser': tseslint.parser
|
||||
}
|
||||
},
|
||||
'plugins': {
|
||||
'vue': vue,
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic/js': stylistic,
|
||||
'@stylistic/ts': stylistic,
|
||||
'@typescript-eslint': typescript,
|
||||
'@typescript-eslint': typescript
|
||||
},
|
||||
'extends': [
|
||||
eslint.configs.recommended,
|
||||
@@ -694,9 +736,9 @@ export default tseslint.config(
|
||||
'error',
|
||||
{
|
||||
'singleline': 3,
|
||||
'multiline': 1,
|
||||
'multiline': 1
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
+19
-41
@@ -1,43 +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": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
"@stylistic/eslint-plugin": "^5.0.0",
|
||||
"@types/express-session": "^1.18.0",
|
||||
"eslint-plugin-vue": "^10.2.0",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.35.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@janishutz/login-sdk-server": "^1.2.0",
|
||||
"@janishutz/login-sdk-server-stubs": "^1.0.0",
|
||||
"@janishutz/store-sdk": "^1.1.0",
|
||||
"@types/body-parser": "^1.19.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.6",
|
||||
"body-parser": "^1.20.2",
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.19.2",
|
||||
"express-session": "^1.18.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"node-mysql": "^0.4.2",
|
||||
"socket.io": "^4.7.5"
|
||||
}
|
||||
"dependencies": {
|
||||
"@types/cors": "^2.8.19",
|
||||
"cors": "^2.8.6",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"jsonwebtoken": "^9.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@stylistic/eslint-plugin": "^5.10.0",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^26.2.0",
|
||||
"eslint": "^10.9.0",
|
||||
"eslint-plugin-vue": "^10.10.0",
|
||||
"globals": "^17.11.0",
|
||||
"typescript-eslint": "^8.67.0"
|
||||
}
|
||||
}
|
||||
|
||||
+38
-252
@@ -1,277 +1,63 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import path from 'path';
|
||||
import expressSession from 'express-session';
|
||||
import fs from 'fs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import cors from 'cors';
|
||||
import {
|
||||
createServer
|
||||
} from 'node:http';
|
||||
import crypto from 'node:crypto';
|
||||
import {
|
||||
SocketData
|
||||
} from './definitions';
|
||||
|
||||
|
||||
// ┌ ┐
|
||||
// │ Handle FOSS vs paid version │
|
||||
// └ ┘
|
||||
const isFossVersion = false;
|
||||
|
||||
import storeSDK from '@janishutz/store-sdk';
|
||||
import sdk from '@janishutz/login-sdk-server';
|
||||
import sse from './sse';
|
||||
import socket from './socket';
|
||||
import logger from './logger';
|
||||
|
||||
// const isFossVersion = true;
|
||||
//
|
||||
// import storeSDK from './sdk/store-sdk-stub';
|
||||
// import sdk from '@janishutz/login-sdk-server-stubs';
|
||||
|
||||
|
||||
const corsOpts: cors.CorsOptions = {
|
||||
'credentials': true,
|
||||
'origin': ( origin, cb ) => {
|
||||
if ( isFossVersion ) cb( null, true );
|
||||
else cb( null, origin === 'https://music.janishutz.com' );
|
||||
}
|
||||
};
|
||||
import path from 'path';
|
||||
|
||||
|
||||
const run = () => {
|
||||
const app = express();
|
||||
const httpServer = createServer( app );
|
||||
|
||||
if ( !isFossVersion ) {
|
||||
logger.info( '[ APP ] Starting in non-FOSS version' );
|
||||
|
||||
const storeConfig = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/store-sdk.config.secret.json'
|
||||
) ).toString() );
|
||||
|
||||
storeSDK.configure( storeConfig );
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────
|
||||
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/sdk.config.secret.json'
|
||||
) ).toString() );
|
||||
|
||||
// Load id.janishutz.com SDK and allow signing in
|
||||
sdk.setUp(
|
||||
{
|
||||
'prod': false,
|
||||
'service': {
|
||||
'serviceID': 'jh-music',
|
||||
'serviceToken': sdkConfig[ 'token' ]
|
||||
},
|
||||
'user-agent': sdkConfig[ 'ua' ],
|
||||
'sessionType': 'memory',
|
||||
'frontendURL': 'https://music.janishutz.com',
|
||||
'corsWhitelist': [ 'https://music.janishutz.com' ],
|
||||
'recheckTimeout': 300 * 1000,
|
||||
'advancedVerification': 'sdk',
|
||||
},
|
||||
app,
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
() => {
|
||||
return new Promise( resolve => {
|
||||
resolve( true );
|
||||
} );
|
||||
},
|
||||
);
|
||||
}
|
||||
app.use( expressSession( {
|
||||
'secret': 'dev',
|
||||
'resave': true,
|
||||
'saveUninitialized': false
|
||||
} ) );
|
||||
|
||||
|
||||
/*
|
||||
Configuration of SSE or WebSocket
|
||||
*/
|
||||
const socketData: SocketData = {};
|
||||
|
||||
sse.useSSE( app, socketData, corsOpts, sdk.getSessionID, sdk.getSignedIn );
|
||||
socket.useWebSocket( httpServer, socketData );
|
||||
|
||||
|
||||
/*
|
||||
GENERAL ROUTES
|
||||
*/
|
||||
app.get( '/', ( _request: express.Request, response: express.Response ) => {
|
||||
response.send( 'Please visit <a href="https://music.janishutz.com">https://music.janishutz.com</a> to use this service' );
|
||||
app.get( '/', ( request: express.Request, response: express.Response ) => {
|
||||
response.send( 'HELLO WORLD' );
|
||||
} );
|
||||
|
||||
|
||||
|
||||
app.get(
|
||||
'/createRoomToken',
|
||||
cors( corsOpts ),
|
||||
sdk.loginCheck(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
// eslint-disable-next-line no-constant-binary-expression
|
||||
const roomName = String( request.query.roomName ) ?? '';
|
||||
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() );
|
||||
|
||||
if ( !socketData[ roomName ] ) {
|
||||
const roomToken = crypto.randomUUID();
|
||||
|
||||
socketData[ roomName ] = {
|
||||
'playbackStart': 0,
|
||||
'playbackStatus': false,
|
||||
'playlist': [],
|
||||
'playlistIndex': 0,
|
||||
'roomName': roomName,
|
||||
'roomToken': roomToken,
|
||||
'ownerUID': sdk.getUID( request ),
|
||||
'useAntiTamper': request.query.useAntiTamper === 'true'
|
||||
? true : false,
|
||||
};
|
||||
logger.debug( `Created room "${ roomName }"` );
|
||||
response.send( roomToken );
|
||||
} else {
|
||||
if (
|
||||
socketData[ roomName ].ownerUID
|
||||
=== sdk.getUID( request )
|
||||
) {
|
||||
response.send( socketData[ roomName ].roomToken );
|
||||
} else {
|
||||
response.status( 409 ).send( 'ERR_CONFLICT' );
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
app.get(
|
||||
'/getAppleMusicDevToken',
|
||||
cors( corsOpts ),
|
||||
sdk.loginCheck(),
|
||||
( req, res ) => {
|
||||
checkIfOwned( req )
|
||||
.then( owned => {
|
||||
if ( owned ) {
|
||||
// sign dev token
|
||||
const privateKey = fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/apple_private_key.p8'
|
||||
) ).toString();
|
||||
const config = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/config/apple-music-api.config.secret.json'
|
||||
) ).toString() );
|
||||
const now = new Date().getTime();
|
||||
const tomorrow = now + ( 24 * 3600 * 1000 );
|
||||
const jwtToken = jwt.sign( {
|
||||
'iss': config.teamID,
|
||||
'iat': Math.floor( now / 1000 ),
|
||||
'exp': Math.floor( tomorrow / 1000 ),
|
||||
}, privateKey, {
|
||||
'algorithm': 'ES256',
|
||||
'keyid': config.keyID
|
||||
} );
|
||||
|
||||
res.send( jwtToken );
|
||||
} else {
|
||||
res.status( 402 ).send( 'ERR_NOT_OWNED' );
|
||||
}
|
||||
} )
|
||||
.catch( e => {
|
||||
if ( e === 'ERR_NOT_OWNED' ) {
|
||||
res.status( 402 ).send( e );
|
||||
} else if ( e === 'ERR_AUTH_REQUIRED' ) {
|
||||
res.status( 401 ).send( e );
|
||||
} else {
|
||||
res.send( 500 ).send( e );
|
||||
}
|
||||
} );
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
const ownedCache = {};
|
||||
|
||||
const checkIfOwned = ( request: express.Request ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const uid = sdk.getUID( request );
|
||||
|
||||
if ( ownedCache[ uid ] ) {
|
||||
resolve( ownedCache[ uid ] );
|
||||
} else {
|
||||
storeSDK.getSubscriptions( uid )
|
||||
.then( stat => {
|
||||
logger.log( 'Subscription check was successful' );
|
||||
const now = new Date().getTime();
|
||||
|
||||
for ( const sub in stat ) {
|
||||
if ( stat[ sub ].expires - now > 0
|
||||
&& (
|
||||
stat[ sub ].id
|
||||
=== 'com.janishutz.MusicPlayer.subscription'
|
||||
|| stat[ sub ].id
|
||||
=== 'com.janishutz.MusicPlayer.subscription-month'
|
||||
)
|
||||
) {
|
||||
ownedCache[ uid ] = true;
|
||||
resolve( true );
|
||||
}
|
||||
}
|
||||
|
||||
ownedCache[ uid ] = false;
|
||||
resolve( false );
|
||||
} )
|
||||
.catch( e => {
|
||||
logger.error( 'Subscription check unsuccessful with error', e );
|
||||
reject( 'ERR_NOT_OWNED' );
|
||||
} );
|
||||
}
|
||||
app.get( '/dev-token', cors( {
|
||||
'origin': 'http://localhost:8081',
|
||||
'credentials': true
|
||||
} ), ( _request: express.Request, response: express.Response ) => {
|
||||
// sign dev token
|
||||
const now = new Date().getTime();
|
||||
const tomorrow = now + ( 24 * 3600 * 1000 );
|
||||
const jwtToken = jwt.sign( {
|
||||
'iss': musicApiConfig.teamID,
|
||||
'iat': Math.floor( now / 1000 ),
|
||||
'exp': Math.floor( tomorrow / 1000 )
|
||||
}, privateKey, {
|
||||
'algorithm': 'ES256',
|
||||
'keyid': musicApiConfig.keyID
|
||||
} );
|
||||
};
|
||||
|
||||
app.get(
|
||||
'/checkUserStatus',
|
||||
cors( corsOpts ),
|
||||
sdk.loginCheck(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
checkIfOwned( request )
|
||||
.then( owned => {
|
||||
if ( owned ) {
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.status( 402 ).send( 'ERR_NOT_OWNED' );
|
||||
}
|
||||
} )
|
||||
.catch( e => {
|
||||
if ( e === 'ERR_NOT_OWNED' ) {
|
||||
response.status( 402 ).send( e );
|
||||
} else if ( e === 'ERR_AUTH_REQUIRED' ) {
|
||||
response.status( 401 ).send( e );
|
||||
} else {
|
||||
response.send( 500 ).send( e );
|
||||
}
|
||||
} );
|
||||
}
|
||||
);
|
||||
response.send( jwtToken );
|
||||
} );
|
||||
|
||||
app.use( ( request: express.Request, response: express.Response ) => {
|
||||
response.status( 404 ).send( 'ERR_NOT_FOUND: ' + request.path );
|
||||
app.use( ( _request: express.Request, response: express.Response ) => {
|
||||
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 {
|
||||
|
||||
Vendored
-23
@@ -1,23 +0,0 @@
|
||||
export interface Room {
|
||||
'playbackStatus': boolean;
|
||||
'playbackStart': number;
|
||||
'playlist': Song[];
|
||||
'playlistIndex': number;
|
||||
'roomName': string;
|
||||
'roomToken': string;
|
||||
'ownerUID': string;
|
||||
'useAntiTamper': boolean;
|
||||
}
|
||||
|
||||
export interface Song {
|
||||
'title': string;
|
||||
'artist': string;
|
||||
'duration': number;
|
||||
'cover': string;
|
||||
'additionalInfo'?: string;
|
||||
}
|
||||
|
||||
export interface SocketData {
|
||||
[key: string]: Room;
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ const configure = ( location: 'stderr' | 'file', minLevel: LogLevel, file?: stri
|
||||
throw new Error( 'File parameter required when location is "file"' );
|
||||
}
|
||||
|
||||
loc = location === 'stderr' ? 'stderr' : file;
|
||||
loc = location === 'stderr' ? 'stderr' : ( file ?? 'musicplayer.log' );
|
||||
lev = levels.indexOf( minLevel );
|
||||
};
|
||||
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import http from 'node:http';
|
||||
import {
|
||||
Server
|
||||
} from 'socket.io';
|
||||
import type {
|
||||
SocketData,
|
||||
Song
|
||||
} from './definitions';
|
||||
|
||||
const useWebSocket = ( httpServer: http.Server, socketData: SocketData ) => {
|
||||
// Websocket for events
|
||||
const io = new Server( httpServer, {
|
||||
'cors': {
|
||||
'origin': true,
|
||||
'credentials': true,
|
||||
}
|
||||
} );
|
||||
|
||||
io.on( 'connection', socket => {
|
||||
socket.on( 'create-room', ( room: {
|
||||
'name': string,
|
||||
'token': string
|
||||
}, cb: ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => void ) => {
|
||||
if ( socketData[ room.name ] ) {
|
||||
if ( room.token === socketData[ room.name ].roomToken ) {
|
||||
socket.join( room.name );
|
||||
cb( {
|
||||
'status': true,
|
||||
'msg': 'ADDED_TO_ROOM'
|
||||
} );
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_TOKEN_INVALID'
|
||||
} );
|
||||
}
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_NAME_INVALID'
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'delete-room', ( room: {
|
||||
'name': string,
|
||||
'token': string
|
||||
}, cb: ( res: {
|
||||
'status': boolean,
|
||||
'msg': string
|
||||
} ) => void ) => {
|
||||
if ( socketData[ room.name ] ) {
|
||||
if ( room.token === socketData[ room.name ].roomToken ) {
|
||||
socket.leave( room.name );
|
||||
socket.to( room.name ).emit( 'delete-share', room.name );
|
||||
socketData[ room.name ] = undefined;
|
||||
cb( {
|
||||
'status': true,
|
||||
'msg': 'ROOM_DELETED'
|
||||
} );
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_TOKEN_INVALID'
|
||||
} );
|
||||
}
|
||||
} else {
|
||||
cb( {
|
||||
'status': false,
|
||||
'msg': 'ERR_NAME_INVALID'
|
||||
} );
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'join-room', ( room: string, cb: ( res: {
|
||||
'status': boolean,
|
||||
'msg': string,
|
||||
'data'?: {
|
||||
'playbackStatus': boolean,
|
||||
'playbackStart': number,
|
||||
'playlist': Song[],
|
||||
'playlistIndex': number,
|
||||
'useAntiTamper': boolean
|
||||
}
|
||||
} ) => void ) => {
|
||||
if ( socketData[ room ] ) {
|
||||
socket.join( room );
|
||||
cb( {
|
||||
'data': {
|
||||
'playbackStart': socketData[ room ].playbackStart,
|
||||
'playbackStatus': socketData[ room ].playbackStatus,
|
||||
'playlist': socketData[ room ].playlist,
|
||||
'playlistIndex': socketData[ room ].playlistIndex,
|
||||
'useAntiTamper': socketData[ room ].useAntiTamper,
|
||||
},
|
||||
'msg': 'STATUS_OK',
|
||||
'status': true,
|
||||
} );
|
||||
} else {
|
||||
cb( {
|
||||
'msg': 'ERR_NO_ROOM_WITH_THIS_ID',
|
||||
'status': false,
|
||||
} );
|
||||
socket.disconnect();
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'tampering', ( data: {
|
||||
'msg': string,
|
||||
'roomName': string
|
||||
} ) => {
|
||||
if ( data.roomName ) {
|
||||
socket.to( data.roomName ).emit( 'tampering-msg', data.msg );
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playlist-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': Song[]
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
if ( socketData[ data.roomName ].playlist !== data.data ) {
|
||||
socketData[ data.roomName ].playlist = data.data;
|
||||
io.to( data.roomName ).emit( 'playlist', data.data );
|
||||
}
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playback-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': boolean
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
socketData[ data.roomName ].playbackStatus = data.data;
|
||||
io.to( data.roomName ).emit( 'playback', data.data );
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playlist-index-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': number
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
socketData[ data.roomName ].playlistIndex = data.data;
|
||||
io.to( data.roomName ).emit( 'playlist-index', data.data );
|
||||
}
|
||||
}
|
||||
} );
|
||||
|
||||
socket.on( 'playback-start-update', ( data: {
|
||||
'roomName': string,
|
||||
'roomToken': string,
|
||||
'data': number
|
||||
} ) => {
|
||||
if ( socketData[ data.roomName ] ) {
|
||||
if ( socketData[ data.roomName ].roomToken === data.roomToken ) {
|
||||
socketData[ data.roomName ].playbackStart = data.data;
|
||||
io.to( data.roomName ).emit( 'playback-start', data.data );
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
export default {
|
||||
useWebSocket
|
||||
};
|
||||
@@ -1,286 +0,0 @@
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
import bodyParser from 'body-parser';
|
||||
import {
|
||||
SocketData
|
||||
} from './definitions';
|
||||
import logger from './logger';
|
||||
|
||||
const useSSE = (
|
||||
app: express.Application,
|
||||
socketData: SocketData,
|
||||
corsOpts: cors.CorsOptions,
|
||||
getSessionID: ( request: express.Request ) => string,
|
||||
getSignedIn: ( request: express.Request ) => boolean
|
||||
) => {
|
||||
/*
|
||||
ROUTES FOR SERVER SENT EVENTS VERSION
|
||||
*/
|
||||
// Connected clients have their session ID as key
|
||||
interface SocketClientList {
|
||||
[key: string]: SocketClient;
|
||||
}
|
||||
|
||||
interface SocketClient {
|
||||
'response': express.Response;
|
||||
'room': string;
|
||||
}
|
||||
|
||||
interface ClientReferenceList {
|
||||
/**
|
||||
* Find all clients connected to one room
|
||||
*/
|
||||
[key: string]: string[];
|
||||
}
|
||||
|
||||
const importantClients: SocketClientList = {};
|
||||
const connectedClients: SocketClientList = {};
|
||||
const clientReference: ClientReferenceList = {};
|
||||
|
||||
app.get(
|
||||
'/socket/connection',
|
||||
cors( corsOpts ),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.query.room ) {
|
||||
if ( socketData[ String( request.query.room ) ] ) {
|
||||
response.writeHead( 200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
} );
|
||||
response.status( 200 );
|
||||
response.flushHeaders();
|
||||
response.write( `data: ${ JSON.stringify( {
|
||||
'type': 'basics',
|
||||
'data': socketData[ String( request.query.room ) ]
|
||||
} ) }\n\n` );
|
||||
const sid = getSessionID( request );
|
||||
|
||||
if ( getSignedIn( request ) ) {
|
||||
importantClients[ sid ] = {
|
||||
'response': response,
|
||||
'room': String( request.query.room )
|
||||
};
|
||||
}
|
||||
|
||||
connectedClients[ sid ] = {
|
||||
'response': response,
|
||||
'room': String( request.query.room )
|
||||
};
|
||||
|
||||
if ( !clientReference[ String( request.query.room ) ] ) {
|
||||
clientReference[ String( request.query.room ) ] = [];
|
||||
}
|
||||
|
||||
if ( !clientReference[ String( request.query.room ) ]
|
||||
.includes( sid ) ) {
|
||||
clientReference[ String( request.query.room ) ].push( sid );
|
||||
}
|
||||
|
||||
request.on( 'close', () => {
|
||||
try {
|
||||
importantClients[ sid ] = undefined;
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( e ) { /* empty */ }
|
||||
|
||||
const cl = clientReference[ String( request.query.room ) ];
|
||||
|
||||
for ( const c in cl ) {
|
||||
if ( cl[ c ] === sid ) {
|
||||
try {
|
||||
cl.splice( parseInt( c ), 1 );
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch ( _ ) { /* empty */ }
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
connectedClients[ sid ] = undefined;
|
||||
} );
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_NO_ROOM_SPECIFIED' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/socket/getData',
|
||||
cors( corsOpts ),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.query.room ) {
|
||||
response.send( socketData[ String( request.query.room ) ] );
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.get(
|
||||
'/socket/joinRoom',
|
||||
cors( corsOpts ),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.query.room ) {
|
||||
if ( socketData[ String( request.query.room ) ] ) {
|
||||
logger.debug( `Room "${ request.query.room }" was joined` );
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_NO_ROOM_SPECIFIED' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.options( '/socket/update', cors( corsOpts ) );
|
||||
app.post(
|
||||
'/socket/update',
|
||||
cors( corsOpts ),
|
||||
bodyParser.json(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( socketData[ request.body.roomName ] ) {
|
||||
if ( request.body.event === 'tampering' ) {
|
||||
logger.debug( `Room "${
|
||||
request.query.roomName }" has new event: Tampering` );
|
||||
|
||||
const clients = clientReference[ request.body.roomName ];
|
||||
|
||||
if ( !clients ) {
|
||||
response.send( 'ERR_CANNOT_SEND' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const client in clients ) {
|
||||
if ( importantClients[ clients[ client ] ] ) {
|
||||
importantClients[ clients[ client ] ]
|
||||
.response.write( 'data: ' + JSON.stringify( {
|
||||
'type': 'tampering-msg',
|
||||
'data': true
|
||||
} ) + '\n\n' );
|
||||
}
|
||||
}
|
||||
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
if (
|
||||
socketData[ request.body.roomName ].roomToken
|
||||
=== request.body.roomToken
|
||||
) {
|
||||
let send = false;
|
||||
let update = '';
|
||||
|
||||
if ( request.body.event === 'playback-start-update' ) {
|
||||
send = true;
|
||||
update = 'playback-start';
|
||||
socketData[ request.body.roomName ]
|
||||
.playbackStart = request.body.data;
|
||||
} else if ( request.body.event === 'playback-update' ) {
|
||||
send = true;
|
||||
update = 'playback';
|
||||
socketData[ request.body.roomName ]
|
||||
.playbackStatus = request.body.data;
|
||||
} else if ( request.body.event === 'playlist-update' ) {
|
||||
send = true;
|
||||
update = 'playlist';
|
||||
socketData[ request.body.roomName ]
|
||||
.playlist = request.body.data;
|
||||
} else if ( request.body.event === 'playlist-index-update' ) {
|
||||
send = true;
|
||||
update = 'playlist-index';
|
||||
socketData[ request.body.roomName ]
|
||||
.playlistIndex = request.body.data;
|
||||
}
|
||||
|
||||
logger.debug( `Room "${
|
||||
request.query.roomName }" has new event: ${ update }` );
|
||||
|
||||
if ( send ) {
|
||||
const clients = clientReference[ request.body.roomName ];
|
||||
|
||||
if ( !clients ) {
|
||||
response.send( 'ERR_CANNOT_SEND' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const client in clients ) {
|
||||
if ( connectedClients[ clients[ client ] ] ) {
|
||||
connectedClients[ clients[ client ] ]
|
||||
.response.write( 'data: ' + JSON.stringify( {
|
||||
'type': update,
|
||||
'data': request.body.data
|
||||
} ) + '\n\n' );
|
||||
}
|
||||
}
|
||||
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_CANNOT_SEND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 403 ).send( 'ERR_UNAUTHORIZED' );
|
||||
}
|
||||
}
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_WRONG_REQUEST' );
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
|
||||
app.options( '/socket/deleteRoom', cors( corsOpts ) );
|
||||
app.post(
|
||||
'/socket/deleteRoom',
|
||||
cors( corsOpts ),
|
||||
bodyParser.json(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( request.body.roomName ) {
|
||||
if ( socketData[ request.body.roomName ] ) {
|
||||
if (
|
||||
socketData[ request.body.roomName ].roomToken
|
||||
=== request.body.roomToken
|
||||
) {
|
||||
logger.debug( `Room "${
|
||||
request.query.roomName }" was deleted` );
|
||||
socketData[ request.body.roomName ] = undefined;
|
||||
const clients = clientReference[ request.body.roomName ];
|
||||
|
||||
if ( !clients ) {
|
||||
response.send( 'ok' );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
for ( const client in clients ) {
|
||||
if ( connectedClients[ clients[ client ] ] ) {
|
||||
connectedClients[ clients[ client ] ]
|
||||
.response.write( 'data: ' + JSON.stringify( {
|
||||
'type': 'delete-share',
|
||||
'data': true
|
||||
} ) + '\n\n' );
|
||||
}
|
||||
}
|
||||
|
||||
response.send( 'ok' );
|
||||
} else {
|
||||
response.send( 403 ).send( 'ERR_UNAUTHORIZED' );
|
||||
}
|
||||
} else {
|
||||
response.status( 404 ).send( 'ERR_ROOM_NOT_FOUND' );
|
||||
}
|
||||
} else {
|
||||
response.status( 400 ).send( 'ERR_NO_ROOM_NAME' );
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
export default {
|
||||
useSSE
|
||||
};
|
||||
@@ -1,386 +0,0 @@
|
||||
/*
|
||||
* libreevent - db.js
|
||||
*
|
||||
* Created by Janis Hutz 03/26/2023, Licensed under the GPL V3 License
|
||||
* https://janishutz.com, development@janishutz.com
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import * as sqlDB from './mysqldb.js';
|
||||
import logger from '../logger.js';
|
||||
|
||||
declare let __dirname: string | undefined;
|
||||
|
||||
if ( typeof __dirname === 'undefined' ) {
|
||||
__dirname = path.resolve( path.dirname( '' ) );
|
||||
} else {
|
||||
__dirname = __dirname + '/../';
|
||||
}
|
||||
|
||||
const dbRef = {
|
||||
'user': 'music_users',
|
||||
'users': 'music_users',
|
||||
};
|
||||
const dbh = new sqlDB.SQLDB();
|
||||
|
||||
dbh.connect();
|
||||
|
||||
/**
|
||||
* Initialize database (create tables, etc)
|
||||
* @returns {undefined}
|
||||
*/
|
||||
const initDB = (): undefined => {
|
||||
( async () => {
|
||||
logger.info( '[ DB ] Setting up...' );
|
||||
dbh.setupDB();
|
||||
logger.info( '[ DB ] Setting up complete!' );
|
||||
} )();
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieve data from the database
|
||||
* @param {string} db The name of the database
|
||||
* @param {string} column The name of the column of the data-table in which to search for the searchQuery
|
||||
* @param {string} searchQuery The query for the selected column
|
||||
* @returns {Promise<object>} Returns a promise that resolves to an object containing the results.
|
||||
*/
|
||||
const getDataSimple = ( db: string, column: string, searchQuery: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'getFilteredData',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( data => {
|
||||
resolve( data );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Use the SQL LeftJoin function to obtain data from DB.
|
||||
* @param {string} db DB name to get data from
|
||||
* @param {string} column The column in the DB in which to search for the searchQuery
|
||||
* @param {string} searchQuery The data to look for in the selected column
|
||||
* @param {string} secondTable The second table on which to perform the left join function
|
||||
* @param {object} columns The columns to return, list of objects: { 'db': TABLE NAME, 'column': COLUMN NAME })
|
||||
* @param {string} nameOfMatchingParam Which properties should be matched to get the data, e.g. order.user_id=users.id
|
||||
* @returns {Promise<Object | Error>} Returns all records from the db and all matching data specified with the matchingParam from the secondTable.
|
||||
*/
|
||||
const getDataWithLeftJoinFunction = ( db: string, column: string, searchQuery: string, secondTable: string, columns: object, nameOfMatchingParam: string ): Promise<object> => {
|
||||
/*
|
||||
LeftJoin (Select values in first table and return all corresponding values of second table):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.columns (The columns of both tables to be selected, list of objects: { 'db': TABLE NAME, 'column': COLUMN NAME })
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
*/
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const settings = {
|
||||
'command': 'LeftJoin',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery,
|
||||
'selection': '',
|
||||
'secondTable': dbRef[ secondTable ],
|
||||
'matchingParam': dbRef[ db ] + '.' + nameOfMatchingParam + '=' + dbRef[ secondTable ] + '.' + nameOfMatchingParam,
|
||||
};
|
||||
|
||||
for ( const el in columns ) {
|
||||
settings.selection += dbRef[ columns[ el ].db ] + '.' + columns[ el ].column + ',';
|
||||
}
|
||||
|
||||
settings.selection = settings.selection.slice( 0, settings.selection.length - 1 );
|
||||
dbh.query( settings, dbRef[ db ] ).then( data => {
|
||||
resolve( data );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all data from the selected database
|
||||
* @param {string} db The database of which all data should be retrieved
|
||||
* @returns {Promise<object>} Returns an object containing all data
|
||||
*/
|
||||
const getData = ( db: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'getAllData'
|
||||
}, dbRef[ db ] ).then( data => {
|
||||
resolve( data );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Write data to the database
|
||||
* @param {string} db The database in which the data should be written
|
||||
* @param {string} column The column in which to search for the data
|
||||
* @param {string} searchQuery The query with which to search
|
||||
* @param {string} data The data to write. Also include the column & searchQuery parameters, if they also need to be added
|
||||
* @returns {Promise<object>} Returns a promise that resolves to the interaction module return.
|
||||
*/
|
||||
const writeDataSimple = ( db: string, column: string, searchQuery: string, data: any ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'checkDataAvailability',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( res => {
|
||||
if ( res.length > 0 ) {
|
||||
dbh.query( {
|
||||
'command': 'updateData',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery,
|
||||
'newValues': data
|
||||
}, dbRef[ db ] ).then( dat => {
|
||||
resolve( dat );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} else {
|
||||
dbh.query( {
|
||||
'command': 'addData',
|
||||
'data': data
|
||||
}, dbRef[ db ] ).then( dat => {
|
||||
resolve( dat );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
}
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete data from the database
|
||||
* @param {string} db The database from which the data should be deleted
|
||||
* @param {string} column The column in which to search for the data
|
||||
* @param {string} searchQuery The query with which to search
|
||||
* @returns {Promise<object>} Returns a promise that resolves to the interaction module return.
|
||||
*/
|
||||
const deleteDataSimple = ( db: string, column: string, searchQuery: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'deleteData',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( dat => {
|
||||
resolve( dat );
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the data is available in the database.
|
||||
* @param {string} db The database in which to check
|
||||
* @param {string} column The column in which to search for the data
|
||||
* @param {string} searchQuery The query with which to search
|
||||
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean (true = is available)
|
||||
*/
|
||||
const checkDataAvailability = ( db: string, column: string, searchQuery: string ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
dbh.query( {
|
||||
'command': 'checkDataAvailability',
|
||||
'property': column,
|
||||
'searchQuery': searchQuery
|
||||
}, dbRef[ db ] ).then( res => {
|
||||
if ( res.length > 0 ) {
|
||||
resolve( true );
|
||||
} else {
|
||||
resolve( false );
|
||||
}
|
||||
} )
|
||||
.catch( error => {
|
||||
reject( error );
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Load multiple JSON files at once
|
||||
* @param {Array<string>} files The files which to load
|
||||
* @returns {Promise<object>} Returns the data from all files
|
||||
*/
|
||||
const getJSONDataBatch = async ( files: Array<string> ): Promise<object> => {
|
||||
const allFiles = {};
|
||||
|
||||
for ( const file in files ) {
|
||||
try {
|
||||
allFiles[ files[ file ] ] = await getJSONData( files[ file ] );
|
||||
} catch ( err ) {
|
||||
allFiles[ files[ file ] ] = 'ERROR: ' + err;
|
||||
}
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load all data from a JSON file
|
||||
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
|
||||
* @returns {Promise<object>} The data that was loaded
|
||||
*/
|
||||
const getJSONData = ( file: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
if ( data.byteLength > 0 ) {
|
||||
resolve( JSON.parse( data.toString() ) ?? {} );
|
||||
} else {
|
||||
resolve( { } );
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Load some data from a JSON file
|
||||
* @param {string} file The file to load (just file name, file must be in "/data/" folder, no file extension)
|
||||
* @param {string} identifier The identifier of the element which should be loaded
|
||||
* @returns {Promise<object>} The data that was loaded
|
||||
*/
|
||||
const getJSONDataSimple = ( file: string, identifier: string ): Promise<object> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/' + file + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
if ( data.byteLength > 0 ) {
|
||||
resolve( JSON.parse( data.toString() )[ identifier ] ?? {} );
|
||||
} else {
|
||||
resolve( { } );
|
||||
}
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Get JSON data, but synchronous (blocking)
|
||||
* @param {string} file The file to be loaded (path relative to root)
|
||||
* @returns {object} Returns the JSON file
|
||||
*/
|
||||
const getJSONDataSync = ( file: string ): object => {
|
||||
return JSON.parse( fs.readFileSync( path.join( __dirname + '/' + file ) ).toString() );
|
||||
};
|
||||
|
||||
/**
|
||||
* Description
|
||||
* @param {any} db:string
|
||||
* @param {any} identifier:string
|
||||
* @param {any} values:any
|
||||
* @returns {any}
|
||||
*/
|
||||
const writeJSONDataSimple = ( db: string, identifier: string, values: any ) => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
let dat = {};
|
||||
|
||||
if ( data.byteLength > 0 ) {
|
||||
dat = JSON.parse( data.toString() ) ?? {};
|
||||
}
|
||||
|
||||
dat[ identifier ] = values;
|
||||
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
}
|
||||
|
||||
resolve( true );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Write data to a JSON file
|
||||
* @param {string} db The database to write into
|
||||
* @param {object} data The data to write
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const writeJSONData = ( db: string, data: object ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( data ), error => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
resolve( true );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete data from a JSON file
|
||||
* @param {string} db The file to delete from (just filename, has to be in "/data/" folder, no file extension)
|
||||
* @param {string} identifier The identifier of the element to delete
|
||||
* @returns {Promise<boolean>} Returns a promise that resolves to a boolean
|
||||
*/
|
||||
const deleteJSONDataSimple = ( db: string, identifier: string ): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
fs.readFile( path.join( __dirname + '/../../data/' + db + '.json' ), ( error, data ) => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
} else {
|
||||
let dat = {};
|
||||
|
||||
if ( data.byteLength > 0 ) {
|
||||
dat = JSON.parse( data.toString() ) ?? {};
|
||||
}
|
||||
|
||||
delete dat[ identifier ];
|
||||
fs.writeFile( path.join( __dirname + '/../../data/' + db + '.json' ), JSON.stringify( dat ), error => {
|
||||
if ( error ) {
|
||||
reject( 'Error occurred: Error trace: ' + error );
|
||||
}
|
||||
|
||||
resolve( true );
|
||||
} );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
};
|
||||
|
||||
export default {
|
||||
initDB,
|
||||
checkDataAvailability,
|
||||
deleteDataSimple,
|
||||
deleteJSONDataSimple,
|
||||
getData,
|
||||
getDataSimple,
|
||||
getDataWithLeftJoinFunction,
|
||||
getJSONData,
|
||||
getJSONDataBatch,
|
||||
getJSONDataSimple,
|
||||
getJSONDataSync,
|
||||
writeDataSimple,
|
||||
writeJSONData,
|
||||
writeJSONDataSimple
|
||||
};
|
||||
@@ -1,221 +0,0 @@
|
||||
/*
|
||||
* libreevent - mysqldb.js
|
||||
*
|
||||
* Created by Janis Hutz 07/12/2023, Licensed under the GPL V3 License
|
||||
* https://janishutz.com, development@janishutz.com
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
import mysql from 'mysql';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import logger from '../logger';
|
||||
|
||||
declare let __dirname: string | undefined;
|
||||
|
||||
if ( typeof __dirname === 'undefined' ) {
|
||||
__dirname = path.resolve( path.dirname( '' ) );
|
||||
} else {
|
||||
__dirname = __dirname + '/../';
|
||||
}
|
||||
|
||||
// If the connection does not work for you, you will need to add your ip
|
||||
// to the whitelist of the database
|
||||
|
||||
class SQLConfig {
|
||||
|
||||
command: string;
|
||||
|
||||
property?: string;
|
||||
|
||||
searchQuery?: string;
|
||||
|
||||
selection?: string;
|
||||
|
||||
query?: string;
|
||||
|
||||
newValues?: object;
|
||||
|
||||
secondTable?: string;
|
||||
|
||||
matchingParam?: string;
|
||||
|
||||
data?: object;
|
||||
|
||||
}
|
||||
|
||||
class SQLDB {
|
||||
|
||||
sqlConnection: mysql.Connection;
|
||||
|
||||
isRecovering: boolean;
|
||||
|
||||
config: object;
|
||||
|
||||
constructor () {
|
||||
this.config = JSON.parse( '' + fs.readFileSync( path.join( __dirname + '/config/db.config.secret.json' ) ) );
|
||||
this.sqlConnection = mysql.createConnection( this.config );
|
||||
this.isRecovering = false;
|
||||
}
|
||||
|
||||
connect () {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
const self = this;
|
||||
|
||||
if ( this.isRecovering ) {
|
||||
logger.info( '[ SQL ] Attempting to recover from critical error' );
|
||||
this.sqlConnection = mysql.createConnection( this.config );
|
||||
this.isRecovering = false;
|
||||
}
|
||||
|
||||
this.sqlConnection.connect( err => {
|
||||
if ( err ) {
|
||||
logger.error( '[ SQL ]: An error ocurred whilst connecting: ' + err.stack );
|
||||
reject( err );
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info( '[ SQL ] Connected to database successfully' );
|
||||
self.sqlConnection.on( 'error', err => {
|
||||
if ( err.code === 'ECONNRESET' ) {
|
||||
self.isRecovering = true;
|
||||
setTimeout( () => {
|
||||
self.disconnect();
|
||||
self.connect();
|
||||
}, 1000 );
|
||||
} else {
|
||||
logger.error( err );
|
||||
}
|
||||
} );
|
||||
resolve( 'connection' );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
disconnect ( ) {
|
||||
this.sqlConnection.end();
|
||||
}
|
||||
|
||||
async setupDB () {
|
||||
this.sqlConnection.query( 'SELECT @@default_storage_engine;', ( error, results ) => {
|
||||
if ( error ) throw error;
|
||||
|
||||
if ( results[ 0 ][ '@@default_storage_engine' ] !== 'InnoDB' ) throw 'DB HAS TO USE InnoDB!';
|
||||
} );
|
||||
this.sqlConnection.query( 'CREATE TABLE music_users ( account_id INT ( 10 ) NOT NULL AUTO_INCREMENT, email TINYTEXT NOT NULL, uid TINYTEXT, lang TINYTEXT, username TINYTEXT, settings VARCHAR( 5000 ), PRIMARY KEY ( account_id ) ) ENGINE=INNODB;', error => {
|
||||
if ( error ) if ( error.code !== 'ER_TABLE_EXISTS_ERROR' ) throw error;
|
||||
|
||||
return 'DONE';
|
||||
} );
|
||||
}
|
||||
|
||||
query ( operation: SQLConfig, table: string ): Promise<Array<object>> {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
/*
|
||||
Possible operation.command values (all need the table argument of the method call):
|
||||
- getAllData: no additional instructions needed
|
||||
|
||||
- getFilteredData:
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- InnerJoin (Select values that match in both tables):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
|
||||
- LeftJoin (Select values in first table and return all corresponding values of second table):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
|
||||
- RightJoin (Select values in second table and return all corresponding values of first table):
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
- operation.selection (The columns of both tables to be selected, e.g. users.name, orders.id)
|
||||
- operation.secondTable (The second table to perform Join operation with)
|
||||
- operation.matchingParam (Which properties should be matched to get the data, e.g. order.user_id=users.id)
|
||||
|
||||
- addData:
|
||||
- operation.data (key-value pair with all data as values and column to insert into as key)
|
||||
|
||||
- deleteData:
|
||||
- operation.property (the column to search for the value)
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- updateData:
|
||||
- operation.newValues (a object with keys being the column and value being the value to be inserted into that column, values are being
|
||||
sanitised by the function)
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- checkDataAvailability:
|
||||
- operation.property (the column to search for the value),
|
||||
- operation.searchQuery (the value to search for [will be sanitised by method])
|
||||
|
||||
- fullCustomCommand:
|
||||
- operation.query (the SQL instruction to be executed) --> NOTE: This command will not be sanitised, so use only with proper sanitisation!
|
||||
*/
|
||||
let command = '';
|
||||
|
||||
if ( operation.command === 'getAllData' ) {
|
||||
command = 'SELECT * FROM ' + table;
|
||||
} else if ( operation.command === 'getFilteredData' || operation.command === 'checkDataAvailability' ) {
|
||||
command = 'SELECT * FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
} else if ( operation.command === 'fullCustomCommand' ) {
|
||||
command = operation.query;
|
||||
} else if ( operation.command === 'addData' ) {
|
||||
let keys = '';
|
||||
let values = '';
|
||||
|
||||
for ( const key in operation.data ) {
|
||||
keys += String( key ) + ', ';
|
||||
values += this.sqlConnection.escape( String( operation.data[ key ] ) ) + ', ';
|
||||
}
|
||||
|
||||
command = 'INSERT INTO ' + table + ' (' + keys.slice( 0, keys.length - 2 ) + ') VALUES (' + values.slice( 0, values.length - 2 ) + ');';
|
||||
} else if ( operation.command === 'updateData' ) {
|
||||
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
|
||||
else {
|
||||
command = 'UPDATE ' + table + ' SET ';
|
||||
let updatedValues = '';
|
||||
|
||||
for ( const value in operation.newValues ) {
|
||||
updatedValues += value + ' = ' + this.sqlConnection.escape( String( operation.newValues[ value ] ) ) + ', ';
|
||||
}
|
||||
|
||||
command += updatedValues.slice( 0, updatedValues.length - 2 );
|
||||
command += ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
}
|
||||
} else if ( operation.command === 'deleteData' ) {
|
||||
if ( !operation.property || !operation.searchQuery ) reject( 'Refusing to run destructive command: Missing Constraints' );
|
||||
else {
|
||||
command = 'DELETE FROM ' + table + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
}
|
||||
} else if ( operation.command === 'InnerJoin' ) {
|
||||
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' INNER JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
} else if ( operation.command === 'LeftJoin' ) {
|
||||
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' LEFT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
} else if ( operation.command === 'RightJoin' ) {
|
||||
command = 'SELECT ' + operation.selection + ' FROM ' + table + ' RIGHT JOIN ' + operation.secondTable + ' ON ' + operation.matchingParam + ' WHERE ' + operation.property + ' = ' + this.sqlConnection.escape( operation.searchQuery );
|
||||
}
|
||||
|
||||
this.sqlConnection.query( command, ( error, results ) => {
|
||||
if ( error ) reject( error );
|
||||
|
||||
resolve( results );
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export {
|
||||
SQLConfig, SQLDB
|
||||
};
|
||||
@@ -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 );
|
||||
|
||||
+16
-11
@@ -1,13 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": ["node"],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": [ "./src/**/*" ],
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"allowJs": true,
|
||||
"target": "ES6",
|
||||
"skipLibCheck": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"types": [
|
||||
"node"
|
||||
],
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext"
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# MusicPlayer Webapp
|
||||
More notes to come
|
||||
@@ -1,15 +1,15 @@
|
||||
import vue from 'eslint-plugin-vue';
|
||||
import eslint from '@eslint/js';
|
||||
import globals from 'globals';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import stylistic from '@stylistic/eslint-plugin';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import typescript from '@typescript-eslint/eslint-plugin';
|
||||
import vue from 'eslint-plugin-vue';
|
||||
|
||||
const style = {
|
||||
'plugins': {
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic/js': stylistic,
|
||||
'@stylistic/ts': stylistic,
|
||||
'@stylistic/ts': stylistic
|
||||
},
|
||||
'files': [
|
||||
'**/*.ts',
|
||||
@@ -21,20 +21,25 @@ const style = {
|
||||
],
|
||||
'rules': {
|
||||
'sort-imports': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
"ignoreCase": false,
|
||||
"ignoreDeclarationSort": false,
|
||||
"ignoreMemberSort": false,
|
||||
"memberSyntaxSortOrder": ["none", "all", "multiple", "single"],
|
||||
"allowSeparatedGroups": false
|
||||
'ignoreCase': false,
|
||||
'ignoreDeclarationSort': false,
|
||||
'ignoreMemberSort': false,
|
||||
'memberSyntaxSortOrder': [
|
||||
'none',
|
||||
'all',
|
||||
'multiple',
|
||||
'single'
|
||||
],
|
||||
'allowSeparatedGroups': false
|
||||
}
|
||||
],
|
||||
// Formatting
|
||||
'@stylistic/array-bracket-newline': [
|
||||
'error',
|
||||
{
|
||||
'multiline': true,
|
||||
'multiline': false,
|
||||
'minItems': 2
|
||||
}
|
||||
],
|
||||
@@ -45,7 +50,8 @@ const style = {
|
||||
'@stylistic/array-element-newline': [
|
||||
'error',
|
||||
{
|
||||
'multiline': true,
|
||||
'consistent': false,
|
||||
'multiline': false,
|
||||
'minItems': 2
|
||||
}
|
||||
],
|
||||
@@ -66,7 +72,14 @@ const style = {
|
||||
],
|
||||
'@stylistic/brace-style': [
|
||||
'error',
|
||||
'1tbs'
|
||||
'1tbs',
|
||||
{
|
||||
'allowSingleLine': false
|
||||
}
|
||||
],
|
||||
'@stylistic/comma-dangle': [
|
||||
'error',
|
||||
'never'
|
||||
],
|
||||
'@stylistic/comma-spacing': [
|
||||
'error',
|
||||
@@ -83,9 +96,9 @@ const style = {
|
||||
'error',
|
||||
'property'
|
||||
],
|
||||
'@stylistic/eol-last': [
|
||||
'@stylistic/function-call-argument-newline': [
|
||||
'error',
|
||||
'always'
|
||||
'consistent'
|
||||
],
|
||||
'@stylistic/function-call-spacing': [
|
||||
'error',
|
||||
@@ -93,13 +106,7 @@ const style = {
|
||||
],
|
||||
'@stylistic/function-paren-newline': [
|
||||
'error',
|
||||
{
|
||||
'minItems': 3
|
||||
}
|
||||
],
|
||||
'@stylistic/function-call-argument-newline': [
|
||||
'error',
|
||||
'consistent'
|
||||
'multiline-arguments'
|
||||
],
|
||||
'@stylistic/implicit-arrow-linebreak': [
|
||||
'error',
|
||||
@@ -109,6 +116,10 @@ const style = {
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'@stylistic/indent-binary-ops': [
|
||||
'error',
|
||||
4
|
||||
],
|
||||
'@stylistic/key-spacing': [
|
||||
'error',
|
||||
{
|
||||
@@ -130,18 +141,31 @@ const style = {
|
||||
'@stylistic/max-len': [
|
||||
'warn',
|
||||
{
|
||||
'code': 120,
|
||||
'comments': 140,
|
||||
'code': 140,
|
||||
'comments': 160,
|
||||
'ignoreComments': false,
|
||||
'ignoreUrls': true,
|
||||
'ignoreStrings': false
|
||||
'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/newline-per-chained-call': 'error',
|
||||
'@stylistic/no-confusing-arrow': 'error',
|
||||
'@stylistic/no-extra-parens': [
|
||||
'error',
|
||||
'all',
|
||||
@@ -179,17 +203,17 @@ const style = {
|
||||
'always'
|
||||
],
|
||||
'@stylistic/object-property-newline': 'error',
|
||||
'@stylistic/one-var-declaration-per-line': 'error',
|
||||
'@stylistic/operator-linebreak': [
|
||||
'error',
|
||||
'before'
|
||||
],
|
||||
'@stylistic/one-var-declaration-per-line': 'error',
|
||||
'@stylistic/padded-blocks': [
|
||||
'error',
|
||||
{
|
||||
'blocks': 'never',
|
||||
'classes': 'always',
|
||||
'switches': 'never',
|
||||
'switches': 'never'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -573,7 +597,7 @@ const style = {
|
||||
'blankLine': 'always',
|
||||
'prev': 'class',
|
||||
'next': '*'
|
||||
},
|
||||
}
|
||||
],
|
||||
'@stylistic/quote-props': [
|
||||
'error',
|
||||
@@ -625,17 +649,23 @@ const style = {
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@stylistic/template-curly-spacing': [
|
||||
'error',
|
||||
'always'
|
||||
],
|
||||
'@stylistic/switch-colon-spacing': 'error',
|
||||
'@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',
|
||||
'@stylistic/ts/type-annotation-spacing': 'error'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -654,15 +684,15 @@ export default tseslint.config(
|
||||
'ecmaVersion': 'latest',
|
||||
'globals': globals.browser,
|
||||
'parserOptions': {
|
||||
'parser': tseslint.parser,
|
||||
},
|
||||
'parser': tseslint.parser
|
||||
}
|
||||
},
|
||||
'plugins': {
|
||||
'vue': vue,
|
||||
'@stylistic': stylistic,
|
||||
'@stylistic/js': stylistic,
|
||||
'@stylistic/ts': stylistic,
|
||||
'@typescript-eslint': typescript,
|
||||
'@typescript-eslint': typescript
|
||||
},
|
||||
'extends': [
|
||||
eslint.configs.recommended,
|
||||
@@ -706,9 +736,9 @@ export default tseslint.config(
|
||||
'error',
|
||||
{
|
||||
'singleline': 3,
|
||||
'multiline': 1,
|
||||
'multiline': 1
|
||||
}
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -5,20 +5,13 @@
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200">
|
||||
<!-- TODO: Update URL -->
|
||||
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
|
||||
<script src="https://static.janishutz.com/libs/jquery/jquery.min.js"></script>
|
||||
<title>MusicPlayer</title>
|
||||
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>This application requires JavaScript to work!</noscript>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
localStorage.setItem('music-player-config', 'sse');// Or 'ws'
|
||||
</script>
|
||||
<noscript>This application requires JavaScript to work!</noscript>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -1,17 +1,3 @@
|
||||
<template>
|
||||
<div>
|
||||
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="material-symbols-outlined" v-html="theme"></span>
|
||||
</button>
|
||||
<router-view id="main-view" v-slot="{ Component, route }">
|
||||
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
RouterView
|
||||
@@ -20,33 +6,46 @@
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const theme = ref( '☼' );
|
||||
const theme = ref( 'dark' );
|
||||
|
||||
const changeTheme = () => {
|
||||
if ( theme.value === '☽' ) {
|
||||
if ( theme.value === 'moon' ) {
|
||||
document.documentElement.classList.remove( 'dark' );
|
||||
document.documentElement.classList.add( 'light' );
|
||||
localStorage.setItem( 'theme', '☼' );
|
||||
theme.value = '☼';
|
||||
} else if ( theme.value === '☼' ) {
|
||||
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', '☽' );
|
||||
theme.value = '☽';
|
||||
localStorage.setItem( 'theme', 'dark' );
|
||||
theme.value = 'moon';
|
||||
}
|
||||
};
|
||||
|
||||
theme.value = localStorage.getItem( 'theme' ) ?? '';
|
||||
|
||||
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === '☽' ) {
|
||||
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === 'dark' ) {
|
||||
document.documentElement.classList.add( 'dark' );
|
||||
theme.value = '☽';
|
||||
theme.value = 'moon';
|
||||
} else {
|
||||
document.documentElement.classList.add( 'light' );
|
||||
theme.value = '☼';
|
||||
theme.value = 'sun';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||
<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">
|
||||
<component :is="Component" />
|
||||
</transition>
|
||||
</router-view>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
body {
|
||||
background-color: var( --background-color );
|
||||
@@ -57,7 +56,7 @@
|
||||
--secondary-color: white;
|
||||
--background-color: rgb(221, 221, 221);
|
||||
--nav-background: white;
|
||||
--hover-color: #00457a;
|
||||
--accent-color: #00457a;
|
||||
--popup-color: rgb(224, 224, 224);
|
||||
--overlay-color: rgba(0, 0, 0, 0.7);
|
||||
--PI: 3.14159265358979;
|
||||
@@ -74,7 +73,7 @@
|
||||
--background-color: rgb(32, 32, 32);
|
||||
--nav-background: rgb(54, 54, 54);
|
||||
--popup-color: rgb(58, 58, 58);
|
||||
--hover-color: #007ddd;
|
||||
--accent-color: #007ddd;
|
||||
--overlay-color: rgba(104, 104, 104, 0.575);
|
||||
--gray-color: rgb(207, 207, 207);
|
||||
--footer-background: rgb(53, 53, 53);
|
||||
@@ -90,7 +89,7 @@
|
||||
--background-color: rgb(32, 32, 32);
|
||||
--nav-background: rgb(54, 54, 54);
|
||||
--popup-color: rgb(58, 58, 58);
|
||||
--hover-color: #007ddd;
|
||||
--accent-color: #007ddd;
|
||||
--overlay-color: rgba(104, 104, 104, 0.575);
|
||||
--gray-color: rgb(207, 207, 207);
|
||||
--footer-background: rgb(53, 53, 53);
|
||||
@@ -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 );
|
||||
}
|
||||
} );
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
|
||||
const play = () => {
|
||||
musickitInstance.play();
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
musickitInstance.pause();
|
||||
};
|
||||
|
||||
const seekTo = ( pos: number ) => {
|
||||
musickitInstance.seekToTime( pos * musickitInstance.currentPlaybackDuration );
|
||||
};
|
||||
|
||||
const playSong = async ( id: string ) => {
|
||||
await musickitInstance.setQueue( {
|
||||
'song': id
|
||||
} );
|
||||
musickitInstance.play();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
musickitInstance.stop();
|
||||
};
|
||||
|
||||
const getPlaybackPos = (): number => {
|
||||
return musickitInstance.currentPlaybackTime / musickitInstance.currentPlaybackDuration;
|
||||
};
|
||||
|
||||
const getDuration = (): number => {
|
||||
return musickitInstance.currentPlaybackDuration;
|
||||
};
|
||||
|
||||
const loggedIn = ref( musickitInstance.isAuthorized );
|
||||
|
||||
musickitInstance.addEventListener( 'authorizationStatusDidChange', () => {
|
||||
loggedIn.value = musickitInstance.isAuthorized;
|
||||
} );
|
||||
|
||||
return {
|
||||
play,
|
||||
pause,
|
||||
seekTo,
|
||||
playSong,
|
||||
stop,
|
||||
getPlaybackPos,
|
||||
getDuration,
|
||||
loggedIn
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import type {
|
||||
Artwork
|
||||
} from 'musickitjs-v3-types/enums';
|
||||
|
||||
export interface AppleMusicApiSearchResult {
|
||||
'results': {
|
||||
'songs': {
|
||||
'data': AppleMusicSongData[],
|
||||
'href': string;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export interface AppleMusicSongData {
|
||||
'id': string,
|
||||
'type': string;
|
||||
'href': string;
|
||||
'attributes': {
|
||||
'albumName': string;
|
||||
'artistName': string;
|
||||
'artwork': Artwork;
|
||||
'name': string;
|
||||
'genreNames': string[];
|
||||
'durationInMillis': number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppleMusicPlaylistData {
|
||||
'id': string;
|
||||
'type': string;
|
||||
'href': string;
|
||||
'attributes': {
|
||||
'artwork': Artwork;
|
||||
'name': string;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
openImportTypePicker
|
||||
} from '@/composables/importTypePicker';
|
||||
import {
|
||||
searchPlaylists
|
||||
} from './playlists';
|
||||
import {
|
||||
searchSongs
|
||||
} from './songs';
|
||||
|
||||
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
|
||||
const songs = await searchSongs( instance, cb );
|
||||
const playlists = await searchPlaylists( instance, cb );
|
||||
|
||||
openImportTypePicker( [
|
||||
{
|
||||
'name': 'Songs',
|
||||
'type': 'cloud',
|
||||
'addSelected': songs.addSelected,
|
||||
'search': songs.search
|
||||
},
|
||||
{
|
||||
'name': 'Playlists',
|
||||
'type': 'cloud',
|
||||
'addSelected': playlists.addSelected,
|
||||
'search': playlists.search
|
||||
}
|
||||
] );
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import type {
|
||||
AppleMusicPlaylistData
|
||||
} from './dtype';
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||
let playlists: Song[] = [];
|
||||
let filtered: Song[] = [];
|
||||
|
||||
const search = async ( term: string ): Promise<Song[]> => {
|
||||
if ( playlists.length === 0 ) {
|
||||
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
|
||||
'limit': 100
|
||||
} ) ).data as {
|
||||
'data': AppleMusicPlaylistData[]
|
||||
} ).data.map( val => {
|
||||
return {
|
||||
'artist': 'Playlist',
|
||||
'artwork': val.attributes.artwork ? window.MusicKit.formatArtworkURL( val.attributes.artwork, 1000, 100 ) : '',
|
||||
'duration': 0,
|
||||
'identifier': val.id,
|
||||
'additional-info': '',
|
||||
'name': val.attributes.name,
|
||||
'source': 'applemusic'
|
||||
};
|
||||
} );
|
||||
}
|
||||
|
||||
filtered = playlists.filter( val => {
|
||||
return val.name.includes( term );
|
||||
} );
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const addSelected = async ( idx: number ) => {
|
||||
// TODO: Implement
|
||||
cb( [] );
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
addSelected
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import type {
|
||||
AppleMusicApiSearchResult,
|
||||
AppleMusicSongData
|
||||
} from './dtype';
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||
let songs: Song[] = [];
|
||||
|
||||
const search = async ( term: string ): Promise<Song[]> => {
|
||||
const params = {
|
||||
'term': term,
|
||||
'types': [ 'songs' ]
|
||||
};
|
||||
|
||||
let results: AppleMusicSongData[] = [];
|
||||
|
||||
try {
|
||||
results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
|
||||
} catch {
|
||||
console.debug( 'Failed results: got', results );
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
songs = [];
|
||||
|
||||
for ( const result of results ) {
|
||||
songs.push( {
|
||||
'duration': Math.round( result.attributes.durationInMillis / 1000 ),
|
||||
'name': result.attributes.name,
|
||||
'additional-info': '',
|
||||
'artist': result.attributes.artistName,
|
||||
'artwork': window.MusicKit.formatArtworkURL( result.attributes.artwork, 1000, 1000 ),
|
||||
'identifier': result.id,
|
||||
'source': 'applemusic'
|
||||
} );
|
||||
}
|
||||
|
||||
return songs;
|
||||
};
|
||||
|
||||
const addSelected = async ( idx: number ) => {
|
||||
cb( [ songs[idx]! ] );
|
||||
};
|
||||
|
||||
return {
|
||||
search,
|
||||
addSelected
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
type Ref,
|
||||
ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
PlayerSourcePlugin
|
||||
} from './plugins/interface';
|
||||
import type {
|
||||
Playlist
|
||||
} from '../dtype/playlist';
|
||||
import type {
|
||||
RepeatMode
|
||||
} from '../dtype/player';
|
||||
import {
|
||||
useLocalPlayer
|
||||
} from './plugins/local';
|
||||
import {
|
||||
useMusicKit
|
||||
} from './plugins/musickit';
|
||||
|
||||
export const sources: {
|
||||
[key: string]: PlayerSourcePlugin
|
||||
} = {};
|
||||
|
||||
export const currentSource = ref( '' );
|
||||
|
||||
export const rawQueue: Ref<Playlist> = ref( [] );
|
||||
|
||||
export const queueIdx = ref( 0 );
|
||||
|
||||
export const queue: Ref<Playlist> = ref( [] );
|
||||
|
||||
export const isPlaying = ref( false );
|
||||
|
||||
export const shuffle = ref( false );
|
||||
|
||||
export const repeat: Ref<RepeatMode> = ref( 'off' );
|
||||
|
||||
const initSources = async () => {
|
||||
try {
|
||||
sources['applemusic'] = await useMusicKit();
|
||||
} catch {
|
||||
console.warn( 'MusicKitJS intialization failed' );
|
||||
}
|
||||
|
||||
try {
|
||||
sources['local'] = await useLocalPlayer();
|
||||
} catch {
|
||||
console.warn( 'Local Player intialization failed' );
|
||||
}
|
||||
};
|
||||
|
||||
initSources();
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
currentSource,
|
||||
queueIdx,
|
||||
repeat,
|
||||
sources
|
||||
} from './state';
|
||||
import {
|
||||
playIndex
|
||||
} from './playlists';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
// Tracking of time and player status
|
||||
let interval = -1;
|
||||
|
||||
export const playbackPercentage = ref( 0 );
|
||||
|
||||
export const duration = ref( 0 );
|
||||
|
||||
export const startTracking = () => {
|
||||
if ( interval === -1 ) {
|
||||
interval = setInterval( tracker, 250 );
|
||||
}
|
||||
|
||||
duration.value = sources[currentSource.value]?.getDuration() ?? -1;
|
||||
setTimeout( () => {
|
||||
duration.value = sources[currentSource.value]?.getDuration() ?? -1;
|
||||
}, 2000 );
|
||||
};
|
||||
|
||||
const tracker = () => {
|
||||
playbackPercentage.value = sources[currentSource.value]?.getPlaybackPos() ?? -1;
|
||||
|
||||
if ( playbackPercentage.value > 0.995 && duration.value > 0 ) {
|
||||
if ( repeat.value === 'one' ) {
|
||||
sources[currentSource.value]?.seekTo( 0 );
|
||||
} else {
|
||||
stopTracking();
|
||||
playIndex( queueIdx.value + 1 );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const stopTracking = () => {
|
||||
try {
|
||||
clearInterval( interval );
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
|
||||
interval = -1;
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Get a human-readable representation of time
|
||||
* @param time - The time to convert, in seconds
|
||||
* @returns The time, in human-readable format
|
||||
*/
|
||||
export const beautifyTime = ( time: number ): string => {
|
||||
const helper = ( t: number, depth: number ): string => {
|
||||
if ( depth > 2 )
|
||||
return `${ Math.floor( t / 60 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
|
||||
else if ( Math.floor( t / 60 ) > 0 )
|
||||
return `${ helper( Math.floor( t / 60 ), depth + 1 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
|
||||
else
|
||||
return `${ depth === 1 ? '00:' : '' }${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
|
||||
};
|
||||
|
||||
if ( time < 0 || isNaN( time ) ) {
|
||||
return '--:--';
|
||||
} else if ( time == 0 ) {
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
return helper( Math.round( time ), 1 );
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user