mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
feat: redesign main page, get player working (mostly) with Apple Music source
This commit is contained in:
+11
-12
@@ -6,38 +6,37 @@
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const theme = ref( '☼' );
|
||||
const theme = ref( 'dark' );
|
||||
|
||||
const changeTheme = () => {
|
||||
if ( theme.value === '☽' ) {
|
||||
if ( theme.value === 'moon' ) {
|
||||
document.documentElement.classList.remove( 'dark' );
|
||||
document.documentElement.classList.add( 'light' );
|
||||
localStorage.setItem( 'theme', '☼' );
|
||||
theme.value = '☼';
|
||||
} else if ( theme.value === '☼' ) {
|
||||
localStorage.setItem( 'theme', 'light' );
|
||||
theme.value = 'sun';
|
||||
} else if ( theme.value === 'sun' ) {
|
||||
document.documentElement.classList.remove( 'light' );
|
||||
document.documentElement.classList.add( 'dark' );
|
||||
localStorage.setItem( 'theme', '☽' );
|
||||
theme.value = '☽';
|
||||
localStorage.setItem( 'theme', 'dark' );
|
||||
theme.value = 'moon';
|
||||
}
|
||||
};
|
||||
|
||||
theme.value = localStorage.getItem( 'theme' ) ?? '';
|
||||
|
||||
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === '☽' ) {
|
||||
if ( window.matchMedia( '(prefers-color-scheme: dark)' ).matches || theme.value === 'dark' ) {
|
||||
document.documentElement.classList.add( 'dark' );
|
||||
theme.value = '☽';
|
||||
theme.value = 'moon';
|
||||
} else {
|
||||
document.documentElement.classList.add( 'light' );
|
||||
theme.value = '☼';
|
||||
theme.value = 'sun';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<span class="material-symbols-outlined" v-html="theme"></span>
|
||||
<i :class="['fa-solid', 'fa-' + theme]"></i>
|
||||
</button>
|
||||
<router-view id="main-view" v-slot="{ Component, route }">
|
||||
<transition :name="route.meta.transition ? String( route.meta.transition ) : 'fade'" mode="out-in">
|
||||
|
||||
@@ -10,9 +10,10 @@
|
||||
'required': true
|
||||
} );
|
||||
|
||||
const addSong = ( source: string ) => {
|
||||
player.addSongFromSource( source );
|
||||
showPopup.value = false;
|
||||
const addSong = async ( source: string ) => {
|
||||
if ( await player.addSongFromSource( source ) ) {
|
||||
showPopup.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -20,11 +21,19 @@
|
||||
<div>
|
||||
<ImportTypePicker />
|
||||
<PopupElement v-model="showPopup" show-close>
|
||||
<h1>Add Song</h1>
|
||||
<div class="title">
|
||||
<h1>Add Song(s)</h1>
|
||||
<p>Pick the source of your songs</p>
|
||||
</div>
|
||||
<div class="song-sources">
|
||||
<div v-for="(source, index) in sources" :key="index" @click="() => addSong( source.id )">
|
||||
<i v-if="source.icon" :class="['fa-solid', 'fa-' + source.icon]"></i>
|
||||
<p>{{ source.name }}</p>
|
||||
<div>
|
||||
<i v-if="source.icon" :class="['fa-solid', 'fa-' + source.icon]"></i>
|
||||
<p>{{ source.name }}</p>
|
||||
</div>
|
||||
<p v-if="!source.authorized.value" class="not-auth-notice">
|
||||
You have not yet logged into this source. Clicking this will start login
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</PopupElement>
|
||||
@@ -32,11 +41,20 @@
|
||||
</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: 35vh;
|
||||
height: 40vh;
|
||||
justify-content: center;
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
@@ -51,9 +69,21 @@
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
flex-direction: column;
|
||||
|
||||
>.fa-solid {
|
||||
font-size: 1.5rem;
|
||||
>div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
>.fa-solid {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
>.not-auth-notice {
|
||||
font-size: 0.6rem;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
const song = defineModel<Song>( {
|
||||
'required': false
|
||||
} );
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="current-song">
|
||||
<div class="artwork">
|
||||
<img
|
||||
v-if="song?.artwork"
|
||||
: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">
|
||||
<h1>
|
||||
{{ song?.name ?? 'Not playing' }}
|
||||
</h1>
|
||||
<p>{{ song?.artist ?? 'No artist' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.current-song {
|
||||
width: 100%;
|
||||
|
||||
.artwork {
|
||||
width: 100%;
|
||||
|
||||
>img, .fa-solid {
|
||||
width: 60%;
|
||||
max-height: 50%;
|
||||
font-size: 15vw;
|
||||
}
|
||||
}
|
||||
|
||||
.song-details {
|
||||
>* {
|
||||
margin: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -3,10 +3,12 @@
|
||||
import {
|
||||
beautifyTime
|
||||
} from '@/ts/util/time';
|
||||
import {
|
||||
computed
|
||||
} from 'vue';
|
||||
import player from '@/ts/player';
|
||||
|
||||
const playbackPercentage = player.playbackPercentage;
|
||||
const duration = player.duration;
|
||||
const repeatMode = player.repeat;
|
||||
const shuffleMode = player.shuffle;
|
||||
|
||||
@@ -31,6 +33,14 @@
|
||||
alert( 'Share menu not yet implemented' );
|
||||
};
|
||||
|
||||
const current = computed( () => {
|
||||
return beautifyTime( player.playbackPercentage.value * player.duration.value );
|
||||
} );
|
||||
const duration = computed( () => {
|
||||
return beautifyTime( player.duration.value );
|
||||
} );
|
||||
|
||||
|
||||
// TODO: Button availability
|
||||
</script>
|
||||
|
||||
@@ -39,7 +49,7 @@
|
||||
<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>
|
||||
<div :class="['play-pause', 'paused']">
|
||||
<div :class="['play-pause', player.isPlaying.value ? undefined : 'paused']">
|
||||
<i class="fa-solid fa-play" @click="player.play"></i>
|
||||
<i class="fa-solid fa-pause" @click="player.pause"></i>
|
||||
</div>
|
||||
@@ -48,12 +58,11 @@
|
||||
</div>
|
||||
|
||||
<div class="time">
|
||||
<!-- TODO: Probably needs to be a computed -->
|
||||
<p class="current">
|
||||
{{ beautifyTime( playbackPercentage * duration ) }}
|
||||
{{ current }}
|
||||
</p>
|
||||
<p class="duration">
|
||||
{{ beautifyTime( duration ) }}
|
||||
{{ duration }}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar v-model="playbackPercentage" @move-end="seek" />
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
transition-delay: 0;
|
||||
overflow: hidden;
|
||||
|
||||
.popup-backdrop {
|
||||
width: 100vw;
|
||||
@@ -42,7 +43,7 @@
|
||||
top: 0;
|
||||
left: 0;
|
||||
overflow: hidden;
|
||||
transition: all 0.5s;
|
||||
transition: all 0.25s;
|
||||
}
|
||||
|
||||
.popup-main {
|
||||
@@ -53,7 +54,7 @@
|
||||
border-radius: 20px;
|
||||
transform: scale(1);
|
||||
position: relative;
|
||||
overflow-y: scroll;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
transition: transform 0.25s ease, opacity 0.25s ease;
|
||||
|
||||
@@ -69,7 +70,7 @@
|
||||
|
||||
&.hidden {
|
||||
top: -100vh;
|
||||
transition-delay: 0.5s;
|
||||
transition-delay: 0.25s;
|
||||
|
||||
.popup-main {
|
||||
transform: scale(1.25);
|
||||
|
||||
@@ -9,22 +9,27 @@
|
||||
'default': 0.5
|
||||
} );
|
||||
const offset = ref( -1 );
|
||||
const isMoving = ref( false );
|
||||
const bar = useTemplateRef( 'bar' );
|
||||
|
||||
const start = ( ev: MouseEvent ) => {
|
||||
offset.value = ev.x - bar.value!.offsetLeft;
|
||||
val.value = offset.value / bar.value!.clientWidth;
|
||||
offset.value = bar.value!.getBoundingClientRect().x;
|
||||
isMoving.value = true;
|
||||
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||
emit( 'move-start' );
|
||||
};
|
||||
|
||||
const move = ( ev: MouseEvent ) => {
|
||||
if ( offset.value > -1 ) {
|
||||
val.value = Math.max( 0, Math.min( ev.x / bar.value!.clientWidth, 1 ) );
|
||||
if ( isMoving.value ) {
|
||||
val.value = Math.max( 0, Math.min( ( ev.x - offset.value ) / bar.value!.clientWidth, 1 ) );
|
||||
}
|
||||
};
|
||||
|
||||
const end = () => {
|
||||
if ( !isMoving.value ) return;
|
||||
|
||||
offset.value = -1;
|
||||
isMoving.value = false;
|
||||
emit( 'move-end' );
|
||||
};
|
||||
|
||||
|
||||
@@ -15,16 +15,18 @@
|
||||
beautifyTime
|
||||
} from '@/ts/util/time';
|
||||
import player from '@/ts/player';
|
||||
import {
|
||||
queueIdx
|
||||
} from '@/ts/player/state';
|
||||
|
||||
const queue: WritableComputedRef<Song[]> = computed( {
|
||||
get () {
|
||||
return player.queue.value.slice( player.queueIdx.value );
|
||||
return player.queue.value.slice( player.queueIdx.value + 1 );
|
||||
},
|
||||
set ( val ) {
|
||||
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value ).concat( val );
|
||||
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value + 1 ).concat( val );
|
||||
}
|
||||
} );
|
||||
const isPlaying = player.isPlaying;
|
||||
const showAddSong = ref( false );
|
||||
const showEditSong = ref( false );
|
||||
const editingSong: Ref<null | Song> = ref( null );
|
||||
@@ -35,16 +37,16 @@
|
||||
|
||||
const editSong = ( idx: number ) => {
|
||||
showEditSong.value = true;
|
||||
editingSong.value = player.queue.value[ idx ]!;
|
||||
editingSong.value = player.queue.value[ idx + queueIdx.value + 1 ]!;
|
||||
};
|
||||
|
||||
const deleteSong = ( idx: number ) => {
|
||||
player.removeSong( idx );
|
||||
player.removeSong( idx + queueIdx.value + 1 );
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="queue-viewer">
|
||||
<div>
|
||||
<button @click="addSong">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
@@ -54,6 +56,7 @@
|
||||
<i class="fa-solid fa-xmark"></i>
|
||||
Clear
|
||||
</button>
|
||||
<!-- TODO: On save, copy the current queue into rawQueue! -->
|
||||
<button>
|
||||
<i class="fa-solid fa-save"></i>
|
||||
Save
|
||||
@@ -66,7 +69,7 @@
|
||||
</div>
|
||||
<SongEditor v-model="showEditSong" editing-song="" />
|
||||
<AddSong v-model="showAddSong" />
|
||||
<div>
|
||||
<div v-if="queue.length > 0" class="queue-container">
|
||||
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
||||
<div class="song-list-element">
|
||||
<div class="song-cover-wrapper">
|
||||
@@ -77,19 +80,8 @@
|
||||
class="song-cover"
|
||||
>
|
||||
<i v-else class="fa-solid fa-music song-cover"></i>
|
||||
<div v-if="index === 0" class="play-overlay">
|
||||
<div v-if="isPlaying" class="playing-symbols">
|
||||
<div id="bar-1" class="playing-bar"></div>
|
||||
<div id="bar-2" class="playing-bar"></div>
|
||||
<div id="bar-3" class="playing-bar"></div>
|
||||
</div>
|
||||
<i
|
||||
v-else
|
||||
class="fa-solid fa-pause"
|
||||
></i>
|
||||
</div>
|
||||
<div v-else class="play-overlay hover">
|
||||
<i class="fa-solid fa-play" @click="() => player.playIndex( index )"></i>
|
||||
<div class="play-overlay hover">
|
||||
<i class="fa-solid fa-play" @click="() => player.playIndex( index + queueIdx + 1 )"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="song-details">
|
||||
@@ -105,6 +97,16 @@
|
||||
</div>
|
||||
</SortableList>
|
||||
</div>
|
||||
<div v-else class="queue-container">
|
||||
<p>
|
||||
No more songs in queue
|
||||
</p>
|
||||
|
||||
<button @click="addSong">
|
||||
<i class="fa-solid fa-plus"></i>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -70,9 +70,9 @@
|
||||
placeholder="Search..."
|
||||
@keypress="search"
|
||||
>
|
||||
<div v-if="!isSearching" class="search-results-wrapper">
|
||||
<div v-if="!isSearching && results.length > 0" class="search-results-wrapper">
|
||||
<div v-for="(result, index) in results" :key="index" @click="() => add(index)">
|
||||
<img :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
|
||||
<img v-if="result.artwork" :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
|
||||
<div>
|
||||
<h4>{{ result.name }}</h4>
|
||||
<p>{{ result.artist }}</p>
|
||||
@@ -80,6 +80,9 @@
|
||||
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
|
||||
No results found
|
||||
</div>
|
||||
<div v-else class="search-results-wrapper placeholder">
|
||||
Searching...
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory
|
||||
} from 'vue-router';
|
||||
import {
|
||||
useAuthStore
|
||||
} from '@/stores/authstore';
|
||||
import {
|
||||
createRouter, createWebHistory
|
||||
} from 'vue-router';
|
||||
|
||||
const router = createRouter( {
|
||||
'history': createWebHistory( import.meta.env.BASE_URL ),
|
||||
@@ -38,16 +39,16 @@ const router = createRouter( {
|
||||
]
|
||||
} );
|
||||
|
||||
router.beforeEach( ( to, from ) => {
|
||||
router.beforeEach( to => {
|
||||
const store = useAuthStore();
|
||||
|
||||
if ( to.meta.authRequired && !store.isAuth ) {
|
||||
if ( to.meta.auth && !store.isAuth ) {
|
||||
return {
|
||||
'name': 'login'
|
||||
'name': 'home'
|
||||
};
|
||||
} else if ( ( to.name === 'login' && store.isAuth ) || ( to.name === 'signup' && store.isAuth ) ) {
|
||||
} else if ( to.name === 'home' && store.isAuth ) {
|
||||
return {
|
||||
'name': 'app-home'
|
||||
'name': 'app'
|
||||
};
|
||||
}
|
||||
} );
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
.mp-player {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -19,10 +18,15 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3rem;
|
||||
|
||||
>.play-pause {
|
||||
cursor: pointer;
|
||||
|
||||
>.fa-play {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.paused {
|
||||
>.fa-pause {
|
||||
display: none;
|
||||
@@ -60,6 +64,11 @@
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
>p {
|
||||
margin-top: 0px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
>.duration {
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -70,6 +79,12 @@
|
||||
}
|
||||
|
||||
>.bottom-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 3rem;
|
||||
margin-top: 15px;
|
||||
|
||||
>.fa-shuffle {
|
||||
&.active {
|
||||
background-color: var(--primary-color);
|
||||
|
||||
@@ -1,127 +1,92 @@
|
||||
.song-list-element {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
.queue-viewer {
|
||||
height: 100%;
|
||||
width: 90%;
|
||||
overflow: hidden;
|
||||
|
||||
>.song-cover-wrapper {
|
||||
height: 10rem;
|
||||
width: 10rem;
|
||||
position: relative;
|
||||
margin-right: 20px;
|
||||
>.queue-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 90%;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.song-cover,
|
||||
.fa-solid {
|
||||
height: 10rem;
|
||||
width: 10rem;
|
||||
font-size: 7rem;
|
||||
line-height: 100%;
|
||||
.song-list-element {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
>.play-overlay {
|
||||
background-color: var(--overlay-color);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&.hover {
|
||||
display: none;
|
||||
>.song-cover-wrapper {
|
||||
height: 7rem;
|
||||
width: 7rem;
|
||||
position: relative;
|
||||
margin-right: 20px;
|
||||
|
||||
.song-cover,
|
||||
.fa-solid {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
font-size: 6rem;
|
||||
line-height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
>.play-overlay {
|
||||
background-color: var(--overlay-color);
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
&.hover {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
>.play-overlay.hover {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
>.play-overlay.hover {
|
||||
display: block;
|
||||
>.song-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
>* {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
>h3 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
>.song-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: 10px;
|
||||
|
||||
>* {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
>.fa-solid {
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.song-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
|
||||
>* {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
>h3 {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
}
|
||||
|
||||
>.song-actions {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
margin-right: 10px;
|
||||
|
||||
>* {
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
>.fa-solid {
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.playing-symbols {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
margin: 0;
|
||||
padding-left: 20%;
|
||||
padding-right: 20%;
|
||||
width: 60%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
|
||||
.playing-bar {
|
||||
height: 40%;
|
||||
background-color: white;
|
||||
width: 12%;
|
||||
border-radius: 50px;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
#bar-1 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
}
|
||||
|
||||
#bar-2 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.3s;
|
||||
}
|
||||
|
||||
#bar-3 {
|
||||
animation: music-playing 0.9s infinite ease-in-out;
|
||||
animation-delay: 0.6s;
|
||||
}
|
||||
|
||||
@keyframes music-playing {
|
||||
0% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(0.5);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleY(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
>.grip-target {
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
export const useAuthStore = defineStore( 'authstore', () => {
|
||||
const isAuth = ref( false );
|
||||
const isAuth = ref( true );
|
||||
|
||||
return {
|
||||
isAuth
|
||||
|
||||
@@ -23,6 +23,9 @@ import {
|
||||
import type {
|
||||
RepeatMode
|
||||
} from '../dtype/player';
|
||||
import type {
|
||||
Song
|
||||
} from '../dtype/playlist';
|
||||
import {
|
||||
playIndex
|
||||
} from './playlists';
|
||||
@@ -118,12 +121,29 @@ const getSources = (): string[] => {
|
||||
return Object.keys( sources );
|
||||
};
|
||||
|
||||
const addToSongList = ( songs: Song[] ) => {
|
||||
addSongList( songs );
|
||||
|
||||
if ( currentSource.value === '' ) {
|
||||
playIndex( 0 );
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Add songs to the playlist from given source
|
||||
* @param source - The ID of the source to add from
|
||||
*/
|
||||
const addSongFromSource = async ( source: string ) => {
|
||||
sources[source]!.addSongsFromThisSource( addSongList );
|
||||
const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
||||
if ( sources[source]!.authorized.value ) {
|
||||
sources[source]!.addSongsFromThisSource( addToSongList );
|
||||
} else {
|
||||
// TODO: Make this interact with user
|
||||
sources[source]!.login!();
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import {
|
||||
currentSource,
|
||||
isPlaying,
|
||||
queue,
|
||||
queueIdx,
|
||||
rawQueue,
|
||||
shuffle
|
||||
shuffle,
|
||||
sources
|
||||
} from '../state';
|
||||
import {
|
||||
duration,
|
||||
playbackPercentage
|
||||
} from '../status-tracking';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
@@ -23,6 +30,10 @@ export const addSongList = ( songs: Song[], first: boolean = false ) => {
|
||||
export const clearQueue = () => {
|
||||
queue.value = [];
|
||||
rawQueue.value = [];
|
||||
sources[currentSource.value]?.stop();
|
||||
duration.value = -1;
|
||||
playbackPercentage.value = 1;
|
||||
isPlaying.value = false;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
currentSource,
|
||||
isPlaying,
|
||||
queue,
|
||||
queueIdx,
|
||||
repeat,
|
||||
@@ -21,9 +22,15 @@ export const playIndex = async ( idx: number ) => {
|
||||
idx = repeat.value === 'all' ? queue.value.length - 1 : -1;
|
||||
}
|
||||
|
||||
if ( idx < 0 ) return false;
|
||||
|
||||
sources[currentSource.value]?.stop();
|
||||
|
||||
if ( idx < 0 ) {
|
||||
isPlaying.value = false;
|
||||
currentSource.value = '';
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
stopTracking();
|
||||
|
||||
const nextSong = queue.value[ idx ]!;
|
||||
@@ -33,4 +40,6 @@ export const playIndex = async ( idx: number ) => {
|
||||
sources[currentSource.value]?.playSong( nextSong.identifier );
|
||||
|
||||
startTracking();
|
||||
|
||||
isPlaying.value = true;
|
||||
};
|
||||
|
||||
+5
-2
@@ -1,3 +1,6 @@
|
||||
import type {
|
||||
Ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
@@ -91,9 +94,9 @@ export interface PlayerSourcePlugin {
|
||||
'getDuration': () => number;
|
||||
|
||||
/**
|
||||
* Check if source is available for playback
|
||||
* Check if source is authorized
|
||||
*/
|
||||
'available': () => boolean;
|
||||
'authorized': Ref<boolean>;
|
||||
|
||||
/**
|
||||
* Called when user adds another song to the playlist via this source.
|
||||
|
||||
@@ -2,6 +2,9 @@ import type {
|
||||
PlayerSourcePlugin,
|
||||
PlayerSourcePluginInitializer
|
||||
} from '../interface';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
|
||||
const player = document.createElement( 'audio' );
|
||||
@@ -12,7 +15,7 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
|
||||
};
|
||||
|
||||
return {
|
||||
'available': () => true,
|
||||
'authorized': ref( true ),
|
||||
'id': 'local',
|
||||
'name': 'Local Disk',
|
||||
'play': player.play,
|
||||
|
||||
@@ -43,7 +43,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
|
||||
};
|
||||
|
||||
return {
|
||||
'available': controls.getLoggedIn,
|
||||
'authorized': controls.loggedIn,
|
||||
'play': controls.play,
|
||||
'pause': controls.pause,
|
||||
'stop': controls.stop,
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
|
||||
const play = () => {
|
||||
@@ -27,16 +30,18 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
|
||||
};
|
||||
|
||||
const getPlaybackPos = (): number => {
|
||||
return musickitInstance.currentPlaybackProgress;
|
||||
return musickitInstance.currentPlaybackTime / musickitInstance.currentPlaybackDuration;
|
||||
};
|
||||
|
||||
const getDuration = (): number => {
|
||||
return musickitInstance.currentPlaybackDuration;
|
||||
};
|
||||
|
||||
const getLoggedIn = (): boolean => {
|
||||
return musickitInstance.isAuthorized;
|
||||
};
|
||||
const loggedIn = ref( musickitInstance.isAuthorized );
|
||||
|
||||
musickitInstance.addEventListener( 'authorizationStatusDidChange', () => {
|
||||
loggedIn.value = musickitInstance.isAuthorized;
|
||||
} );
|
||||
|
||||
return {
|
||||
play,
|
||||
@@ -46,6 +51,6 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
|
||||
stop,
|
||||
getPlaybackPos,
|
||||
getDuration,
|
||||
getLoggedIn
|
||||
loggedIn
|
||||
};
|
||||
};
|
||||
|
||||
+11
-1
@@ -18,9 +18,19 @@ export interface AppleMusicSongData {
|
||||
'attributes': {
|
||||
'albumName': string;
|
||||
'artistName': string;
|
||||
'artwork': Artwork,
|
||||
'artwork': Artwork;
|
||||
'name': string;
|
||||
'genreNames': string[];
|
||||
'durationInMillis': number;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AppleMusicPlaylistData {
|
||||
'id': string;
|
||||
'type': string;
|
||||
'href': string;
|
||||
'attributes': {
|
||||
'artwork': Artwork;
|
||||
'name': string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import type {
|
||||
AppleMusicPlaylistData
|
||||
} from './dtype';
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
@@ -6,10 +9,33 @@ import type {
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||
// TODO: Get all playlists
|
||||
let playlists: Song[] = [];
|
||||
let filtered: Song[] = [];
|
||||
|
||||
const search = async ( term: string ): Promise<Song[]> => {
|
||||
return [];
|
||||
if ( playlists.length === 0 ) {
|
||||
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
|
||||
'limit': 100
|
||||
} ) ).data as {
|
||||
'data': AppleMusicPlaylistData[]
|
||||
} ).data.map( val => {
|
||||
return {
|
||||
'artist': 'Playlist',
|
||||
'artwork': val.attributes.artwork ? window.MusicKit.formatArtworkURL( val.attributes.artwork, 1000, 100 ) : '',
|
||||
'duration': 0,
|
||||
'identifier': val.id,
|
||||
'additional-info': '',
|
||||
'name': val.attributes.name,
|
||||
'source': 'applemusic'
|
||||
};
|
||||
} );
|
||||
}
|
||||
|
||||
filtered = playlists.filter( val => {
|
||||
return val.name.includes( term );
|
||||
} );
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const addSelected = async ( idx: number ) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
AppleMusicApiSearchResult
|
||||
AppleMusicApiSearchResult,
|
||||
AppleMusicSongData
|
||||
} from './dtype';
|
||||
import type {
|
||||
MusicKitInstance
|
||||
@@ -16,7 +17,16 @@ export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song
|
||||
'term': term,
|
||||
'types': [ 'songs' ]
|
||||
};
|
||||
const results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
|
||||
|
||||
let results: AppleMusicSongData[] = [];
|
||||
|
||||
try {
|
||||
results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
|
||||
} catch {
|
||||
console.debug( 'Failed results: got', results );
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
songs = [];
|
||||
|
||||
|
||||
@@ -24,12 +24,15 @@ export const startTracking = () => {
|
||||
}
|
||||
|
||||
duration.value = sources[currentSource.value]?.getDuration() ?? -1;
|
||||
setTimeout( () => {
|
||||
duration.value = sources[currentSource.value]?.getDuration() ?? -1;
|
||||
}, 2000 );
|
||||
};
|
||||
|
||||
const tracker = () => {
|
||||
playbackPercentage.value = sources[currentSource.value]?.getPlaybackPos() ?? -1;
|
||||
|
||||
if ( playbackPercentage.value > duration.value ) {
|
||||
if ( playbackPercentage.value > 0.995 && duration.value > 0 ) {
|
||||
if ( repeat.value === 'one' ) {
|
||||
sources[currentSource.value]?.seekTo( 0 );
|
||||
} else {
|
||||
@@ -42,8 +45,9 @@ const tracker = () => {
|
||||
export const stopTracking = () => {
|
||||
try {
|
||||
clearInterval( interval );
|
||||
interval = -1;
|
||||
} catch {
|
||||
// empty
|
||||
}
|
||||
|
||||
interval = -1;
|
||||
};
|
||||
|
||||
@@ -10,13 +10,13 @@ export const beautifyTime = ( time: number ): string => {
|
||||
else if ( Math.floor( t / 60 ) > 0 )
|
||||
return `${ helper( Math.floor( t / 60 ), depth + 1 ) }:${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
|
||||
else
|
||||
return `${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
|
||||
return `${ depth === 1 ? '00:' : '' }${ t % 60 < 10 ? '0' : '' }${ Math.floor( t % 60 ) }`;
|
||||
};
|
||||
|
||||
if ( time < 0 ) {
|
||||
return '-:--';
|
||||
if ( time < 0 || isNaN( time ) ) {
|
||||
return '--:--';
|
||||
} else if ( time == 0 ) {
|
||||
return '0:00';
|
||||
return '00:00';
|
||||
}
|
||||
|
||||
return helper( Math.round( time ), 1 );
|
||||
|
||||
@@ -1,14 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
queue,
|
||||
queueIdx
|
||||
} from '@/ts/player/state';
|
||||
import CurrentSong from '@/components/CurrentSong.vue';
|
||||
import PlayerControls from '@/components/PlayerControls.vue';
|
||||
import QueueViewer from '@/components/QueueViewer.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="main-app">
|
||||
<div class="player-controls">
|
||||
<div
|
||||
class="panel"
|
||||
>
|
||||
<div style="margin-bottom: 20px; width: 100%;">
|
||||
<CurrentSong
|
||||
v-model="queue[queueIdx]"
|
||||
/>
|
||||
</div>
|
||||
<PlayerControls />
|
||||
</div>
|
||||
<div class="song-queue">
|
||||
<div class="panel">
|
||||
<QueueViewer />
|
||||
</div>
|
||||
</div>
|
||||
@@ -17,17 +29,21 @@
|
||||
<style lang="scss" scoped>
|
||||
.main-app {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
flex-direction: row;
|
||||
|
||||
.player-controls {
|
||||
width: 60%;
|
||||
}
|
||||
|
||||
.song-queue {
|
||||
width: 80%;
|
||||
.panel {
|
||||
width: 45%;
|
||||
margin-left: 2.5%;
|
||||
margin-right: 2.5%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user