mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
Compare commits
6
Commits
68bbfa7896
...
6d9853f2c1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d9853f2c1 | ||
|
|
054fda2fb9 | ||
|
|
867374fa47 | ||
|
|
a5910df441 | ||
|
|
cb5cdc483f | ||
|
|
18bdf82bcf |
@@ -0,0 +1 @@
|
||||
{"playlists":[],"userid":"server-stubs","version":"1"}
|
||||
+5
-1
@@ -8,11 +8,13 @@ import {
|
||||
import devtoken from './routes/devtoken';
|
||||
import express from 'express';
|
||||
import fs from 'fs';
|
||||
import logger from './logger';
|
||||
import path from 'path';
|
||||
import room from './routes/room';
|
||||
import user from './routes/user';
|
||||
|
||||
const run = () => {
|
||||
// FIXME: Use DB instead of memory optionally
|
||||
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
|
||||
__dirname,
|
||||
'/../config/sdk.config.testing.json'
|
||||
@@ -26,6 +28,8 @@ const run = () => {
|
||||
const storeSdk = getStoreSdk( foss );
|
||||
const app = express();
|
||||
|
||||
app.use( logger.routeLogging() );
|
||||
|
||||
// Load id.janishutz.com SDK and allow signing in
|
||||
sdk.setUp(
|
||||
{
|
||||
@@ -76,7 +80,7 @@ const run = () => {
|
||||
|
||||
// Load extra routes
|
||||
devtoken.routes( app, foss );
|
||||
room.routes( app, foss );
|
||||
room.routes( app, foss, config );
|
||||
user.routes( app, foss );
|
||||
|
||||
|
||||
|
||||
+16
-7
@@ -1,29 +1,37 @@
|
||||
import express from 'express';
|
||||
import {
|
||||
writeFile
|
||||
} from 'node:fs';
|
||||
|
||||
const log = ( ...msg: unknown[] ) => {
|
||||
output( 'log', log.caller.toString(), ...msg );
|
||||
output( 'log', 'UNKNOWN', ...msg );
|
||||
};
|
||||
|
||||
const info = ( ...msg: unknown[] ) => {
|
||||
output( 'info', log.caller.toString(), ...msg );
|
||||
output( 'info', 'UNKNOWN', ...msg );
|
||||
};
|
||||
|
||||
const debug = ( ...msg: unknown[] ) => {
|
||||
output( 'debug', log.caller.toString(), ...msg );
|
||||
output( 'debug', 'UNKNOWN', ...msg );
|
||||
};
|
||||
|
||||
const warn = ( ...msg: unknown[] ) => {
|
||||
output( 'warn', log.caller.toString(), ...msg );
|
||||
output( 'warn', 'UNKNOWN', ...msg );
|
||||
};
|
||||
|
||||
const error = ( ...msg: unknown[] ) => {
|
||||
output( 'error', log.caller.toString(), ...msg );
|
||||
output( 'error', 'UNKNOWN', ...msg );
|
||||
};
|
||||
|
||||
const fatal = ( ...msg: unknown[] ) => {
|
||||
output( 'fatal', log.caller.toString(), ...msg );
|
||||
output( 'fatal', 'UNKNOWN', ...msg );
|
||||
};
|
||||
|
||||
const routeLogging = () => {
|
||||
return ( request: express.Request, _response: express.Response, next: express.NextFunction ) => {
|
||||
output( 'info', 'ROUTER', request.originalUrl, 'from', request.headers['user-agent'] ?? 'No UA' );
|
||||
next();
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -100,5 +108,6 @@ export default {
|
||||
warn,
|
||||
error,
|
||||
fatal,
|
||||
configure
|
||||
configure,
|
||||
routeLogging
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
Client,
|
||||
Room,
|
||||
RoomStore
|
||||
} from './room';
|
||||
import {
|
||||
@@ -16,7 +17,7 @@ const rooms: RoomStore = {};
|
||||
* @param room - The name of the room to get
|
||||
* @returns The room, or undefined if it is not present
|
||||
*/
|
||||
const get = ( room: string ) => {
|
||||
const get = ( room: string ): Room | undefined => {
|
||||
return rooms[room];
|
||||
};
|
||||
|
||||
@@ -114,7 +115,9 @@ const close = ( name: string, uid: string ) => {
|
||||
* @returns true if update succeeded
|
||||
*/
|
||||
const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number ) => {
|
||||
if ( !rooms[room] || rooms[room].owner !== uid ) return false;
|
||||
if ( !rooms[room] || rooms[room].owner !== uid ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
rooms[room].state = {
|
||||
'playing': playing,
|
||||
@@ -149,10 +152,12 @@ const sendUpdate = ( room: string, kind: 'state' | 'playlist' ) => {
|
||||
|
||||
if ( !roomObject ) return false;
|
||||
|
||||
roomObject.clients.forEach( client => client.response.write( `data: ${ {
|
||||
roomObject.clients.forEach( client => client.response.write( `data: json:${ JSON.stringify( {
|
||||
'type': kind,
|
||||
'data': JSON.stringify( roomObject[kind] )
|
||||
} }\n\n` ) );
|
||||
'data': roomObject[kind]
|
||||
} ) }\n\n` ) );
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
Config
|
||||
} from '../../dtype/config';
|
||||
import express from 'express';
|
||||
import {
|
||||
generateToken
|
||||
} from '../../token';
|
||||
import rooms from '.';
|
||||
|
||||
export const sseMiddleware = ( kind: 'client' | 'trackingClient' ) => {
|
||||
export const sseMiddleware = ( kind: 'client' | 'trackingClient', config?: Config ) => {
|
||||
return ( request: express.Request, response: express.Response ) => {
|
||||
if ( typeof request.params.id !== 'string' )
|
||||
return response.sendStatus( 400 );
|
||||
|
||||
const room = rooms.get( request.params.id );
|
||||
|
||||
if ( !room ) response.sendStatus( 404 );
|
||||
if ( !room ) return response.sendStatus( 404 );
|
||||
|
||||
response.writeHead( 200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
@@ -20,7 +23,11 @@ export const sseMiddleware = ( kind: 'client' | 'trackingClient' ) => {
|
||||
} );
|
||||
response.status( 200 );
|
||||
response.flushHeaders();
|
||||
response.write( 'data: connected\n\n' );
|
||||
|
||||
if ( kind === 'trackingClient' || config?.clientMode === 'poll' )
|
||||
response.write( 'data: use-poll\n\n' );
|
||||
else
|
||||
response.write( 'data: connected\n\n' );
|
||||
|
||||
const token = generateToken( 20 );
|
||||
|
||||
|
||||
@@ -18,6 +18,14 @@ export const getUserFile = async ( uid: string ): Promise<UserPlaylistFile> => {
|
||||
} );
|
||||
};
|
||||
|
||||
const createUserFile = async ( uid: string ): Promise<void> => {
|
||||
writeUserFile( uid, {
|
||||
'playlists': [],
|
||||
'userid': uid,
|
||||
'version': '1'
|
||||
} );
|
||||
};
|
||||
|
||||
export const getUserFilePath = ( uid: string ): string => {
|
||||
return path.join( __dirname, '/../../../data/', uid + '.json' );
|
||||
};
|
||||
@@ -43,5 +51,6 @@ export default {
|
||||
getUserFilePath,
|
||||
getUserFile,
|
||||
writeUserFile,
|
||||
testUserFileExists
|
||||
testUserFileExists,
|
||||
createUserFile
|
||||
};
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
} from '../../sdk';
|
||||
import express from 'express';
|
||||
|
||||
const cache = {};
|
||||
const cache: {
|
||||
[key: string]: boolean
|
||||
} = {};
|
||||
|
||||
export const getOwnershipManager = ( foss: boolean ) => {
|
||||
const storeSdk = getStoreSdk( foss );
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
Config
|
||||
} from '../../dtype/config';
|
||||
import express from 'express';
|
||||
import {
|
||||
getLoginSdk
|
||||
@@ -9,24 +12,24 @@ import rooms from '../../manager/rooms';
|
||||
import tracking from './tracking';
|
||||
import update from './update';
|
||||
|
||||
const routes = ( app: express.Application, foss: boolean ) => {
|
||||
const routes = ( app: express.Application, foss: boolean, config: Config ) => {
|
||||
const sdk = getLoginSdk( foss );
|
||||
const ownership = getOwnershipManager( foss );
|
||||
|
||||
tracking.routes( app, foss );
|
||||
update.routes( app, foss );
|
||||
update.routes( app, foss, config );
|
||||
|
||||
app.get(
|
||||
'/room/create',
|
||||
sdk.loginCheck(),
|
||||
ownership.middleware(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( !request.query.room ) return response.sendStatus( 400 );
|
||||
if ( !request.query.room || !( /^[a-zA-Z0-9-]{3,20}/ ).test( String( request.query.room ) ) ) return response.sendStatus( 400 );
|
||||
|
||||
if ( rooms.create( String( request.query.room ), sdk.getUID( request )! ) )
|
||||
response.sendStatus( 200 );
|
||||
else
|
||||
response.sendStatus( 500 );
|
||||
response.sendStatus( 409 );
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const routes = ( app: express.Application, foss: boolean ) => {
|
||||
'/room/:id/admin',
|
||||
corsManager.middleware( false ),
|
||||
sdk.loginCheck(),
|
||||
sseMiddleware( 'client' )
|
||||
sseMiddleware( 'trackingClient' )
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import {
|
||||
Config
|
||||
} from '../../dtype/config';
|
||||
import bodyParser from 'body-parser';
|
||||
import corsManager from '../../corsManager';
|
||||
import express from 'express';
|
||||
@@ -10,11 +13,11 @@ import {
|
||||
sseMiddleware
|
||||
} from '../../manager/rooms/sse';
|
||||
|
||||
const routes = ( app: express.Application, foss: boolean ) => {
|
||||
const routes = ( app: express.Application, foss: boolean, config: Config ) => {
|
||||
const sdk = getLoginSdk( foss );
|
||||
|
||||
// FIXME: Here, if not in sse mode, simply close the connection after sending 'poll'
|
||||
app.get( '/room/:id/connect', corsManager.middleware( false ), sseMiddleware( 'client' ) );
|
||||
app.get( '/room/:id/connect', corsManager.middleware( false ), sseMiddleware( 'client', config ) );
|
||||
|
||||
app.get( '/room/:id/poll', corsManager.middleware( false ), ( request: express.Request, response: express.Response ) => {
|
||||
if ( typeof request.params.id !== 'string' )
|
||||
@@ -28,8 +31,8 @@ const routes = ( app: express.Application, foss: boolean ) => {
|
||||
|
||||
response.send(
|
||||
JSON.stringify( {
|
||||
'state': room.state,
|
||||
'playlist': lastRequest < room.playlist.lastUpdate ? room.playlist : undefined
|
||||
'state': room?.state,
|
||||
'playlist': ( lastRequest < room!.playlist!.lastUpdate || isNaN( lastRequest ) ) ? room!.playlist.playlist : undefined
|
||||
} )
|
||||
);
|
||||
} );
|
||||
@@ -48,7 +51,7 @@ const routes = ( app: express.Application, foss: boolean ) => {
|
||||
if ( rooms.updatePlaylist(
|
||||
request.params.id,
|
||||
sdk.getUID( request )!,
|
||||
request.body.playlist ? JSON.parse( request.body.playlist ) : []
|
||||
request.body.playlist ?? []
|
||||
) )
|
||||
response.sendStatus( 200 );
|
||||
else
|
||||
@@ -67,8 +70,9 @@ const routes = ( app: express.Application, foss: boolean ) => {
|
||||
sdk.loginCheck(),
|
||||
bodyParser.json(),
|
||||
( request: express.Request, response: express.Response ) => {
|
||||
if ( typeof request.params.id !== 'string' )
|
||||
if ( typeof request.params.id !== 'string' ) {
|
||||
return response.sendStatus( 400 );
|
||||
}
|
||||
|
||||
try {
|
||||
if ( rooms.updateState(
|
||||
|
||||
+24
-1
@@ -1,4 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
Notifications
|
||||
} from '@kyvg/vue3-notification';
|
||||
import {
|
||||
RouterView
|
||||
} from 'vue-router';
|
||||
@@ -35,6 +38,14 @@
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<notifications
|
||||
position="top center"
|
||||
:duration="5000"
|
||||
class="notifications"
|
||||
style="top: 20px;"
|
||||
width="400px"
|
||||
:max="3"
|
||||
/>
|
||||
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||
<i :class="['fa-solid', 'fa-' + theme]"></i>
|
||||
</button>
|
||||
@@ -46,11 +57,23 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
<style lang="scss">
|
||||
body {
|
||||
background-color: var( --background-color );
|
||||
}
|
||||
|
||||
.notifications {
|
||||
.vue-notification {
|
||||
padding: 20px;
|
||||
.notification-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
.notification-text {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root, :root.light {
|
||||
--primary-color: #0a1520;
|
||||
--secondary-color: white;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import AssociationView from '@/composables/AssociationView.vue';
|
||||
import PlayerComponent from './PlayerComponent.vue';
|
||||
import SmallPlayerComponent from './SmallPlayerComponent.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const fullPlayer = ref( true );
|
||||
fullPlayer
|
||||
} from '@/ts/player/state';
|
||||
|
||||
const close = () => {
|
||||
fullPlayer.value = false;
|
||||
@@ -14,6 +13,7 @@
|
||||
|
||||
<template>
|
||||
<div class="player-wrapper">
|
||||
<AssociationView />
|
||||
<SmallPlayerComponent v-model="fullPlayer" />
|
||||
<div :class="['player-container', fullPlayer ? undefined : 'hidden']">
|
||||
<i class="fa-solid fa-xmark" @click="close"></i>
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import UserPlaylists from '../playlists/UserPlaylists.vue';
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>Playlists</h1>
|
||||
<UserPlaylists />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
ref
|
||||
} from 'vue';
|
||||
import ProgressBar from './ProgressBar.vue';
|
||||
import ShareManagement from './ShareManagement.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 showShareMenu = ref( false );
|
||||
|
||||
const seek = () => {
|
||||
player.seekTo( playbackPercentage.value );
|
||||
@@ -30,7 +33,7 @@
|
||||
};
|
||||
|
||||
const openShareMenu = () => {
|
||||
alert( 'Share menu not yet implemented' );
|
||||
showShareMenu.value = true;
|
||||
};
|
||||
|
||||
const current = computed( () => {
|
||||
@@ -39,13 +42,11 @@
|
||||
const duration = computed( () => {
|
||||
return beautifyTime( player.duration.value );
|
||||
} );
|
||||
|
||||
|
||||
// TODO: Button availability
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mp-player">
|
||||
<ShareManagement v-model="showShareMenu" />
|
||||
<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>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
'required': true,
|
||||
'default': 0.5
|
||||
} );
|
||||
const moveVal = ref( 0 );
|
||||
const offset = ref( -1 );
|
||||
const isMoving = ref( false );
|
||||
const bar = useTemplateRef( 'bar' );
|
||||
@@ -15,19 +16,20 @@
|
||||
const start = ( ev: MouseEvent ) => {
|
||||
offset.value = bar.value!.getBoundingClientRect().x;
|
||||
isMoving.value = true;
|
||||
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||
moveVal.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 ) );
|
||||
moveVal.value = Math.max( 0, Math.min( ( ev.x - offset.value ) / bar.value!.clientWidth, 1 ) );
|
||||
}
|
||||
};
|
||||
|
||||
const end = () => {
|
||||
const end = ( ev: MouseEvent ) => {
|
||||
if ( !isMoving.value ) return;
|
||||
|
||||
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||
offset.value = -1;
|
||||
isMoving.value = false;
|
||||
emit( 'move-end' );
|
||||
@@ -42,7 +44,7 @@
|
||||
<template>
|
||||
<div class="progressbar">
|
||||
<div ref="bar" class="back">
|
||||
<div :style="`width: ${ val * 100 }%;`"></div>
|
||||
<div :style="`width: ${ isMoving ? moveVal * 100 : val * 100 }%;`"></div>
|
||||
</div>
|
||||
<div
|
||||
:class="['click-target', offset >= 0 ? 'active' : undefined]"
|
||||
|
||||
@@ -18,6 +18,9 @@
|
||||
import {
|
||||
queueIdx
|
||||
} from '@/ts/player/state';
|
||||
import {
|
||||
savePlaylist
|
||||
} from '@/ts/userPlaylists/save';
|
||||
|
||||
const queue: WritableComputedRef<Song[]> = computed( {
|
||||
get () {
|
||||
@@ -56,15 +59,10 @@
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
Clear
|
||||
</button>
|
||||
<button>
|
||||
<button @click="savePlaylist">
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import messages, {
|
||||
isConnected
|
||||
} from '@/ts/messages';
|
||||
import PopupElement from '../popups/PopupElement.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const showPopup = defineModel<boolean>( {
|
||||
'required': true
|
||||
} );
|
||||
const shareName = ref( '' );
|
||||
const useAntiTamper = ref( false );
|
||||
const errorMessage = ref( '' );
|
||||
|
||||
const startShare = async () => {
|
||||
if ( !await messages.createRoom( shareName.value, useAntiTamper.value ) ) {
|
||||
errorMessage.value = 'Invalid room name';
|
||||
}
|
||||
};
|
||||
|
||||
const stopShare = () => {
|
||||
messages.closeRoom();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PopupElement v-model="showPopup" show-close>
|
||||
<h2>Share</h2>
|
||||
<div v-if="!isConnected">
|
||||
<p>
|
||||
You can use a share to show what you are currently listening to (and the progress) on a page.
|
||||
</p>
|
||||
<p>{{ errorMessage }}</p>
|
||||
<input v-model="shareName" type="text">
|
||||
<button @click="startShare">
|
||||
Create Share
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<!-- TODO: Need to explain and add controls -->
|
||||
<!-- TODO: How to handle anti-tamper? -->
|
||||
<p>Connected</p>
|
||||
<button @click="stopShare">
|
||||
End share
|
||||
</button>
|
||||
</div>
|
||||
</PopupElement>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import PopupElement from '../popups/PopupElement.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const showPopup = defineModel<boolean>( {
|
||||
'required': true
|
||||
} );
|
||||
|
||||
const addPlaylist = () => {
|
||||
showPopup.value = false;
|
||||
emit( 'add-playlist', playlistName.value );
|
||||
};
|
||||
|
||||
const playlistName = ref( '' );
|
||||
const emit = defineEmits<{
|
||||
( e: 'add-playlist', name: string ): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PopupElement v-model="showPopup" show-close>
|
||||
<div class="title">
|
||||
<h1>Add Playlist</h1>
|
||||
</div>
|
||||
<input v-model="playlistName" type="text" placeholder="Playlist name">
|
||||
<br>
|
||||
<button @click="addPlaylist">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add Playlist
|
||||
</button>
|
||||
</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: 45%;
|
||||
margin: 0.5%;
|
||||
height: 60%;
|
||||
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;
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
addPlaylist,
|
||||
removePlaylist
|
||||
} from '@/ts/userPlaylists';
|
||||
import {
|
||||
editingPlaylists,
|
||||
playlistIdx as playlistIdx,
|
||||
playlists
|
||||
} from '@/ts/userPlaylists/state';
|
||||
import {
|
||||
getPlaylists,
|
||||
savePlaylists
|
||||
} from '@/ts/userPlaylists/save';
|
||||
import {
|
||||
onMounted,
|
||||
ref
|
||||
} from 'vue';
|
||||
import AddPlaylist from './AddPlaylist.vue';
|
||||
import {
|
||||
UnownedError
|
||||
} from '@/ts/request';
|
||||
import player from '@/ts/player';
|
||||
import router from '@/router';
|
||||
|
||||
const checkingStatus = ref( true );
|
||||
const dots = ref( 0 );
|
||||
const showAddPlaylist = ref( false );
|
||||
|
||||
let interval = -1;
|
||||
|
||||
const loadPlaylists = async () => {
|
||||
if ( interval < 0 ) {
|
||||
interval = setInterval( () => {
|
||||
dots.value = ( dots.value + 1 ) % 4;
|
||||
}, 500 );
|
||||
}
|
||||
|
||||
try {
|
||||
await getPlaylists();
|
||||
} catch ( e ) {
|
||||
if ( e instanceof UnownedError ) {
|
||||
router.push( '/get' );
|
||||
}
|
||||
}
|
||||
|
||||
checkingStatus.value = false;
|
||||
|
||||
try {
|
||||
clearInterval( interval );
|
||||
interval = -1;
|
||||
} catch { /* Empty */ }
|
||||
};
|
||||
|
||||
onMounted( () => {
|
||||
loadPlaylists();
|
||||
} );
|
||||
|
||||
onMounted( () => {} );
|
||||
|
||||
const openAddPlaylistPopup = () => {
|
||||
showAddPlaylist.value = true;
|
||||
};
|
||||
|
||||
const togglePlaylistEditing = ( idx: number ) => {
|
||||
editingPlaylists.value[ idx ] = !editingPlaylists.value[ idx ];
|
||||
};
|
||||
|
||||
const selectPlaylist = ( idx: number ) => {
|
||||
playlistIdx.value = idx;
|
||||
player.clearQueue();
|
||||
player.loadPlaylist( playlists.value[ idx ]!.songs );
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="playlists">
|
||||
<AddPlaylist v-model="showAddPlaylist" @add-playlist="addPlaylist" />
|
||||
<div v-if="checkingStatus">
|
||||
Loading{{ '.'.repeat( dots ) }}
|
||||
</div>
|
||||
<div v-else-if="playlists.length === 0">
|
||||
No playlists
|
||||
<button @click="openAddPlaylistPopup">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add one
|
||||
</button>
|
||||
</div>
|
||||
<div v-else>
|
||||
<button @click="openAddPlaylistPopup">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add Playlist
|
||||
</button>
|
||||
<button @click="savePlaylists">
|
||||
<i class="fa-solid fa-floppy-disk"></i>
|
||||
Save Changes
|
||||
</button>
|
||||
<button @click="loadPlaylists">
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
Undo unsaved changes
|
||||
</button>
|
||||
<div v-for="(playlist, index) in playlists" :key="index">
|
||||
<input v-if="editingPlaylists[ index ]" v-model="playlist.name" type="text">
|
||||
<h2 v-else @click="() => selectPlaylist( index )">
|
||||
{{ playlist.name }}
|
||||
</h2>
|
||||
<i class="fa-solid fa-pen-to-square" @click="() => togglePlaylistEditing( index )"></i>
|
||||
<i class="fa-solid fa-trash" @click="() => removePlaylist( index )"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,55 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
ref
|
||||
} from 'vue';
|
||||
import {
|
||||
currentQueue,
|
||||
currentQueueIdx
|
||||
} from '@/ts/shared/state';
|
||||
|
||||
const songs = computed( () => currentQueue.value?.slice( currentQueueIdx.value + 1 ) ?? [] );
|
||||
// TODO: Move this out of this file
|
||||
const showArtworks = ref( false );
|
||||
const timeToPlay = computed( () => {
|
||||
return ( idx: number ) => {
|
||||
let total = 0;
|
||||
|
||||
for ( let i = 0; i < idx; i++ ) {
|
||||
total += songs.value[ i ]!.duration;
|
||||
}
|
||||
|
||||
total += currentQueue.value[ currentQueueIdx.value ]?.duration ?? 0;
|
||||
|
||||
return Math.round( total / 60 );
|
||||
};
|
||||
} );
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="queue-container">
|
||||
<div v-for="(song, index) in songs" :key="index" class="song-list-element">
|
||||
<div class="song-cover-wrapper">
|
||||
<img
|
||||
v-if="song.artwork && showArtworks"
|
||||
: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">
|
||||
<h3>{{ song.name }}</h3>
|
||||
<p>{{ song.artist }}</p>
|
||||
<p>{{ song['additional-info'] }}</p>
|
||||
</div>
|
||||
<div class="song-actions">
|
||||
<p>In {{ timeToPlay( index ) }}min</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@use '@/scss/components/queue.scss';
|
||||
</style>
|
||||
@@ -7,22 +7,23 @@
|
||||
needsFiles,
|
||||
saveAssociations
|
||||
} from './associationManager';
|
||||
import {
|
||||
fullPlayer,
|
||||
queue
|
||||
} from '@/ts/player/state';
|
||||
import {
|
||||
onMounted,
|
||||
useTemplateRef
|
||||
} from 'vue';
|
||||
import PopupElement from '@/components/popups/PopupElement.vue';
|
||||
import player from '@/ts/player';
|
||||
import {
|
||||
queue
|
||||
} from '@/ts/player/state';
|
||||
|
||||
const fileinput = useTemplateRef( 'fileinput' );
|
||||
|
||||
onMounted( () => {
|
||||
fileinput.value?.addEventListener( 'change', async () => {
|
||||
if ( fileinput.value && fileinput.value.files ) {
|
||||
associationResults.value.concat( await associationOpts.value?.get( fileinput.value.files ) ?? [] );
|
||||
await associationOpts.value?.get( fileinput.value.files );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
@@ -42,6 +43,12 @@
|
||||
|
||||
associationResults.value.splice( idx, 1 );
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
player.clearQueue();
|
||||
isShowingAssociationManager.value = false;
|
||||
fullPlayer.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -58,16 +65,15 @@
|
||||
:accept="associationOpts?.mime ?? '*'"
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length === 0">
|
||||
All files have been associated correctly
|
||||
</div>
|
||||
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length > 0">
|
||||
<div v-if="needsFiles && !isAnalyzing && associationResults.length > 0" class="association-wrapper">
|
||||
<button @click="saveAssociations">
|
||||
Save
|
||||
</button>
|
||||
<div v-for="(result, index) in associationResults" :key="index">
|
||||
<div v-for="(result, index) in associationResults" :key="index" class="association">
|
||||
<p>
|
||||
{{ result.song.name }} by {{ result.song.artist }}
|
||||
<b>{{ result.song.name.length > 30 ? result.song.name.slice( 0, 30 ) + '...' : result.song.name }}</b>
|
||||
by
|
||||
<i>{{ result.song.artist.length > 20 ? result.song.artist.slice( 0, 20 ) + '...' : result.song.artist }}</i>
|
||||
</p>
|
||||
<select v-if="result.match === 'multiple'">
|
||||
<option v-for="(file, idx) in result.possibleFiles" :key="idx" :value="idx">
|
||||
@@ -82,10 +88,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="associationResults.length === 0"></div>
|
||||
<div v-else>
|
||||
An error occurred. Please try again
|
||||
<!-- FIXME: Button to restore -->
|
||||
</div>
|
||||
<button @click="cancel">
|
||||
Cancel
|
||||
</button>
|
||||
</PopupElement>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.association-wrapper {
|
||||
width: 60vw;
|
||||
height: 50vh;
|
||||
overflow-x: hidden;
|
||||
overflow-y: scroll;
|
||||
|
||||
.association {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 3rem;
|
||||
|
||||
>div, select {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
>p {
|
||||
margin-right: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import type {
|
||||
AssociationResult
|
||||
} from '@/ts/player/plugins/interface';
|
||||
import player from '@/ts/player';
|
||||
import {
|
||||
queue
|
||||
} from '@/ts/player/state';
|
||||
@@ -21,7 +22,7 @@ export const isAnalyzing = ref( false );
|
||||
export const associationResults: Ref<AssociationResult[]> = ref( [] );
|
||||
|
||||
export const associationOpts: Ref<{
|
||||
'get': ( files: FileList ) => Promise<AssociationResult[]>,
|
||||
'get': ( files: FileList ) => Promise<void>,
|
||||
'mime': string;
|
||||
} | null> = ref( null );
|
||||
|
||||
@@ -30,8 +31,21 @@ export const openAssociationManager = ( cb: ( files: FileList ) => Promise<Assoc
|
||||
associationResults.value = [];
|
||||
needsFiles.value = true;
|
||||
isAnalyzing.value = false;
|
||||
|
||||
const callback = async ( files: FileList ): Promise<void> => {
|
||||
const results = await cb( files );
|
||||
|
||||
|
||||
if ( results?.length ?? -1 > 0 ) {
|
||||
associationResults.value = associationResults.value.concat( results! );
|
||||
} else {
|
||||
isShowingAssociationManager.value = false;
|
||||
player.playIndex( 0 );
|
||||
}
|
||||
};
|
||||
|
||||
associationOpts.value = {
|
||||
'get': cb,
|
||||
'get': callback,
|
||||
'mime': mime
|
||||
};
|
||||
};
|
||||
@@ -45,4 +59,9 @@ export const saveAssociations = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( associationResults.value.length === 0 ) {
|
||||
isShowingAssociationManager.value = false;
|
||||
player.playIndex( 0 );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import '@fortawesome/fontawesome-free/css/all.css';
|
||||
import App from './App.vue';
|
||||
import Notifications from '@kyvg/vue3-notification';
|
||||
import {
|
||||
createApp
|
||||
} from 'vue';
|
||||
@@ -12,5 +13,6 @@ const app = createApp( App );
|
||||
|
||||
app.use( createPinia() );
|
||||
app.use( router );
|
||||
app.use( Notifications );
|
||||
|
||||
app.mount( '#app' );
|
||||
|
||||
@@ -27,6 +27,15 @@ const router = createRouter( {
|
||||
'auth': true
|
||||
}
|
||||
},
|
||||
{
|
||||
'path': '/share/:name',
|
||||
'name': 'share',
|
||||
'component': () => import( '@/views/SharedView.vue' ),
|
||||
'meta': {
|
||||
'title': 'Shared',
|
||||
'auth': false
|
||||
}
|
||||
},
|
||||
{
|
||||
'path': '/:pathMatch(.*)*',
|
||||
'name': 'NotFound',
|
||||
|
||||
Vendored
+5
@@ -1,5 +1,10 @@
|
||||
// NOTE: For re-associating files with details here, can use dropdown in UI
|
||||
|
||||
export interface Playlist {
|
||||
'name': string;
|
||||
'songs': PlaylistSongs;
|
||||
}
|
||||
|
||||
export type PlaylistSongs = Song[];
|
||||
|
||||
export interface UrlToFileMapping {
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import {
|
||||
isPlaying,
|
||||
queue,
|
||||
queueIdx
|
||||
} from '../player/state';
|
||||
import {
|
||||
ref,
|
||||
watch
|
||||
} from 'vue';
|
||||
import {
|
||||
playbackPercentage
|
||||
} from '../player/status-tracking';
|
||||
import request from '../request';
|
||||
|
||||
const RETRY_CAP = 10;
|
||||
|
||||
export const room = ref( localStorage.getItem( 'room' ) ?? '' );
|
||||
|
||||
export const isConnected = ref( false );
|
||||
|
||||
export const useAntiTamper = ref( false );
|
||||
|
||||
// const antiTamperClients = [];
|
||||
|
||||
let connection: null | EventSource = null;
|
||||
let retries = 0;
|
||||
|
||||
// TODO: Persist room name in local storage
|
||||
|
||||
const createRoom = async ( name: string, antiTamper: boolean ): Promise<boolean> => {
|
||||
if ( !( /^[a-zA-Z0-9-]{3,20}$/ ).test( name ) ) return false;
|
||||
|
||||
try {
|
||||
await request.get( '/room/create?room=' + name );
|
||||
} catch ( err ) {
|
||||
if ( err === 'ERR_409' ) {
|
||||
return await connect();
|
||||
}
|
||||
}
|
||||
|
||||
localStorage.setItem( 'room', name );
|
||||
useAntiTamper.value = antiTamper;
|
||||
room.value = name;
|
||||
|
||||
return await connect();
|
||||
};
|
||||
|
||||
const closeRoom = async () => {
|
||||
// TODO: Trigger close on backend and disconnect.
|
||||
isConnected.value = false;
|
||||
localStorage.removeItem( 'room' );
|
||||
};
|
||||
|
||||
const connect = (): Promise<boolean> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
if ( !useAntiTamper.value ) {
|
||||
isConnected.value = true;
|
||||
sendPlaylistData();
|
||||
sendStateData();
|
||||
|
||||
return resolve( true );
|
||||
}
|
||||
|
||||
connection = new EventSource( request.backendURL + `/room/${ room.value }/admin`, {
|
||||
'withCredentials': true
|
||||
} );
|
||||
|
||||
connection.onopen = () => {
|
||||
isConnected.value = true;
|
||||
console.log( '[SSE] Connection established successfully' );
|
||||
sendPlaylistData();
|
||||
sendStateData();
|
||||
resolve( true );
|
||||
};
|
||||
|
||||
connection.onmessage = msg => {
|
||||
console.log( msg );
|
||||
};
|
||||
|
||||
connection.onerror = () => {
|
||||
connection?.close();
|
||||
console.error( '[SSE] Reconnecting due to error' );
|
||||
|
||||
if ( !isConnected.value ) reject( 'ERR_CONNECT' );
|
||||
|
||||
if ( retries <= RETRY_CAP ) {
|
||||
retries += 1;
|
||||
|
||||
setTimeout( () => {
|
||||
createRoom( room.value, useAntiTamper.value );
|
||||
}, 1000 * retries );
|
||||
}
|
||||
};
|
||||
} );
|
||||
};
|
||||
|
||||
// FIXME: This is not a sensible solution, but easy for now (i.e. solve properly)
|
||||
let playlistLock = false;
|
||||
let stateLock = false;
|
||||
|
||||
const sendPlaylistData = () => {
|
||||
if ( isConnected.value && !playlistLock ) {
|
||||
playlistLock = true;
|
||||
request.post( `/room/${ room.value }/update/playlist`, JSON.stringify( {
|
||||
'playlist': queue.value
|
||||
} ) );
|
||||
|
||||
setTimeout( () => {
|
||||
playlistLock = false;
|
||||
}, 500 );
|
||||
}
|
||||
};
|
||||
|
||||
const sendStateData = () => {
|
||||
if ( isConnected.value && !stateLock ) {
|
||||
stateLock = true;
|
||||
request.post( `/room/${ room.value }/update/state`, JSON.stringify( {
|
||||
'playing': isPlaying.value,
|
||||
'index': queueIdx.value,
|
||||
'start': new Date().getTime() - ( playbackPercentage.value * ( queue.value[ queueIdx.value ]?.duration ?? 0 ) ) - 100
|
||||
} ) );
|
||||
|
||||
setTimeout( () => {
|
||||
stateLock = false;
|
||||
}, 500 );
|
||||
}
|
||||
};
|
||||
|
||||
const useRoomWatchers = () => {
|
||||
watch( queue, sendPlaylistData );
|
||||
|
||||
watch( [
|
||||
isPlaying,
|
||||
queueIdx
|
||||
], sendStateData );
|
||||
|
||||
if ( room.value ) connect();
|
||||
};
|
||||
|
||||
export default {
|
||||
createRoom,
|
||||
useRoomWatchers,
|
||||
closeRoom
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ import type {
|
||||
import {
|
||||
load
|
||||
} from './playlists/loader';
|
||||
import messages from '../messages';
|
||||
import {
|
||||
playIndex
|
||||
} from './playlists';
|
||||
@@ -148,6 +149,7 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
||||
return true;
|
||||
};
|
||||
|
||||
messages.useRoomWatchers();
|
||||
|
||||
export default {
|
||||
play,
|
||||
|
||||
@@ -15,6 +15,9 @@ import {
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
playlistIdx
|
||||
} from '@/ts/userPlaylists/state';
|
||||
|
||||
export const addSongList = ( songs: Song[] ) => {
|
||||
rawQueue.value = rawQueue.value.concat( songs );
|
||||
@@ -22,6 +25,7 @@ export const addSongList = ( songs: Song[] ) => {
|
||||
};
|
||||
|
||||
export const clearQueue = () => {
|
||||
playlistIdx.value = -1;
|
||||
queue.value = [];
|
||||
rawQueue.value = [];
|
||||
sources[currentSource.value]?.stop();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
fullPlayer,
|
||||
queue,
|
||||
rawQueue,
|
||||
sources
|
||||
@@ -42,6 +43,16 @@ export const load = ( playlist: PlaylistSongs ) => {
|
||||
|
||||
if ( needToLoadLocalSongs ) {
|
||||
// FIXME: Combine mime types
|
||||
openAssociationManager( fileLoader, '' );
|
||||
const mime = Object.values( sources )
|
||||
.map( src => {
|
||||
return src.loading.requiresLocalFiles === true ? src.loading.mime : '';
|
||||
} )
|
||||
.reduce( ( prev, curr ) => {
|
||||
return prev === '' ? curr : prev + ',' + curr;
|
||||
} );
|
||||
|
||||
openAssociationManager( fileLoader, mime );
|
||||
}
|
||||
|
||||
fullPlayer.value = true;
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const currentSource = ref( '' );
|
||||
|
||||
export const rawQueue: Ref<PlaylistSongs> = ref( [] );
|
||||
|
||||
export const queueIdx = ref( 0 );
|
||||
export const queueIdx = ref( -1 );
|
||||
|
||||
export const queue: Ref<PlaylistSongs> = ref( [] );
|
||||
|
||||
@@ -36,6 +36,8 @@ export const shuffle = ref( false );
|
||||
|
||||
export const repeat: Ref<RepeatMode> = ref( 'off' );
|
||||
|
||||
export const fullPlayer = ref( false );
|
||||
|
||||
const initSources = async () => {
|
||||
try {
|
||||
sources['applemusic'] = await useMusicKit();
|
||||
|
||||
@@ -9,10 +9,13 @@ const get = async ( url: string ): Promise<Response> => {
|
||||
} );
|
||||
};
|
||||
|
||||
const backendURL = import.meta.env.VITE_BACKEND_URL;
|
||||
|
||||
const post = async ( url: string, payload: string, mime: string = 'application/json' ): Promise<Response> => {
|
||||
return await wrapper( url, {
|
||||
'credentials': 'include',
|
||||
'body': payload,
|
||||
'method': 'post',
|
||||
'headers': {
|
||||
'Content-Type': mime ?? 'application/json'
|
||||
}
|
||||
@@ -20,11 +23,12 @@ const post = async ( url: string, payload: string, mime: string = 'application/j
|
||||
};
|
||||
|
||||
const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
|
||||
const res = await fetch( import.meta.env.VITE_BACKEND_URL + url, opts );
|
||||
const res = await fetch( backendURL + url, opts );
|
||||
|
||||
if ( res.ok ) {
|
||||
return res;
|
||||
} else if ( res.status === 403 || res.status === 401 ) {
|
||||
// TODO: Handle these errors better (probably do something like in the old version, but better)
|
||||
throw new AuthError( 'ERR_USER_UNAUTHORIZED' );
|
||||
} else if ( res.status === 402 ) {
|
||||
throw new UnownedError( 'ERR_USER_UNOWNED' );
|
||||
@@ -35,5 +39,6 @@ const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
|
||||
|
||||
export default {
|
||||
get,
|
||||
post
|
||||
post,
|
||||
backendURL
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
type StateUpdate,
|
||||
messageHandler
|
||||
} from './messageHandler';
|
||||
import {
|
||||
currentQueue,
|
||||
currentQueueIdx,
|
||||
isPlaying,
|
||||
startTime
|
||||
} from './state';
|
||||
import type {
|
||||
Song
|
||||
} from '../dtype/playlist';
|
||||
import request from '../request';
|
||||
|
||||
const RETRY_CAP = 10;
|
||||
|
||||
let room = location.pathname;
|
||||
let connection: EventSource | null = null;
|
||||
let hasConnected = false;
|
||||
let retries = 0;
|
||||
|
||||
// TODO: Persist settings in local storage
|
||||
// TODO: Polling instead of sse
|
||||
|
||||
const connect = (): Promise<void> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
room = location.pathname.substring( location.pathname.lastIndexOf( '/' ) + 1 );
|
||||
connection = new EventSource( request.backendURL + `/room/${ room }/connect` );
|
||||
|
||||
connection.onopen = async () => {
|
||||
hasConnected = true;
|
||||
console.log( '[SSE] Connection established successfully' );
|
||||
const data = await ( await request.get( `/room/${ room }/poll` ) ).json() as {
|
||||
'playlist': Song[],
|
||||
'state': StateUpdate
|
||||
};
|
||||
|
||||
currentQueue.value = data.playlist;
|
||||
currentQueueIdx.value = data.state.index;
|
||||
isPlaying.value = data.state.playing;
|
||||
startTime.value = data.state.start;
|
||||
|
||||
resolve();
|
||||
};
|
||||
|
||||
connection.onmessage = msg => {
|
||||
if ( msg.data === 'close' ) {
|
||||
connection?.close();
|
||||
|
||||
// TODO: Show popup informing user that share was closed
|
||||
return;
|
||||
}
|
||||
|
||||
messageHandler( msg.data );
|
||||
};
|
||||
|
||||
connection.onerror = () => {
|
||||
connection?.close();
|
||||
|
||||
if ( !hasConnected ) return reject( 'ERR_CONNECT' );
|
||||
|
||||
console.error( '[SSE] Connection failed, reconnecting' );
|
||||
|
||||
if ( retries <= RETRY_CAP ) {
|
||||
retries += 1;
|
||||
setTimeout( () => {
|
||||
connect();
|
||||
}, 1000 * retries );
|
||||
}
|
||||
};
|
||||
} );
|
||||
};
|
||||
|
||||
export default {
|
||||
connect
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
currentQueue,
|
||||
currentQueueIdx,
|
||||
isPlaying,
|
||||
startTime
|
||||
} from './state';
|
||||
import type {
|
||||
Song
|
||||
} from '../dtype/playlist';
|
||||
|
||||
interface ReceivedJSONMessage {
|
||||
'type': 'state' | 'playlist';
|
||||
'data': unknown;
|
||||
}
|
||||
|
||||
export interface StateUpdate {
|
||||
'playing': boolean;
|
||||
'index': number;
|
||||
'start': number;
|
||||
}
|
||||
|
||||
export const messageHandler = ( msg: string ) => {
|
||||
if ( msg.startsWith( 'json:' ) ) {
|
||||
try {
|
||||
const data = JSON.parse( msg.substring( 5 ) ) as ReceivedJSONMessage;
|
||||
|
||||
if ( data.type === 'playlist' ) {
|
||||
currentQueue.value = ( data.data as {
|
||||
'playlist': Song[]
|
||||
} ?? {
|
||||
'playlist': []
|
||||
} ).playlist;
|
||||
} else if ( data.type === 'state' ) {
|
||||
const state = data.data as StateUpdate;
|
||||
|
||||
currentQueueIdx.value = state.index;
|
||||
isPlaying.value = state.playing;
|
||||
startTime.value = state.start;
|
||||
} else {
|
||||
console.log( '[SSE] Received unknown data', data.type );
|
||||
}
|
||||
} catch ( err ) {
|
||||
console.error( 'JSON DECODE failed with error', err );
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import {
|
||||
type Ref,
|
||||
ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
Song
|
||||
} from '../dtype/playlist';
|
||||
|
||||
export const currentQueue: Ref<Song[]> = ref( [] );
|
||||
|
||||
export const isPlaying = ref( false );
|
||||
|
||||
export const currentQueueIdx = ref( -1 );
|
||||
|
||||
export const startTime = ref( new Date().getTime() );
|
||||
@@ -0,0 +1,19 @@
|
||||
import {
|
||||
editingPlaylists,
|
||||
playlists
|
||||
} from './state';
|
||||
|
||||
export const addPlaylist = ( name: string ) => {
|
||||
playlists.value.push( {
|
||||
'name': name,
|
||||
'songs': []
|
||||
} );
|
||||
editingPlaylists.value.push( false );
|
||||
};
|
||||
|
||||
export const removePlaylist = ( idx: number ) => {
|
||||
if ( confirm( 'Do you really want to delete this playlist?' ) ) {
|
||||
playlists.value.splice( idx, 1 );
|
||||
editingPlaylists.value.splice( idx, 1 );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,10 +1,62 @@
|
||||
import {
|
||||
editingPlaylists,
|
||||
playlistIdx,
|
||||
playlists
|
||||
} from './state';
|
||||
import {
|
||||
queue,
|
||||
rawQueue,
|
||||
shuffle
|
||||
} from '../player/state';
|
||||
import {
|
||||
addPlaylist
|
||||
} from '.';
|
||||
import request from '../request';
|
||||
import {
|
||||
useNotification
|
||||
} from '@kyvg/vue3-notification';
|
||||
|
||||
const savePlaylist = () => {
|
||||
export const savePlaylist = async () => {
|
||||
rawQueue.value = queue.value;
|
||||
shuffle.value = false;
|
||||
|
||||
if ( playlistIdx.value ) {
|
||||
let name: null | string = '';
|
||||
|
||||
while ( !name || name.length === 0 ) {
|
||||
name = prompt( 'You are trying to save a playlist that has not previously been created. Please enter a name for it' );
|
||||
}
|
||||
|
||||
addPlaylist( name );
|
||||
playlistIdx.value = playlists.value.length - 1;
|
||||
}
|
||||
|
||||
playlists.value[ playlistIdx.value ]!.songs = rawQueue.value;
|
||||
|
||||
savePlaylists();
|
||||
};
|
||||
|
||||
export const getPlaylists = async () => {
|
||||
playlists.value = await ( await request.get( '/user/playlists' ) ).json();
|
||||
editingPlaylists.value = playlists.value.map( () => false );
|
||||
};
|
||||
|
||||
export const savePlaylists = async () => {
|
||||
const notifications = useNotification();
|
||||
|
||||
try {
|
||||
await request.post( '/user/playlists', JSON.stringify( playlists.value ) );
|
||||
notifications.notify( {
|
||||
'text': 'Playlists saved successfully',
|
||||
'type': 'success',
|
||||
'title': 'Playlists'
|
||||
} );
|
||||
} catch ( e ) {
|
||||
console.error( e );
|
||||
notifications.notify( {
|
||||
'text': 'Failed to save playlists',
|
||||
'type': 'error',
|
||||
'title': 'Playlists'
|
||||
} );
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,8 +3,11 @@ import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
PlaylistSongs
|
||||
} from '../dtype/playlist';
|
||||
Playlist
|
||||
} from './file';
|
||||
|
||||
const currentPlaylist = ref( '' );
|
||||
const playlists: Ref<PlaylistSongs> = ref( [] );
|
||||
export const playlistIdx = ref( -1 );
|
||||
|
||||
export const playlists: Ref<Playlist[]> = ref( [] );
|
||||
|
||||
export const editingPlaylists: Ref<boolean[]> = ref( [] );
|
||||
|
||||
@@ -1,33 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import PlayerWrapper from '@/components/main/PlayerWrapper.vue';
|
||||
import PlaylistsComponent from '@/components/main/PlaylistsComponent.vue';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
import request from '@/ts/request';
|
||||
import router from '@/router';
|
||||
|
||||
const checkingStatus = ref( true );
|
||||
|
||||
const ownershipCheck = async () => {
|
||||
const data = await ( await request.get( '/user/owned' ) ).json();
|
||||
|
||||
if ( data[ 'status' ] )
|
||||
router.push( '/get' );
|
||||
};
|
||||
|
||||
ownershipCheck();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-app">
|
||||
<div v-if="checkingStatus">
|
||||
Loading...
|
||||
</div>
|
||||
<div v-else>
|
||||
<PlaylistsComponent />
|
||||
<PlayerWrapper />
|
||||
</div>
|
||||
<PlaylistsComponent />
|
||||
<PlayerWrapper />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import CurrentSong from '@/components/player/CurrentSong.vue';
|
||||
import SharedQueue from '@/components/shared/SharedQueue.vue';
|
||||
import shared from '@/ts/shared';
|
||||
|
||||
shared.connect();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shared-view">
|
||||
<div>
|
||||
<div>
|
||||
<CurrentSong />
|
||||
</div>
|
||||
<div>
|
||||
<SharedQueue />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user