14 Commits
62 changed files with 1603 additions and 163 deletions
+1
View File
@@ -1,6 +1,7 @@
# ignore node_modules
node_modules
*.secret.json
*.secret.json
apple_private_key.p8
musicplayerv2-server.zip
dist
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "../config.schema.json",
"mode": "foss",
"mode": "hosted",
"clientMode": "sse",
"webUrl": "http://localhost:8081"
}
+1
View File
@@ -0,0 +1 @@
{"playlists":[{"name":"Hello World","songs":[{"duration":236,"name":"CAN'T STOP THE FEELING! (Original Song From DreamWorks Animation's \"TROLLS\")","additional-info":"Test 3","artist":"Justin Timberlake","artwork":"https://is1-ssl.mzstatic.com/image/thumb/Music124/v4/52/d0/02/52d002d7-1f7f-8d91-4791-ed04c460ec93/886445894653.jpg/2000x2000bb.jpg","identifier":"blob:http://localhost:8081/fdb6fccd-30cb-458c-a9f6-c424d2f53c43","source":"local","additional-identifier":"16_Can't_Stop_The_Feeling-Justin_Timberlake.mp3__4407330"},{"duration":258,"name":"Muévelo","additional-info":"Test","artist":"Rey Ruiz","artwork":"https://is1-ssl.mzstatic.com/image/thumb/Music122/v4/58/15/0d/58150db1-0e74-dd19-f7eb-3f15e8a04b56/197187182459.jpg/2000x2000bb.jpg","identifier":"blob:http://localhost:8081/18dcea36-839d-4714-b7fc-6800f26e07b9","source":"local","additional-identifier":"18_Muevlo-Rey_Ruiz.mp3__4831574"}]}],"userid":"server-stubs","version":"1"}
+14 -9
View File
@@ -8,24 +8,30 @@ 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 = () => {
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/../config/sdk.config.testing.json'
) ).toString() );
// FIXME: Use DB instead of memory optionally
const config = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/../config/config.json'
) ).toString() ) as Config;
const foss = config.mode !== 'hosted';
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
__dirname,
`/../config/sdk.config.${ foss ? 'testing' : 'secret' }.json`
) ).toString() );
const sdk = getLoginSdk( foss );
const storeSdk = getStoreSdk( foss );
const app = express();
logger.info( 'Starting in', foss ? 'FOSS' : 'hosted', 'mode' );
app.use( logger.routeLogging() );
// Load id.janishutz.com SDK and allow signing in
sdk.setUp(
{
@@ -39,7 +45,8 @@ const run = () => {
'frontendURL': config.webUrl,
'corsWhitelist': [ config.webUrl ],
'recheckTimeout': 300 * 1000,
'advancedVerification': 'sdk'
'advancedVerification': 'sdk',
'defaultRedirectURL': '/app'
},
app,
async () => {
@@ -62,7 +69,7 @@ const run = () => {
// Load store sdk
const storeConfig = JSON.parse( fs.readFileSync( path.join(
__dirname,
'/../config/store-sdk.config.testing.json'
`/../config/store-sdk.config.${ foss ? 'testing' : 'secret' }.json`
) ).toString() );
storeSdk.configure( storeConfig );
@@ -71,12 +78,10 @@ const run = () => {
response.redirect( config.webUrl ?? 'https://music.janishutz.com' );
} );
// TODO: Need way for frontend to get the connection type for shares
// Load extra routes
devtoken.routes( app, foss );
room.routes( app, foss );
room.routes( app, foss, config );
user.routes( app, foss );
+16 -7
View File
@@ -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
};
+16 -8
View File
@@ -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];
};
@@ -40,7 +41,8 @@ const create = ( name: string, uid: string ) => {
'index': 0,
'lastUpdate': new Date().getTime(),
'playing': false,
'start': new Date().getTime()
'start': new Date().getTime(),
'offset': 0
}
};
@@ -111,16 +113,20 @@ const close = ( name: string, uid: string ) => {
* @param playing - Set to true if the player is playing currently
* @param index - The current playback index
* @param start - The timestamp where playback of the current song started
* @param offset - The playback offset from the start in seconds
* @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;
const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number, offset: number ) => {
if ( !rooms[room] || rooms[room].owner !== uid ) {
return false;
}
rooms[room].state = {
'playing': playing,
'index': index,
'lastUpdate': new Date().getTime(),
'start': start
'start': start,
'offset': offset
};
return sendUpdate( room, 'state' );
@@ -149,10 +155,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
View File
@@ -20,6 +20,7 @@ export interface Room {
'start': number;
'playing': boolean;
'lastUpdate': number;
'offset': number;
};
'owner': string;
'clients': Client[];
+10 -3
View File
@@ -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 );
+10 -1
View File
@@ -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
};
+3 -1
View File
@@ -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 );
+7 -4
View File
@@ -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 );
}
);
+1 -1
View File
@@ -39,7 +39,7 @@ const routes = ( app: express.Application, foss: boolean ) => {
'/room/:id/admin',
corsManager.middleware( false ),
sdk.loginCheck(),
sseMiddleware( 'client' )
sseMiddleware( 'trackingClient' )
);
};
+12 -8
View File
@@ -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,10 @@ 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 +30,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 +50,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 +69,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(
@@ -76,7 +79,8 @@ const routes = ( app: express.Application, foss: boolean ) => {
sdk.getUID( request )!,
request.body.playing ?? false,
request.body.index ?? -1,
request.body.start ?? new Date().getTime()
request.body.start ?? new Date().getTime(),
request.body.offset ?? 0
) )
response.sendStatus( 200 );
else
+24 -1
View File
@@ -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;
+10 -1
View File
@@ -13,7 +13,7 @@
<div
class="panel"
>
<div style="margin-bottom: 20px; width: 100%;">
<div class="current-song-wrapper">
<CurrentSong
v-model="queue[queueIdx]"
/>
@@ -44,6 +44,15 @@
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
>.current-song-wrapper {
margin-bottom: 1.5rem;
width: 100%;
height: 100%;
max-height: calc(100% - 15rem);
overflow: hidden;
}
}
}
</style>
+5 -4
View File
@@ -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>
@@ -32,6 +32,7 @@
background-color: var(--secondary-color);
transition: bottom 1s ease;
transition-delay: 0.25s;
overflow: hidden;
&.hidden {
transition-delay: 0s;
+14 -1
View File
@@ -1,5 +1,18 @@
<script setup lang="ts">
import UserPlaylists from '../playlists/UserPlaylists.vue';
</script>
<template>
<div>
<div class="playlist-main">
<h1>Playlists</h1>
<UserPlaylists />
</div>
</template>
<style lang="scss" scoped>
.playlist-main {
height: 100%;
width: 100%;
}
</style>
@@ -1,4 +1,24 @@
<script setup lang="ts">
import {
type ComputedRef,
computed
} from 'vue';
import {
isPlaying,
queue,
queueIdx
} from '@/ts/player/state';
import type {
Song
} from '@/ts/dtype/playlist';
import {
beautifyTime
} from '@/ts/util/time';
import {
playbackPercentage
} from '@/ts/player/status-tracking';
import player from '@/ts/player';
const closed = defineModel<boolean>( {
'required': true
} );
@@ -6,30 +26,117 @@
const open = () => {
closed.value = true;
};
const song: ComputedRef<Song> = computed( () => {
if ( queueIdx.value >= 0 && queue.value.length > queueIdx.value ) {
return queue.value[ queueIdx.value ]!;
} else {
return {
'artwork': '',
'additional-info': '',
'artist': 'No artist',
'duration': -1,
'identifier': 'nosong-ident',
'name': 'Not Playing',
'source': 'local'
};
}
} );
</script>
<template>
<div :class="['small-player', closed ? 'hidden' : undefined]" @click="open">
Player
<div :class="['small-player', closed ? 'hidden' : undefined]">
<div>
<img
v-if="song.artwork"
:src="song.artwork"
alt="Song cover"
class="song-cover"
>
<i v-else class="fa-solid fa-music song-cover" @click="open"></i>
<div @click="open">
<h3>{{ song.name }}</h3>
<p>{{ song.artist }}</p>
</div>
<p @click="open">
{{ beautifyTime( playbackPercentage * song.duration ) }} / {{ beautifyTime( song.duration ) }}
</p>
<i v-if="!isPlaying" class="fa-solid fa-play" @click="player.play"></i>
<i v-else class="fa-solid fa-pause" @click="player.pause"></i>
<i class="fa-solid fa-forward-step" @click="player.next"></i>
</div>
</div>
</template>
<style lang="scss" scoped>
.small-player {
background-color: green;
background-color: var(--secondary-color);
width: 80vw;
height: 7vh;
height: 4.5rem;
position: fixed;
left: 10vw;
bottom: 15px;
border-radius: 4vh;
border-radius: 2rem;
overflow: hidden;
transition: bottom 0.5s ease;
transition-delay: 0.75s;
display: flex;
align-items: center;
justify-content: center;
&.hidden {
bottom: -8vh;
transition-delay: 0s;
}
>div {
display: flex;
align-items: center;
justify-content: center;
width: calc(100% - 6rem);
height: 100%;
>.song-cover {
display: flex;
justify-content: center;
align-items: center;
width: 3rem;
height: 3rem;
font-size: 2.5rem;
cursor: pointer;
margin-right: 30px;
}
>div {
cursor: pointer;
margin-right: auto;
display: flex;
align-items: flex-start;
justify-content: center;
flex-direction: column;
width: calc(100% - 20rem);
overflow-x: hidden;
height: 100%;
>h3 {
margin: 0;
}
>p {
margin: 0;
}
}
>p {
cursor: pointer;
margin-right: 15px;
width: max-content;
}
>.fa-solid {
font-size: 2rem;
cursor: pointer;
}
}
}
</style>
+16 -3
View File
@@ -6,6 +6,9 @@
const song = defineModel<Song>( {
'required': false
} );
const props = defineProps<{
'showAdditionalInfo'?: boolean
}>();
</script>
<template>
@@ -24,6 +27,9 @@
{{ song?.name ?? 'Not playing' }}
</h1>
<p>{{ song?.artist ?? 'No artist' }}</p>
<p v-if="props.showAdditionalInfo">
{{ song?.['additional-info'] }}
</p>
</div>
</div>
</template>
@@ -31,14 +37,21 @@
<style lang="scss" scoped>
.current-song {
width: 100%;
height: 100%;
position: relative;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.artwork {
width: 100%;
height: 100%;
max-height: calc(100% - 9rem);
>img, .fa-solid {
width: 60%;
max-height: 50%;
font-size: 15vw;
height: 100%;
font-size: 40vh;
}
}
+10 -6
View File
@@ -1,16 +1,20 @@
<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 messages from '@/ts/messages';
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 +34,7 @@
};
const openShareMenu = () => {
alert( 'Share menu not yet implemented' );
showShareMenu.value = true;
};
const current = computed( () => {
@@ -40,12 +44,12 @@
return beautifyTime( player.duration.value );
} );
// TODO: Button availability
messages.useRoomWatchers();
</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>
+12 -5
View File
@@ -8,26 +8,33 @@
'required': true,
'default': 0.5
} );
const moveVal = ref( 0 );
const offset = ref( -1 );
const isMoving = ref( false );
const bar = useTemplateRef( 'bar' );
const props = defineProps<{
'disallowMove'?: boolean
}>();
const start = ( ev: MouseEvent ) => {
if ( props.disallowMove ) return;
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,10 +49,10 @@
<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]"
:class="['click-target', offset >= 0 ? 'active' : undefined, props.disallowMove ? 'disallowed' : undefined]"
@mousedown="start"
@mousemove="move"
@mouseup="end"
+9 -7
View File
@@ -18,6 +18,9 @@
import {
queueIdx
} from '@/ts/player/state';
import {
savePlaylist
} from '@/ts/userPlaylists/save';
const queue: WritableComputedRef<Song[]> = computed( {
get () {
@@ -56,17 +59,16 @@
<i class="fa-solid fa-xmark"></i>
Clear
</button>
<button>
<button @click="editSong( -1 )">
<i class="fa-solid fa-pen-to-square"></i>
Edit Current
</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="" />
<SongEditor v-model="showEditSong" :song="editingSong" />
<AddSong v-model="showAddSong" />
<div v-if="queue.length > 0" class="queue-container">
<SortableList v-slot="{ item: song, index }" v-model="queue">
@@ -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,114 @@
<script setup lang="ts">
import {
addPlaylist,
removePlaylist,
selectPlaylist
} from '@/ts/userPlaylists';
import {
editingPlaylists,
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 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 ];
};
</script>
<template>
<div class="playlists">
<AddPlaylist v-model="showAddPlaylist" @add-playlist="addPlaylist" />
<div v-if="checkingStatus" class="playlist-wrapper">
Loading{{ '.'.repeat( dots ) }}
</div>
<div v-else-if="playlists.length === 0" class="playlist-wrapper">
No playlists
<button @click="openAddPlaylistPopup">
<i class="fa-solid fa-plus"></i>
Add one
</button>
</div>
<div v-else class="playlist-wrapper">
<div class="playlist-actions">
<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>
<div class="playlist-container">
<div v-for="(playlist, index) in playlists" :key="index" class="playlist">
<i class="fa-solid fa-circle-play" @click="() => selectPlaylist( index )"></i>
<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>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/playlists.scss';
</style>
+61 -1
View File
@@ -1,16 +1,76 @@
<script setup lang="ts">
import {
type Ref, ref,
watch
} from 'vue';
import PopupElement from './PopupElement.vue';
import type {
Song
} from '@/ts/dtype/playlist';
import player from '@/ts/player';
const model = defineModel<boolean>( {
'required': true
} );
const props = defineProps<{
'song': Song | null
}>();
const localSong: Ref<Song> = ref( {
'name': '',
'artist': '',
'artwork': '',
'additional-info': '',
'duration': -1,
'identifier': '',
'source': 'local'
} );
watch( props, () => {
if ( props.song )
localSong.value = props.song;
} );
const search = () => {
player.addSongFromSource( 'applemusic', ( songs: Song[] ) => {
const song = songs[ 0 ];
if ( !song ) return;
localSong.value.artist = song.artist;
localSong.value.artwork = song.artwork;
localSong.value.name = song.name;
}, true );
};
const save = () => {
model.value = false;
document.dispatchEvent( new CustomEvent( 'musicplayer:update' ) );
};
</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 -->
<button @click="search">
Search song on Apple Music
</button>
<label for="song-name">Song title</label>
<input id="song-name" v-model="localSong.name" type="text">
<label for="song-artist">Artist</label>
<input id="song-artist" v-model="localSong.artist" type="text">
<label for="song-artwork">Artwork URL</label>
<input id="song-artwork" v-model="localSong.artwork" type="text">
<label for="song-add-info">Additional Info</label>
<input id="song-add-info" v-model="localSong['additional-info']" type="text">
<button @click="save">
Save
</button>
</PopupElement>
</div>
</template>
@@ -0,0 +1,25 @@
<script setup lang="ts">
import PopupElement from '../popups/PopupElement.vue';
const show = defineModel<boolean>();
const props = defineProps<{
'title': string;
'message'?: string;
}>();
const close = () => {
show.value = false;
};
</script>
<template>
<div>
<PopupElement v-model="show">
<h2>{{ props.title }}</h2>
<p>{{ props.message }}</p>
<button @click="close">
Ok
</button>
</PopupElement>
</div>
</template>
@@ -0,0 +1,21 @@
<script setup lang="ts">
import PopupElement from '../popups/PopupElement.vue';
const show = defineModel<boolean>();
const close = () => {
show.value = false;
};
</script>
<template>
<div>
<!-- TODO: Beacon request when exiting browser (and confirm popup for that!) -->
<PopupElement v-model="show">
<h2>Settings</h2>
<button @click="close">
Ok
</button>
</PopupElement>
</div>
</template>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import {
currentQueue,
currentQueueIdx,
playbackTime,
showArtworks
} from '@/ts/shared/state';
import {
computed
} from 'vue';
const songs = computed( () => currentQueue.value?.slice( currentQueueIdx.value + 1 ) ?? [] );
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;
total -= playbackTime.value;
return Math.ceil( total / 60 );
};
} );
</script>
<template>
<div class="queue-viewer">
<div class="queue-container">
<div v-if="songs.length > 0" class="queue-scroll">
<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>
<div v-else class="queue-empty">
No upcoming songs
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/queue.scss';
.queue-scroll {
height: 100%;
width: 100%;
margin-top: 20px;
justify-content: flex-start !important;
overflow-y: scroll;
}
</style>
+55 -11
View File
@@ -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,18 @@
associationResults.value.splice( idx, 1 );
};
const cancel = () => {
player.clearQueue();
isShowingAssociationManager.value = false;
fullPlayer.value = false;
};
const retry = () => {
associationResults.value = [];
isAnalyzing.value = false;
needsFiles.value = true;
};
</script>
<template>
@@ -58,16 +71,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 +94,42 @@
</div>
</div>
</div>
<div v-else-if="associationResults.length === 0"></div>
<div v-else>
An error occurred. Please try again
<!-- FIXME: Button to restore -->
<button @click="retry">
Retry
</button>
</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>
+4
View File
@@ -49,6 +49,10 @@
addedIndex.value = idx;
searchOpts.value?.addSelected( idx );
if ( searchOpts.value?.autoClose )
isShowingSearchView.value = false;
addedTimeout = setTimeout( () => {
addedIndex.value = -1;
}, 2000 );
+21 -2
View File
@@ -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 );
}
};
+2
View File
@@ -12,6 +12,7 @@ export interface CloudImport {
'search': ( term: string, offset: number ) => Promise<Song[]>;
'addSelected': ( index: number ) => void;
'minChars'?: number;
'autoClose': boolean;
}
export interface FileImport {
@@ -19,6 +20,7 @@ export interface FileImport {
'type': 'file';
'mime': string;
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
'autoClose': boolean;
}
export type ImportTypes = CloudImport | FileImport;
+2
View File
@@ -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' );
+9
View File
@@ -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',
+55
View File
@@ -0,0 +1,55 @@
.playlists {
height: 100%;
width: 100%;
position: relative;
>.playlist-wrapper {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
>.playlist-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
flex-direction: column;
overflow-x: scroll;
>.playlist {
width: 50%;
display: flex;
justify-content: center;
align-items: center;
padding: 10px;
height: 3rem;
>h2, >input {
margin: 0;
margin-right: auto;
margin-left: 10px;
width: 100%;
text-align: start;
cursor: pointer;
}
>input {
margin-right: auto;
margin-left: 10px;
width: 50%;
min-width: 200px;
}
>.fa-circle-play {
font-size: 1.5rem;
}
>.fa-solid {
cursor: pointer;
}
}
}
}
}
+4
View File
@@ -33,5 +33,9 @@
position: fixed;
z-index: 10000;
}
&.disallowed {
cursor: default;
}
}
}
@@ -23,6 +23,7 @@
&.moving {
position: fixed;
user-select: none;
}
>i {
+44
View File
@@ -0,0 +1,44 @@
.shared-view {
width: 100%;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
flex-direction: row;
.panel {
width: 45%;
margin-left: 2.5%;
margin-right: 2.5%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
>.current-song-wrapper {
margin-bottom: 1.5rem;
width: 100%;
height: 100%;
max-height: calc(100% - 10rem);
overflow: hidden;
}
>.time {
display: flex;
width: 85%;
margin-left: auto;
margin-right: auto;
>p {
margin-top: 0px;
margin-bottom: 5px;
}
>.duration {
margin-left: auto;
}
}
}
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from 'vue';
export const useAuthStore = defineStore( 'authstore', () => {
const isAuth = ref( true );
const isAuth = ref( false );
return {
isAuth
+8
View File
@@ -4,4 +4,12 @@ import type {
declare global {
var MusicKit: MusicKitObject;
interface GlobalEventHandlersEventMap {
'musicplayer:playpause': CustomEvent<void>;
'musicplayer:playindex': CustomEvent<void>;
'musicplayer:seek': CustomEvent<void>;
'musicplayer:update': CustomEvent<void>;
'musicplayer:reauth': CustomEvent<void>;
'musicplayer:autherror': CustomEvent<void>;
}
}
+4 -1
View File
@@ -1,4 +1,7 @@
// NOTE: For re-associating files with details here, can use dropdown in UI
export interface Playlist {
'name': string;
'songs': PlaylistSongs;
}
export type PlaylistSongs = Song[];
+186
View File
@@ -0,0 +1,186 @@
import {
isPlaying,
queue,
queueIdx
} from '../player/state';
import {
onMounted,
onUnmounted,
ref,
watch
} from 'vue';
import {
playbackPercentage
} from '../player/status-tracking';
import {
reauth
} from '../util/reauth';
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 );
// TODO: Anit-Tamper
// const antiTamperClients = [];
let connection: null | EventSource = null;
let retries = 0;
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;
document.addEventListener( 'musicplayer:autherror', reauth );
return await connect();
};
const closeRoom = async () => {
// TODO: Trigger close on backend and disconnect.
isConnected.value = false;
localStorage.removeItem( 'room' );
connection?.close();
try {
document.removeEventListener( 'musicplayer:autherror', reauth );
} catch { /* empty */ }
};
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;
try {
request.post( `/room/${ room.value }/update/playlist`, JSON.stringify( {
'playlist': queue.value
} ) );
} catch ( e ) {
console.error( e );
}
setTimeout( () => {
playlistLock = false;
}, 500 );
}
};
const sendStateData = async () => {
if ( isConnected.value && !stateLock ) {
stateLock = true;
try {
request.post( `/room/${ room.value }/update/state`, JSON.stringify( {
'playing': isPlaying.value,
'index': queueIdx.value,
'start': new Date().getTime() - 100,
'offset': playbackPercentage.value * ( queue.value[ queueIdx.value ]?.duration ?? 0 )
} ) );
} catch ( e ) {
console.error( e );
}
setTimeout( () => {
stateLock = false;
}, 500 );
}
};
const useRoomWatchers = () => {
watch( queue, sendPlaylistData );
onMounted( () => {
document.addEventListener( 'musicplayer:playindex', sendStateData );
document.addEventListener( 'musicplayer:seek', sendStateData );
document.addEventListener( 'musicplayer:playpause', sendStateData );
document.addEventListener( 'musicplayer:update', sendPlaylistData );
} );
onUnmounted( () => {
try {
document.addEventListener( 'musicplayer:play', sendStateData );
} catch { /* empty */ }
try {
document.addEventListener( 'musicplayer:seek', sendStateData );
} catch { /* empty */ }
try {
document.addEventListener( 'musicplayer:playpause', sendStateData );
} catch { /* empty */ }
try {
document.addEventListener( 'musicplayer:update', sendPlaylistData );
} catch { /* empty */ }
} );
if ( room.value ) connect();
};
export default {
createRoom,
useRoomWatchers,
closeRoom
};
+11 -5
View File
@@ -38,7 +38,9 @@ const next = () => {
};
const prev = () => {
// TODO: Back to beginning if more than some seconds have passed?
if ( playbackPercentage.value * duration.value > 7 )
return seekTo( 0 );
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
};
@@ -49,6 +51,7 @@ const play = () => {
sources[currentSource.value]?.play();
startTracking();
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
};
const pause = () => {
@@ -58,6 +61,7 @@ const pause = () => {
sources[currentSource.value]?.pause();
stopTracking();
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
};
/**
@@ -68,6 +72,7 @@ const seekTo = ( pos: number ) => {
if ( currentSource.value === '' ) return;
sources[currentSource.value]?.seekTo( pos );
document.dispatchEvent( new CustomEvent( 'musicplayer:seek' ) );
};
const skip10 = () => {
@@ -135,10 +140,12 @@ const addToSongList = ( songs: Song[] ) => {
/**
* Add songs to the playlist from given source
* @param source - The ID of the source to add from
* @param cb - A custom callback to be executed instead of the default, which adds to queue
* @param skipLogin - Whether to skip login checks
*/
const addSongFromSource = async ( source: string ): Promise<boolean> => {
if ( sources[source]!.authorized.value ) {
sources[source]!.addSongsFromThisSource( addToSongList );
const addSongFromSource = async ( source: string, cb?: ( songs: Song[] ) => void, skipLogin?: boolean ): Promise<boolean> => {
if ( sources[source]!.authorized.value || skipLogin ) {
sources[source]!.addSongsFromThisSource( cb ? cb : addToSongList, skipLogin ? 0 : undefined, skipLogin );
} else {
sources[source]!.login!();
@@ -148,7 +155,6 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
return true;
};
export default {
play,
pause,
+4
View File
@@ -15,6 +15,9 @@ import {
import type {
Song
} from '@/ts/dtype/playlist';
import {
setPlaylistIdx
} from '@/ts/userPlaylists';
export const addSongList = ( songs: Song[] ) => {
rawQueue.value = rawQueue.value.concat( songs );
@@ -22,6 +25,7 @@ export const addSongList = ( songs: Song[] ) => {
};
export const clearQueue = () => {
setPlaylistIdx( 0 );
queue.value = [];
rawQueue.value = [];
sources[currentSource.value]?.stop();
+4 -1
View File
@@ -15,7 +15,7 @@ import {
* 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 ) => {
export const playIndex = ( idx: number ) => {
if ( idx >= queue.value.length ) {
idx = repeat.value === 'all' ? 0 : -1;
} else if ( idx < 0 ) {
@@ -42,4 +42,7 @@ export const playIndex = async ( idx: number ) => {
startTracking();
isPlaying.value = true;
setTimeout( () => {
document.dispatchEvent( new CustomEvent( 'musicplayer:playindex', {} ) );
}, 500 );
};
+12 -2
View File
@@ -1,4 +1,5 @@
import {
fullPlayer,
queue,
rawQueue,
sources
@@ -41,7 +42,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;
};
+4 -1
View File
@@ -116,8 +116,11 @@ export interface PlayerSourcePlugin {
/**
* 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
* @param cb - Added songs are the arguments
* @param kindIdx - The index in the list of import types that should be used without showing popup. If unset, will show the picker
* @param autoClose - Whether or not to automatically close the interface after a single pick
*/
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void, kindIdx?: number, autoClose?: boolean ) => void;
/**
* Fully unload a song. This is called on clear of a playlist
@@ -1,9 +1,13 @@
import {
type CloudImport,
openImportTypePicker
} from '@/composables/importTypePicker';
import type {
Song
} from '@/ts/dtype/playlist';
import {
openImportTypePicker
} from '@/composables/importTypePicker';
openSearchInterface
} from '@/composables/searchManager';
import {
searchPlaylists
} from './playlists';
@@ -11,23 +15,29 @@ import {
searchSongs
} from './songs';
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void ): Promise<void> => {
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void, kindIdx?: number, autoClose?: boolean ): Promise<void> => {
const songs = await searchSongs( cb );
const playlists = await searchPlaylists( cb );
openImportTypePicker( [
const kinds: CloudImport[] = [
{
'name': 'Songs',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search,
'minChars': 3
'minChars': 3,
'autoClose': autoClose ?? false
},
{
'name': 'Playlists',
'type': 'cloud',
'addSelected': playlists.addSelected,
'search': playlists.search
'search': playlists.search,
'autoClose': autoClose ?? false
}
] );
];
if ( kindIdx === undefined )
openImportTypePicker( kinds );
else
openSearchInterface( kinds[kindIdx]! );
};
+3 -1
View File
@@ -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();
+8 -2
View File
@@ -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,13 @@ 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 ) {
document.dispatchEvent( new CustomEvent( 'musicplayer:autherror' ) );
throw new AuthError( 'ERR_USER_UNAUTHORIZED' );
} else if ( res.status === 402 ) {
throw new UnownedError( 'ERR_USER_UNOWNED' );
@@ -35,5 +40,6 @@ const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
export default {
get,
post
post,
backendURL
};
+87
View File
@@ -0,0 +1,87 @@
import {
type StateUpdate,
messageHandler
} from './messageHandler';
import {
currentQueue,
currentQueueIdx,
isPlaying,
playbackOffset,
playbackProgress,
playbackTime,
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 as option
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;
if ( data.playlist.length === 0 )
playbackTime.value = 0;
else
playbackTime.value = data.state.offset;
playbackOffset.value = data.state.offset;
playbackProgress.value = data.state.offset / ( data.playlist[ data.state.index ]?.duration ?? -1 );
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
};
+51
View File
@@ -0,0 +1,51 @@
import {
currentQueue,
currentQueueIdx,
isPlaying,
playbackOffset,
playbackTime,
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;
'offset': 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;
playbackTime.value = state.offset;
playbackOffset.value = state.offset;
} else {
console.log( '[SSE] Received unknown data', data.type );
}
} catch ( err ) {
console.error( 'JSON DECODE failed with error', err );
}
}
};
+23
View File
@@ -0,0 +1,23 @@
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() );
export const showArtworks = ref( false );
export const playbackTime = ref( 0 );
export const playbackOffset = ref( 0 );
export const playbackProgress = ref( 0 );
+32
View File
@@ -0,0 +1,32 @@
import {
editingPlaylists,
playlistIdx,
playlists
} from './state';
import player from '../player';
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 );
}
};
export const selectPlaylist = ( idx: number ) => {
playlistIdx.value = idx;
player.clearQueue();
player.loadPlaylist( playlists.value[ idx ]!.songs );
};
export const setPlaylistIdx = ( idx: number ) => {
playlistIdx.value = idx;
};
+55 -1
View File
@@ -1,10 +1,64 @@
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 = () => {
rawQueue.value = queue.value;
shuffle.value = false;
if ( playlistIdx.value < 0 ) {
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' );
if ( !name ) return;
}
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'
} );
}
};
+7 -4
View File
@@ -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( [] );
+17
View File
@@ -0,0 +1,17 @@
export const reauth = () => {
localStorage.setItem( 'close-tab', 'true' );
const listener = () => {
if ( localStorage.getItem( 'reauth-ok' ) === 'true' ) {
try {
window.removeEventListener( 'storage', listener );
} catch { /* empty */ }
localStorage.removeItem( 'reauth-ok' );
document.dispatchEvent( new CustomEvent( 'musicplayer:reauth' ) );
}
};
window.addEventListener( 'storage', listener );
};
+2 -22
View File
@@ -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>
+86
View File
@@ -0,0 +1,86 @@
<script setup lang="ts">
import {
type ComputedRef,
computed,
onMounted
} from 'vue';
import {
currentQueue,
currentQueueIdx,
isPlaying,
playbackOffset,
playbackProgress,
playbackTime,
startTime
} from '@/ts/shared/state';
import CurrentSong from '@/components/player/CurrentSong.vue';
import ProgressBar from '@/components/player/ProgressBar.vue';
import SharedQueue from '@/components/shared/SharedQueue.vue';
import type {
Song
} from '@/ts/dtype/playlist';
import {
beautifyTime
} from '@/ts/util/time';
import shared from '@/ts/shared';
shared.connect();
onMounted( () => {
setInterval( () => {
if ( !isPlaying.value ) return;
playbackTime.value = ( ( new Date().getTime() - startTime.value ) / 1000 ) + playbackOffset.value;
playbackProgress.value = playbackTime.value / song.value.duration;
if ( playbackTime.value > song.value.duration ) {
playbackOffset.value -= song.value.duration;
if ( currentQueueIdx.value < currentQueue.value.length - 1 )
currentQueueIdx.value++;
else
isPlaying.value = false;
}
}, 250 );
} );
const song: ComputedRef<Song> = computed( () => {
if ( currentQueueIdx.value >= 0 && currentQueue.value.length > currentQueueIdx.value )
return currentQueue.value[currentQueueIdx.value]!;
else
return {
'artist': 'No artist',
'duration': -1,
'name': 'Not playing',
'artwork': '',
'additional-info': '',
'identifier': 'nosong-ident',
'source': 'local'
};
} );
</script>
<template>
<div class="shared-view">
<div class="panel">
<div class="current-song-wrapper">
<CurrentSong v-model="song" :show-additional-info="true" />
</div>
<ProgressBar v-model="playbackProgress" :disallow-move="true" />
<div class="time">
<p class="current">
{{ beautifyTime( song.duration >= 0 ? playbackTime : -1 ) }}
</p>
<p class="duration">
{{ beautifyTime( song.duration ) }}
</p>
</div>
</div>
<div class="panel">
<SharedQueue />
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/shared/main.scss';
</style>
+49 -24
View File
@@ -1,36 +1,61 @@
<script setup lang="ts">
import SortableList from '@/components/SortableList.vue';
import {
onMounted,
ref
} from 'vue';
import router from '@/router';
import sdk from '@janishutz/login-sdk-browser';
import {
useAuthStore
} from '@/stores/authstore';
const items = ref( [
'test',
'test2',
'test3',
'test4',
'test5',
'test6',
'test7',
'test8',
'test9',
'test10'
] );
const isLoggingIn = ref( true );
const store = useAuthStore();
sdk.setUp( 'jh-music', 'http://localhost:8080', '/app' );
onMounted( async () => {
try {
store.isAuth = await sdk.verify();
if ( store.isAuth )
isAuthorizedHandler();
} catch ( e ) {
if ( e !== 'ERR_401' ) {
throw e;
}
}
isLoggingIn.value = false;
// TODO: Logout button
} );
const isAuthorizedHandler = () => {
if ( localStorage.getItem( 'close-tab' ) === 'true' ) {
localStorage.setItem( 'login-ok', 'true' );
localStorage.removeItem( 'close-tab' );
return window.close();
}
router.push( '/app' );
};
const login = () => {
if ( isLoggingIn.value ) return;
isLoggingIn.value = true;
sdk.login();
isLoggingIn.value = false;
};
</script>
<template>
<div>
<h1>MusicPlayer</h1>
<div class="test">
<SortableList v-slot="{ item, index }" v-model="items">
{{ item }} at {{ index }}
</SortableList>
</div>
<button :class="['fancy-button', isLoggingIn ? 'fancy-button-inactive' : undefined]" @click="login">
Log In
</button>
</div>
</template>
<style lang="scss" scoped>
.test {
height: 150px;
}
</style>