start integrating websocket, player basically done

This commit is contained in:
2024-06-27 16:50:03 +02:00
parent 76f543eb2f
commit 1e11f1dc2e
13 changed files with 567 additions and 48 deletions

View File

@@ -141,7 +141,7 @@
#themeSelector {
position: fixed;
top: 10px;
right: 10px;
left: 10px;
background: none;
border: none;
color: var( --primary-color );

View File

@@ -45,7 +45,9 @@
<span class="material-symbols-outlined close-fullscreen" @click="controlUI( 'hide' )">close</span>
<playlistView :playlist="playlist" class="pl-wrapper" :currently-playing="currentlyPlayingSongIndex" :is-playing="isPlaying" :pos="pos"
@control="( action ) => { control( action ) }" @play-song="( song ) => { playSong( song ) }"
@add-new-songs="( songs ) => addNewSongs( songs )" @playlist-reorder="( move ) => moveSong( move )"></playlistView>
@add-new-songs="( songs ) => addNewSongs( songs )" @playlist-reorder="( move ) => moveSong( move )"
:is-logged-into-apple-music="player.isLoggedIn"
@add-new-songs-apple-music="( song ) => addNewSongFromObject( song )"></playlistView>
</div>
<notificationsModule ref="notifications" location="bottomleft" size="bigger"></notificationsModule>
<audio src="" id="local-audio" controls="false"></audio>
@@ -63,6 +65,8 @@
import type { ReadFile, Song, SongMove } from '@/scripts/song';
import { parseBlob } from 'music-metadata-browser';
import notificationsModule from './notificationsModule.vue';
import { useUserStore } from '@/stores/userStore';
import NotificationHandler from '@/scripts/notificationHandler';
const isPlaying = ref( false );
const repeatMode = ref( '' );
@@ -82,6 +86,7 @@
const pos = ref( 0 );
const duration = ref( 0 );
const notifications = ref( notificationsModule );
const notificationHandler = new NotificationHandler();
const emits = defineEmits( [ 'playerStateChange' ] );
@@ -90,6 +95,7 @@
if ( isPlaying.value ) {
player.control( 'play' );
startProgressTracker();
} else {
player.control( 'pause' );
stopProgressTracker();
@@ -364,7 +370,7 @@
const addNewSongs = async ( songs: ReadFile[] ) => {
let n = notifications.value.createNotification( 'Analyzing new songs', 200, 'progress', 'normal' );
playlist.value = player.getPlaylist();
playlist.value = player.getQueue();
for ( let element in songs ) {
try {
playlist.value.push( await fetchSongData( songs[ element ] ) );
@@ -374,29 +380,48 @@
notifications.value.updateNotification( n, `Analyzing new songs (${element}/${songs.length})` );
}
player.setPlaylist( playlist.value );
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
}
notifications.value.cancelNotification( n );
notifications.value.createNotification( 'New songs added', 10, 'ok', 'normal' );
}
const addNewSongFromObject = ( song: Song ) => {
playlist.value = player.getQueue();
playlist.value.push( song );
player.setPlaylist( playlist.value );
if ( !isPlaying.value ) {
player.prepare( 0 );
isPlaying.value = true;
setTimeout( () => {
startProgressTracker();
getDetails();
}, 2000 );
}
}
emits( 'playerStateChange', isShowingFullScreenPlayer.value ? 'show' : 'hide' );
const userStore = useUserStore();
document.addEventListener( 'keydown', ( e ) => {
if ( e.key === ' ' ) {
// TODO: fix
e.preventDefault();
playPause();
} else if ( e.key === 'ArrowRight' ) {
e.preventDefault();
control( 'next' );
} else if ( e.key === 'ArrowLeft' ) {
e.preventDefault();
control( 'previous' );
if ( !userStore.isUsingKeyboard ) {
if ( e.key === ' ' ) {
e.preventDefault();
playPause();
} else if ( e.key === 'ArrowRight' ) {
e.preventDefault();
control( 'next' );
} else if ( e.key === 'ArrowLeft' ) {
e.preventDefault();
control( 'previous' );
}
}
} );

View File

@@ -1,11 +1,11 @@
<template>
<div>
<h1>Playlist</h1>
<input type="file" multiple accept="audio/*" id="more-songs">
<button @click="addNewSongs()">Load local songs</button>
<input type="file" multiple accept="audio/*" id="more-songs" class="small-buttons">
<button @click="addNewSongs()" class="small-buttons" title="Load selected files"><span class="material-symbols-outlined">upload</span></button>
<button @click="openSearch()" v-if="$props.isLoggedIntoAppleMusic" class="small-buttons" title="Search Apple Music for the song"><span class="material-symbols-outlined">search</span></button>
<p v-if="!hasSelectedSongs">Please select at least one song to proceed</p>
<div class="playlist-box" id="pl-box">
<!-- TODO: Allow sorting -->
<!-- TODO: Allow adding more songs with search on Apple Music or loading from local disk -->
<div class="song" v-for="song in computedPlaylist" v-bind:key="song.id"
:class="( song.id === ( $props.playlist ? $props.playlist [ $props.currentlyPlaying ?? 0 ].id : '' ) && isPlaying ? 'playing' : ' not-playing' )
@@ -27,13 +27,16 @@
<p class="playing-in">{{ getTimeUntil( song ) }}</p>
</div>
</div>
<searchView ref="search" @selected-song="( song ) => { addNewSongsAppleMusic( song ) }"></searchView>
</div>
</template>
<script setup lang="ts">
import type { ReadFile, Song } from '@/scripts/song';
import type { AppleMusicSongData, ReadFile, Song } from '@/scripts/song';
import { computed, ref } from 'vue';
import searchView from './searchView.vue';
const search = ref( searchView );
const props = defineProps( {
'playlist': {
default: [],
@@ -54,6 +57,11 @@
default: 0,
required: false,
type: Number,
},
'isLoggedIntoAppleMusic': {
default: false,
required: true,
type: Boolean,
}
} );
const hasSelectedSongs = ref( true );
@@ -67,6 +75,12 @@
return pl;
} );
const openSearch = () => {
if ( search.value ) {
search.value.controlSearch( 'show' );
}
}
const canBeMoved = computed( () => {
return ( direction: movementDirection, songID: string ): boolean => {
let id = 0;
@@ -125,9 +139,8 @@
}
const addNewSongs = () => {
// TODO: Also allow loading Apple Music songs
const fileURLList: ReadFile[] = [];
const allFiles = ( document.getElementById( 'pl-loader' ) as HTMLInputElement ).files ?? [];
const allFiles = ( document.getElementById( 'more-songs' ) as HTMLInputElement ).files ?? [];
if ( allFiles.length > 0 ) {
hasSelectedSongs.value = true;
for ( let file = 0; file < allFiles.length; file++ ) {
@@ -139,6 +152,18 @@
}
}
const addNewSongsAppleMusic = ( songData: AppleMusicSongData ) => {
const song: Song = {
artist: songData.attributes.artistName,
cover: songData.attributes.artwork.url.replace( '{w}', String( songData.attributes.artwork.width ) ).replace( '{h}', String( songData.attributes.artwork.height ) ),
duration: songData.attributes.durationInMillis / 1000,
id: songData.id,
origin: 'apple-music',
title: songData.attributes.name
}
emits( 'add-new-songs-apple-music', song );
}
type movementDirection = 'up' | 'down';
const moveSong = ( songID: string, direction: movementDirection ) => {
let newSongPos = 0;
@@ -156,7 +181,7 @@
}
}
const emits = defineEmits( [ 'play-song', 'control', 'playlist-reorder', 'add-new-songs' ] );
const emits = defineEmits( [ 'play-song', 'control', 'playlist-reorder', 'add-new-songs', 'add-new-songs-apple-music' ] );
</script>
<style scoped>
@@ -294,4 +319,22 @@
cursor: pointer;
user-select: none;
}
.small-buttons {
margin-bottom: 10px;
font-size: 1rem;
background: none;
border: none;
cursor: pointer;
}
.small-buttons .material-symbols-outlined {
font-size: 1.5rem;
color: var( --primary-color );
transition: all 0.5s;
}
.small-buttons:hover .material-symbols-outlined {
transform: scale(1.1);
}
</style>

View File

@@ -7,8 +7,8 @@
</div>
<div v-else-if="!$props.isLoggedIn">
<p>You are not logged into Apple Music.</p>
<input class="fancy-button" type="file" multiple="true" accept="audio/*" id="pl-loader"><br>
<button @click="loadPlaylistFromDisk()" class="fancy-button">Load custom playlist from disk</button>
<input class="pl-loader-button" type="file" multiple="true" accept="audio/*" id="pl-loader"><br>
<button @click="loadPlaylistFromDisk()" class="pl-loader-button">Load custom playlist from disk</button>
<p v-if="!hasSelectedSongs">Please select at least one song to proceed!</p>
</div>
<div class="playlist-wrapper">
@@ -84,4 +84,8 @@
cursor: pointer;
user-select: none;
}
.pl-loader-button {
background-color: white;
}
</style>

View File

@@ -0,0 +1,265 @@
<template>
<div>
<div id="search-bar" :class="showsSearch ? 'search-shown' : ''">
<div id="search-box-wrapper">
<input type="text" v-model="searchText" id="search-box" placeholder="Type to search..." @keyup="e => { keyHandler( e ) }">
<div class="symbol-wrapper" id="search-symbol-wrapper">
<span class="material-symbols-outlined search-symbol" @click="search()">search</span>
</div>
<div :class="'search-result-wrapper' + ( searchText.length > 0 ? ' show-search-results' : '' )">
<div v-for="result in searchResults" v-bind:key="result.id"
:class="'search-result' + ( selectedProduct === result.id ? ' prod-selected' : '' )"
@mouseenter="removeSelection()" @click="select( result )">
<div :style="'background-image: url(' + result.attributes.artwork.url.replace( '{w}', '500' ).replace( '{h}', '500' ) + ');'" class="search-product-image"></div>
<div class="search-product-name"><p><b>{{ result.attributes.name }}</b> <i>by {{ result.attributes.artistName }}</i></p></div>
</div>
<div v-if="searchResults.length === 0 && searchText.length < 3">
<p>No results to show</p>
</div>
<div v-else-if="searchText.length < 3">
<p>Enter at least three characters to start searching</p>
</div>
</div>
</div>
<div class="symbol-wrapper">
<span class="material-symbols-outlined search-symbol" @click="controlSearch( 'hide' )" id="close-icon">close</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import MusicKitJSWrapper from '@/scripts/music-player';
import type { AppleMusicSongData } from '@/scripts/song';
import { useUserStore } from '@/stores/userStore';
import { ref, type Ref } from 'vue';
const showsSearch = ref( false );
const searchText = ref( '' );
const selectedProduct = ref( '' );
let selectedProductIndex = -1;
const player = new MusicKitJSWrapper();
const updateSearchResults = () => {
if ( searchText.value.length > 2 ) {
player.findSongOnAppleMusic( searchText.value ).then( data => {
searchResults.value = data.data.results.songs.data ?? [];
selectedProductIndex = -1;
selectedProduct.value = '';
} );
}
}
const searchResults: Ref<AppleMusicSongData[]> = ref( [] );
const userStore = useUserStore();
const controlSearch = ( action: string ) => {
if ( action === 'show' ) {
userStore.setKeyboardUsageStatus( true );
showsSearch.value = true;
setTimeout( () => {
const searchBox = document.getElementById( 'search-box' ) as HTMLInputElement;
searchBox.focus();
}, 500 );
} else if ( action === 'hide' ) {
userStore.setKeyboardUsageStatus( false );
showsSearch.value = false;
}
searchText.value = '';
removeSelection();
}
const removeSelection = () => {
selectedProduct.value = '';
selectedProductIndex = -1;
}
const keyHandler = ( e: KeyboardEvent ) => {
if ( e.key === 'Escape' ) {
controlSearch( 'hide' );
} else if ( e.key === 'Enter' ) {
e.preventDefault();
if ( selectedProductIndex >= 0 ) {
select( searchResults.value[ selectedProductIndex ] );
controlSearch( 'hide' );
} else {
search();
}
} else if ( e.key === 'ArrowDown' ) {
e.preventDefault();
if ( selectedProductIndex < searchResults.value.length - 1 ) {
selectedProductIndex += 1;
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
}
} else if ( e.key === 'ArrowUp' ) {
e.preventDefault();
if ( selectedProductIndex > 0 ) {
selectedProductIndex -= 1;
selectedProduct.value = searchResults.value[ selectedProductIndex ].id;
} else {
removeSelection();
}
} else {
updateSearchResults();
}
}
const select = ( song: AppleMusicSongData ) => {
emits( 'selected-song', song );
controlSearch( 'hide' );
}
const search = () => {
emits( 'selected-song', searchResults.value[ 0 ] );
controlSearch( 'hide' );
}
const emits = defineEmits( [ 'selected-song' ] );
defineExpose( {
controlSearch
} );
</script>
<style scoped>
#search-bar {
position: fixed;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
top: -15vh;
background-color: var( --accent-background );
height: 15vh;
width: 100vw;
left: 0;
transition: all 1s;
z-index: 2;
}
#search-bar.search-shown {
top: 0;
}
#search-box {
width: calc(100% - 20px);
padding: 10px;
font-size: 150%;
}
#search-box-wrapper {
width: 60vw;
position: relative;
display: block;
}
@media only screen and (min-width: 1000px) {
#search-box-wrapper {
width: 45vw;
}
}
@media only screen and (min-width: 1500px) {
#search-box-wrapper {
width: 30vw;
}
}
#search-symbol-wrapper {
position: absolute;
right: 10px;
top: 3px;
}
.symbol-wrapper {
display: flex;
height: 3rem;
width: 3rem;
align-items: center;
justify-content: center;
}
.search-symbol {
color: black;
padding: 0;
margin: 0;
font-size: 200%;
cursor: pointer;
transition: all 0.5s;
}
.search-symbol:hover {
font-size: 250%;
}
#close-icon {
margin-left: 5px;
color: var( --primary-color );
}
</style>
<style scoped>
.search-result-wrapper {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
padding: 2px;
padding-bottom: 5px;
left: 0;
text-decoration: none;
background-color: white;
border-bottom-left-radius: 10px;
border-bottom-right-radius: 10px;
transform-origin: top;
transform: scaleY( 0 );
transition: all 0.5s;
color: black;
}
.show-search-results {
transform: scaleY( 1 );
}
.search-result {
padding: 3px;
display: flex;
flex-direction: row;
width: 98%;
text-decoration: none;
transition: all 0.5s;
cursor: pointer;
}
.search-result:hover, .prod-selected {
text-decoration: underline black;
}
.search-product-image {
height: 4rem;
width: 4rem;
background-position: center;
background-size: cover;
margin-right: 20px;
display: flex;
align-items: center;
flex-direction: row;
justify-content: center;
}
.search-product-name {
display: flex;
flex-direction: row;
align-items: center;
text-decoration: none;
color: black;
font-weight: bold;
padding: 0;
margin: 0;
width: calc( 100% - 4rem - 20px );
}
</style>

View File

@@ -228,6 +228,7 @@ class MusicKitJSWrapper {
switch ( action ) {
case "play":
if ( this.isPreparedToPlay ) {
this.control( 'pause' );
if ( this.playlist[ this.playingSongID ].origin === 'apple-music' ) {
this.musicKit.play();
return false;
@@ -285,6 +286,7 @@ class MusicKitJSWrapper {
return false;
}
case "next":
this.control( 'pause' );
if ( this.queuePos < this.queue.length - 1 ) {
this.queuePos += 1;
this.prepare( this.queue[ this.queuePos ] );
@@ -300,6 +302,7 @@ class MusicKitJSWrapper {
return true;
}
case "previous":
this.control( 'pause' );
if ( this.queuePos > 0 ) {
this.queuePos -= 1;
this.prepare( this.queue[ this.queuePos ] );

View File

@@ -15,7 +15,25 @@ class NotificationHandler {
socket: Socket;
constructor () {
this.socket = io();
this.socket = io( localStorage.getItem( 'url' ) ?? '', {
autoConnect: false,
} );
}
/**
* Create a room token and connect to
* @param {string} roomName
* @returns {Promise<string>}
*/
connect ( roomName: string ): Promise<string> {
fetch( localStorage.getItem( 'url' ) + '/createRoomToken', { credentials: 'include' } ).then( res => {
if ( res.status === 200 ) {
res.json().then( json => {
} );
}
} );
}
/**
@@ -35,10 +53,6 @@ class NotificationHandler {
disconnect (): void {
this.socket.disconnect();
}
joinRoom ( roomName: string ): void {
// this.socket.
}
}
export default NotificationHandler;

View File

@@ -42,6 +42,14 @@ export interface Song {
additionalInfo?: string;
}
export interface SongTransmitted {
title: string;
artist: string;
duration: number;
cover: string;
additionalInfo?: string;
}
export interface ReadFile {
url: string;

View File

@@ -1,12 +0,0 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
export const useCounterStore = defineStore('counter', () => {
const count = ref(0)
const doubleCount = computed(() => count.value * 2)
function increment() {
count.value++
}
return { count, doubleCount, increment }
})

View File

@@ -0,0 +1,32 @@
/*
* LanguageSchoolHossegorBookingSystem - userStore.js
*
* Created by Janis Hutz 10/27/2023, Licensed under a proprietary License
* https://janishutz.com, development@janishutz.com
*
*
*/
import { defineStore } from 'pinia';
export const useUserStore = defineStore( 'user', {
state: () => ( { 'isUserAuth': false, 'isAdminAuth': false, 'isUsingKeyboard': false, 'username': '' } ),
getters: {
getUserAuthenticated: ( state ) => state.isUserAuth,
getAdminAuthenticated: ( state ) => state.isAdminAuth,
},
actions: {
setUserAuth ( auth: boolean ) {
this.isUserAuth = auth;
},
setAdminAuth ( auth: boolean ) {
this.isAdminAuth = auth;
},
setUsername ( username: string ) {
this.username = username;
},
setKeyboardUsageStatus ( status: boolean ) {
this.isUsingKeyboard = status;
}
}
} );