feat: basic connection between frontend and backend

This commit is contained in:
2026-09-07 19:35:30 +02:00
parent 867374fa47
commit 054fda2fb9
16 changed files with 329 additions and 26 deletions
+8 -7
View File
@@ -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>
@@ -63,11 +63,6 @@
<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>
+45
View File
@@ -0,0 +1,45 @@
<script setup lang="ts">
import {
computed,
ref
} from 'vue';
import type {
Song
} from '@/ts/dtype/playlist';
const props = defineProps<{
'songs': Song[],
'idx': number
}>();
const songs = computed( () => props.songs?.slice( props.idx + 1 ) ?? [] );
// TODO: Move this out of this file
const showArtworks = ref( false );
</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 {{ song.duration }}min</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/queue.scss';
</style>
+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',
+121
View File
@@ -0,0 +1,121 @@
import {
duration,
playbackPercentage
} from '../player/status-tracking';
import {
isPlaying,
queue,
queueIdx
} from '../player/state';
import {
ref,
watch
} from 'vue';
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;
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' );
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 );
}
};
} );
};
const useRoomWatchers = () => {
watch( queue, () => {
if ( isConnected.value )
request.post( `/room/${ room.value }/update/playlist`, JSON.stringify( {
'playlist': queue.value
} ) );
} );
watch( [
isPlaying,
queueIdx
], () => {
if ( isConnected.value )
request.post( `/room/${ room.value }/update/state`, JSON.stringify( {
'playing': isPlaying.value,
'index': queueIdx.value,
'start': new Date().getTime() - ( playbackPercentage.value * duration.value ) - 100
} ) );
} );
if ( room.value ) connect();
};
export default {
createRoom,
useRoomWatchers,
closeRoom
};
+2
View File
@@ -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,
+6 -2
View File
@@ -9,6 +9,8 @@ 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',
@@ -21,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' );
@@ -36,5 +39,6 @@ const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
export default {
get,
post
post,
backendURL
};
+47
View File
@@ -0,0 +1,47 @@
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
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 = () => {
hasConnected = true;
console.log( '[SSE] Connection established successfully' );
resolve();
};
connection.onmessage = msg => {
console.log( msg.data );
// TODO: On connect, retrieve data from poll endpoint
};
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
};
+20
View File
@@ -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 :songs="[]" :idx="-1" />
</div>
</div>
</div>
</template>