mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
Compare commits
8
Commits
6d9853f2c1
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27be5f42c | ||
|
|
5e322f6c67 | ||
|
|
8d6d24a780 | ||
|
|
946ba84518 | ||
|
|
be2a9f3a3e | ||
|
|
d573ac90eb | ||
|
|
b11882c993 | ||
|
|
047a3a8b29 |
@@ -1,6 +1,7 @@
|
||||
# ignore node_modules
|
||||
node_modules
|
||||
*.secret.json
|
||||
*.secret.json
|
||||
apple_private_key.p8
|
||||
musicplayerv2-server.zip
|
||||
dist
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "../config.schema.json",
|
||||
"mode": "foss",
|
||||
"mode": "hosted",
|
||||
"clientMode": "sse",
|
||||
"webUrl": "http://localhost:8081"
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"playlists":[],"userid":"server-stubs","version":"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"}
|
||||
+9
-8
@@ -15,19 +15,21 @@ 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'
|
||||
) ).toString() );
|
||||
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
|
||||
@@ -43,7 +45,8 @@ const run = () => {
|
||||
'frontendURL': config.webUrl,
|
||||
'corsWhitelist': [ config.webUrl ],
|
||||
'recheckTimeout': 300 * 1000,
|
||||
'advancedVerification': 'sdk'
|
||||
'advancedVerification': 'sdk',
|
||||
'defaultRedirectURL': '/app'
|
||||
},
|
||||
app,
|
||||
async () => {
|
||||
@@ -66,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 );
|
||||
@@ -75,8 +78,6 @@ 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 );
|
||||
|
||||
@@ -41,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
|
||||
}
|
||||
};
|
||||
|
||||
@@ -112,9 +113,10 @@ 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 ) => {
|
||||
const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number, offset: number ) => {
|
||||
if ( !rooms[room] || rooms[room].owner !== uid ) {
|
||||
return false;
|
||||
}
|
||||
@@ -123,7 +125,8 @@ const updateState = ( room: string, uid: string, playing: boolean, index: number
|
||||
'playing': playing,
|
||||
'index': index,
|
||||
'lastUpdate': new Date().getTime(),
|
||||
'start': start
|
||||
'start': start,
|
||||
'offset': offset
|
||||
};
|
||||
|
||||
return sendUpdate( room, 'state' );
|
||||
|
||||
Vendored
+1
@@ -20,6 +20,7 @@ export interface Room {
|
||||
'start': number;
|
||||
'playing': boolean;
|
||||
'lastUpdate': number;
|
||||
'offset': number;
|
||||
};
|
||||
'owner': string;
|
||||
'clients': Client[];
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
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', config ) );
|
||||
|
||||
app.get( '/room/:id/poll', corsManager.middleware( false ), ( request: express.Request, response: express.Response ) => {
|
||||
@@ -80,7 +79,8 @@ const routes = ( app: express.Application, foss: boolean, config: Config ) => {
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
background-color: var(--secondary-color);
|
||||
transition: bottom 1s ease;
|
||||
transition-delay: 0.25s;
|
||||
overflow: hidden;
|
||||
|
||||
&.hidden {
|
||||
transition-delay: 0s;
|
||||
|
||||
@@ -4,8 +4,15 @@
|
||||
</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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import {
|
||||
beautifyTime
|
||||
} from '@/ts/util/time';
|
||||
import messages from '@/ts/messages';
|
||||
import player from '@/ts/player';
|
||||
|
||||
const playbackPercentage = player.playbackPercentage;
|
||||
@@ -42,6 +43,8 @@
|
||||
const duration = computed( () => {
|
||||
return beautifyTime( player.duration.value );
|
||||
} );
|
||||
|
||||
messages.useRoomWatchers();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -12,8 +12,13 @@
|
||||
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;
|
||||
moveVal.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||
@@ -47,7 +52,7 @@
|
||||
<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"
|
||||
|
||||
@@ -59,12 +59,16 @@
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
Clear
|
||||
</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>
|
||||
</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">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
addPlaylist,
|
||||
removePlaylist
|
||||
removePlaylist,
|
||||
selectPlaylist
|
||||
} from '@/ts/userPlaylists';
|
||||
import {
|
||||
editingPlaylists,
|
||||
playlistIdx as playlistIdx,
|
||||
playlists
|
||||
} from '@/ts/userPlaylists/state';
|
||||
import {
|
||||
@@ -20,7 +20,6 @@
|
||||
import {
|
||||
UnownedError
|
||||
} from '@/ts/request';
|
||||
import player from '@/ts/player';
|
||||
import router from '@/router';
|
||||
|
||||
const checkingStatus = ref( true );
|
||||
@@ -65,48 +64,51 @@
|
||||
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">
|
||||
<div v-if="checkingStatus" class="playlist-wrapper">
|
||||
Loading{{ '.'.repeat( dots ) }}
|
||||
</div>
|
||||
<div v-else-if="playlists.length === 0">
|
||||
<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>
|
||||
<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 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>
|
||||
|
||||
@@ -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>
|
||||
@@ -1,16 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
computed,
|
||||
ref
|
||||
} from 'vue';
|
||||
import {
|
||||
currentQueue,
|
||||
currentQueueIdx
|
||||
currentQueueIdx,
|
||||
playbackTime,
|
||||
showArtworks
|
||||
} from '@/ts/shared/state';
|
||||
import {
|
||||
computed
|
||||
} from 'vue';
|
||||
|
||||
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;
|
||||
@@ -20,31 +19,39 @@
|
||||
}
|
||||
|
||||
total += currentQueue.value[ currentQueueIdx.value ]?.duration ?? 0;
|
||||
total -= playbackTime.value;
|
||||
|
||||
return Math.round( total / 60 );
|
||||
return Math.ceil( 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 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 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 v-else class="queue-empty">
|
||||
No upcoming songs
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,4 +59,12 @@
|
||||
|
||||
<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>
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
isShowingAssociationManager.value = false;
|
||||
fullPlayer.value = false;
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
associationResults.value = [];
|
||||
isAnalyzing.value = false;
|
||||
needsFiles.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +97,9 @@
|
||||
<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
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
|
||||
addedIndex.value = idx;
|
||||
searchOpts.value?.addSelected( idx );
|
||||
|
||||
if ( searchOpts.value?.autoClose )
|
||||
isShowingSearchView.value = false;
|
||||
|
||||
addedTimeout = setTimeout( () => {
|
||||
addedIndex.value = -1;
|
||||
}, 2000 );
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,5 +33,9 @@
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
&.disallowed {
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
|
||||
&.moving {
|
||||
position: fixed;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
>i {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
export const useAuthStore = defineStore( 'authstore', () => {
|
||||
const isAuth = ref( true );
|
||||
const isAuth = ref( false );
|
||||
|
||||
return {
|
||||
isAuth
|
||||
|
||||
Vendored
+8
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
-2
@@ -1,5 +1,3 @@
|
||||
// NOTE: For re-associating files with details here, can use dropdown in UI
|
||||
|
||||
export interface Playlist {
|
||||
'name': string;
|
||||
'songs': PlaylistSongs;
|
||||
|
||||
@@ -4,12 +4,17 @@ import {
|
||||
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;
|
||||
@@ -20,13 +25,12 @@ export const isConnected = ref( false );
|
||||
|
||||
export const useAntiTamper = ref( false );
|
||||
|
||||
// TODO: Anit-Tamper
|
||||
// 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;
|
||||
|
||||
@@ -42,6 +46,8 @@ const createRoom = async ( name: string, antiTamper: boolean ): Promise<boolean>
|
||||
useAntiTamper.value = antiTamper;
|
||||
room.value = name;
|
||||
|
||||
document.addEventListener( 'musicplayer:autherror', reauth );
|
||||
|
||||
return await connect();
|
||||
};
|
||||
|
||||
@@ -49,6 +55,11 @@ 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> => {
|
||||
@@ -101,9 +112,14 @@ let stateLock = false;
|
||||
const sendPlaylistData = () => {
|
||||
if ( isConnected.value && !playlistLock ) {
|
||||
playlistLock = true;
|
||||
request.post( `/room/${ room.value }/update/playlist`, JSON.stringify( {
|
||||
'playlist': queue.value
|
||||
} ) );
|
||||
|
||||
try {
|
||||
request.post( `/room/${ room.value }/update/playlist`, JSON.stringify( {
|
||||
'playlist': queue.value
|
||||
} ) );
|
||||
} catch ( e ) {
|
||||
console.error( e );
|
||||
}
|
||||
|
||||
setTimeout( () => {
|
||||
playlistLock = false;
|
||||
@@ -111,14 +127,20 @@ const sendPlaylistData = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const sendStateData = () => {
|
||||
const sendStateData = async () => {
|
||||
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
|
||||
} ) );
|
||||
|
||||
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;
|
||||
@@ -129,10 +151,30 @@ const sendStateData = () => {
|
||||
const useRoomWatchers = () => {
|
||||
watch( queue, sendPlaylistData );
|
||||
|
||||
watch( [
|
||||
isPlaying,
|
||||
queueIdx
|
||||
], sendStateData );
|
||||
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();
|
||||
};
|
||||
|
||||
@@ -29,7 +29,6 @@ import type {
|
||||
import {
|
||||
load
|
||||
} from './playlists/loader';
|
||||
import messages from '../messages';
|
||||
import {
|
||||
playIndex
|
||||
} from './playlists';
|
||||
@@ -39,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 );
|
||||
};
|
||||
|
||||
@@ -50,6 +51,7 @@ const play = () => {
|
||||
|
||||
sources[currentSource.value]?.play();
|
||||
startTracking();
|
||||
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
|
||||
};
|
||||
|
||||
const pause = () => {
|
||||
@@ -59,6 +61,7 @@ const pause = () => {
|
||||
|
||||
sources[currentSource.value]?.pause();
|
||||
stopTracking();
|
||||
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -69,6 +72,7 @@ const seekTo = ( pos: number ) => {
|
||||
if ( currentSource.value === '' ) return;
|
||||
|
||||
sources[currentSource.value]?.seekTo( pos );
|
||||
document.dispatchEvent( new CustomEvent( 'musicplayer:seek' ) );
|
||||
};
|
||||
|
||||
const skip10 = () => {
|
||||
@@ -136,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!();
|
||||
|
||||
@@ -149,8 +155,6 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
||||
return true;
|
||||
};
|
||||
|
||||
messages.useRoomWatchers();
|
||||
|
||||
export default {
|
||||
play,
|
||||
pause,
|
||||
|
||||
@@ -16,8 +16,8 @@ import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
playlistIdx
|
||||
} from '@/ts/userPlaylists/state';
|
||||
setPlaylistIdx
|
||||
} from '@/ts/userPlaylists';
|
||||
|
||||
export const addSongList = ( songs: Song[] ) => {
|
||||
rawQueue.value = rawQueue.value.concat( songs );
|
||||
@@ -25,7 +25,7 @@ export const addSongList = ( songs: Song[] ) => {
|
||||
};
|
||||
|
||||
export const clearQueue = () => {
|
||||
playlistIdx.value = -1;
|
||||
setPlaylistIdx( 0 );
|
||||
queue.value = [];
|
||||
rawQueue.value = [];
|
||||
sources[currentSource.value]?.stop();
|
||||
|
||||
@@ -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 );
|
||||
};
|
||||
|
||||
@@ -42,7 +42,6 @@ export const load = ( playlist: PlaylistSongs ) => {
|
||||
};
|
||||
|
||||
if ( needToLoadLocalSongs ) {
|
||||
// FIXME: Combine mime types
|
||||
const mime = Object.values( sources )
|
||||
.map( src => {
|
||||
return src.loading.requiresLocalFiles === true ? src.loading.mime : '';
|
||||
|
||||
+4
-1
@@ -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]! );
|
||||
};
|
||||
|
||||
@@ -28,7 +28,8 @@ const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
|
||||
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)
|
||||
document.dispatchEvent( new CustomEvent( 'musicplayer:autherror' ) );
|
||||
|
||||
throw new AuthError( 'ERR_USER_UNAUTHORIZED' );
|
||||
} else if ( res.status === 402 ) {
|
||||
throw new UnownedError( 'ERR_USER_UNOWNED' );
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
currentQueue,
|
||||
currentQueueIdx,
|
||||
isPlaying,
|
||||
playbackOffset,
|
||||
playbackProgress,
|
||||
playbackTime,
|
||||
startTime
|
||||
} from './state';
|
||||
import type {
|
||||
@@ -21,7 +24,7 @@ let hasConnected = false;
|
||||
let retries = 0;
|
||||
|
||||
// TODO: Persist settings in local storage
|
||||
// TODO: Polling instead of sse
|
||||
// TODO: Polling instead of sse as option
|
||||
|
||||
const connect = (): Promise<void> => {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
@@ -41,6 +44,13 @@ const connect = (): Promise<void> => {
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import {
|
||||
currentQueue,
|
||||
currentQueueIdx,
|
||||
isPlaying,
|
||||
playbackOffset,
|
||||
playbackTime,
|
||||
startTime
|
||||
} from './state';
|
||||
import type {
|
||||
@@ -17,6 +19,7 @@ export interface StateUpdate {
|
||||
'playing': boolean;
|
||||
'index': number;
|
||||
'start': number;
|
||||
'offset': number;
|
||||
}
|
||||
|
||||
export const messageHandler = ( msg: string ) => {
|
||||
@@ -36,6 +39,8 @@ export const messageHandler = ( msg: string ) => {
|
||||
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 );
|
||||
}
|
||||
|
||||
@@ -13,3 +13,11 @@ 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 );
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import {
|
||||
editingPlaylists,
|
||||
playlistIdx,
|
||||
playlists
|
||||
} from './state';
|
||||
import player from '../player';
|
||||
|
||||
|
||||
export const addPlaylist = ( name: string ) => {
|
||||
playlists.value.push( {
|
||||
@@ -17,3 +20,13 @@ export const removePlaylist = ( idx: number ) => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -16,15 +16,17 @@ import {
|
||||
useNotification
|
||||
} from '@kyvg/vue3-notification';
|
||||
|
||||
export const savePlaylist = async () => {
|
||||
export const savePlaylist = () => {
|
||||
rawQueue.value = queue.value;
|
||||
shuffle.value = false;
|
||||
|
||||
if ( playlistIdx.value ) {
|
||||
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 );
|
||||
|
||||
@@ -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 );
|
||||
};
|
||||
@@ -1,20 +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>
|
||||
<div>
|
||||
<CurrentSong />
|
||||
<div class="panel">
|
||||
<div class="current-song-wrapper">
|
||||
<CurrentSong v-model="song" :show-additional-info="true" />
|
||||
</div>
|
||||
<div>
|
||||
<SharedQueue />
|
||||
<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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user