feat: design for queue, add songs, search and more

This commit is contained in:
2026-09-01 15:33:21 +02:00
parent 3a0f8210dd
commit faa271edd6
19 changed files with 251 additions and 34 deletions
+1
View File
@@ -50,6 +50,7 @@
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
>.fa-solid {
font-size: 1.5rem;
+13
View File
@@ -0,0 +1,13 @@
<script setup lang="ts">
import PopupElement from './PopupElement.vue';
</script>
<template>
<div>
<PopupElement>
<h2></h2>
<button>Ok</button>
<button>Cancel</button>
</PopupElement>
</div>
</template>
+36 -11
View File
@@ -1,22 +1,46 @@
<script setup lang="ts">
import {
type Ref,
type WritableComputedRef,
computed,
ref
} from 'vue';
import AddSong from './AddSong.vue';
import type {
Song
} from '@/ts/dtype/playlist';
import SongEditor from './SongEditor.vue';
import SortableList from './SortableList.vue';
import {
beautifyTime
} from '@/ts/util/time';
import player from '@/ts/player';
import {
ref
} from 'vue';
const queue = player.queue;
const queueIndex = player.queueIdx;
const queue: WritableComputedRef<Song[]> = computed( {
get () {
return player.queue.value.slice( player.queueIdx.value );
},
set ( val ) {
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value ).concat( val );
}
} );
const isPlaying = player.isPlaying;
const showAddSong = ref( false );
const showEditSong = ref( false );
const editingSong: Ref<null | Song> = ref( null );
const addSong = () => {
showAddSong.value = true;
};
const editSong = ( idx: number ) => {
showEditSong.value = true;
editingSong.value = player.queue.value[ idx ]!;
};
const deleteSong = ( idx: number ) => {
player.removeSong( idx );
};
</script>
<template>
@@ -40,6 +64,7 @@
Transmit
</button>
</div>
<SongEditor v-model="showEditSong" editing-song="" />
<AddSong v-model="showAddSong" />
<div>
<SortableList v-slot="{ item: song, index }" v-model="queue">
@@ -52,7 +77,7 @@
class="song-cover"
>
<i v-else class="fa-solid fa-music song-cover"></i>
<div v-if="index === queueIndex" class="play-overlay">
<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>
@@ -67,15 +92,15 @@
<i class="fa-solid fa-play" @click="() => player.playIndex( index )"></i>
</div>
</div>
<div>
<div class="song-details">
<h3>{{ song.name }}</h3>
<p>{{ song.artist }}</p>
<p>{{ beautifyTime( song.duration ) }}</p>
<p>{{ song['additional-info'] }}</p>
</div>
<div>
<i v-if="index !== queueIndex" class="fa-solid fa-trash-can"></i>
<i class="fa-solid fa-pen-to-square"></i>
<div class="song-actions">
<p>{{ beautifyTime( song.duration ) }}</p>
<i v-if="index !== 0" class="fa-solid fa-trash-can" @click="() => deleteSong( index )"></i>
<i class="fa-solid fa-pen-to-square" @click="() => editSong( index )"></i>
</div>
</div>
</SortableList>
+16
View File
@@ -0,0 +1,16 @@
<script setup lang="ts">
import PopupElement from './PopupElement.vue';
const model = defineModel<boolean>( {
'required': true
} );
</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 -->
</PopupElement>
</div>
</template>
+2
View File
@@ -29,6 +29,8 @@
let moveSpeed = 0;
const start = ( ev: MouseEvent, idx: number ) => {
if ( array.value.length < 2 ) return;
movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight;
offset.value = ev.y - ( movableSize.value / 2 );
movingIdx.value = idx;
+30 -2
View File
@@ -28,8 +28,8 @@
<template>
<div>
<SearchView />
<PopupElement v-model="isShowingImportTypePicker">
<h3>What would you like to add?</h3>
<PopupElement v-model="isShowingImportTypePicker" show-close>
<h2>What would you like to add?</h2>
<div class="import-type-list">
<div v-for="(source, index) in importers" :key="index" @click="openPicker( source )">
{{ source.name }}
@@ -38,3 +38,31 @@
</PopupElement>
</div>
</template>
<style lang="scss" scoped>
.import-type-list {
display: flex;
flex-wrap: wrap;
width: 50vw;
height: 35vh;
justify-content: center;
overflow-y: scroll;
overflow-x: hidden;
>div {
width: 32%;
margin: 0.5%;
height: 50%;
background-color: var(--accent-background);
border-radius: 20px;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
>.fa-solid {
font-size: 1.5rem;
}
}
}
</style>
+16 -3
View File
@@ -15,7 +15,9 @@
const results: Ref<Song[]> = ref( [] );
const query = ref( '' );
const isSearching = ref( false );
const addedIndex = ref( -1 );
let addedTimeout = -1;
let timeout = -1;
let searchedForQuery = '';
@@ -46,7 +48,15 @@
};
const add = ( idx: number ) => {
try {
clearTimeout( addedTimeout );
} catch { /* empty */ }
addedIndex.value = idx;
searchOpts.value?.addSelected( idx );
addedTimeout = setTimeout( () => {
addedIndex.value = -1;
}, 2000 );
};
</script>
@@ -63,11 +73,14 @@
<div v-if="!isSearching" 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">
<h4>{{ result.name }}</h4>
<p>{{ result.artist }}</p>
<div>
<h4>{{ result.name }}</h4>
<p>{{ result.artist }}</p>
</div>
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
</div>
</div>
<div v-else class="search-results-wrapper">
<div v-else class="search-results-wrapper placeholder">
Searching...
</div>
</PopupElement>
+35
View File
@@ -6,6 +6,7 @@
height: 10rem;
width: 10rem;
position: relative;
margin-right: 20px;
.song-cover,
.fa-solid {
@@ -25,6 +26,7 @@
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
&.hover {
display: none;
@@ -37,6 +39,39 @@
}
}
}
>.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 {
+33 -1
View File
@@ -3,10 +3,42 @@
height: 60vh;
overflow-x: hidden;
overflow-y: scroll;
display: flex;
align-items: center;
flex-direction: column;
&.placeholder {
justify-content: center;
}
>div {
display: flex;
align-items: center;
margin-bottom: 5px;
width: 75%;
cursor: pointer;
>div {
display: flex;
justify-content: flex-start;
align-items: flex-start;
flex-direction: column;
height: 100%;
>* {
margin: 5px;
}
}
>img {
width: 10%;
height: 7rem;
width: 7rem;
margin-right: 20px;
}
>.fa-solid {
margin-left: auto;
font-size: 2rem;
}
}
}
@@ -18,6 +18,7 @@
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 5px;
&.moving {
position: fixed;
+8 -2
View File
@@ -1,6 +1,7 @@
import {
addSongList,
clearQueue,
removeSong,
shuffleList
} from './playlists/add';
import {
@@ -27,12 +28,12 @@ import {
} from './playlists';
const next = () => {
playIndex( queueIdx.value + 1 );
playIndex( ( queueIdx.value + 1 ) % queue.value.length );
};
const prev = () => {
// TODO: Back to beginning if more than some seconds have passed?
playIndex( queueIdx.value - 1 );
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
};
const play = () => {
@@ -117,6 +118,10 @@ const getSources = (): string[] => {
return Object.keys( sources );
};
/**
* 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 );
};
@@ -135,6 +140,7 @@ export default {
next,
prev,
clearQueue,
removeSong,
queue,
queueIdx,
duration,
-1
View File
@@ -35,7 +35,6 @@ export const removeSong = ( idx: number ) => {
const removed = queue.value.splice( idx, 1 )[0];
// TODO: Check that this works
for ( let i = 0; i < rawQueue.value.length; i++ ) {
if ( rawQueue.value[ i ] === removed ) {
rawQueue.value.splice( i, 1 );
+5
View File
@@ -90,6 +90,11 @@ export interface PlayerSourcePlugin {
*/
'getDuration': () => number;
/**
* Check if source is available for playback
*/
'available': () => boolean;
/**
* 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
+1
View File
@@ -12,6 +12,7 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
};
return {
'available': () => true,
'id': 'local',
'name': 'Local Disk',
'play': player.play,
@@ -43,6 +43,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
};
return {
'available': controls.getLoggedIn,
'play': controls.play,
'pause': controls.pause,
'stop': controls.stop,
@@ -34,6 +34,10 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
return musickitInstance.currentPlaybackDuration;
};
const getLoggedIn = (): boolean => {
return musickitInstance.isAuthorized;
};
return {
play,
pause,
@@ -41,6 +45,7 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
playSong,
stop,
getPlaybackPos,
getDuration
getDuration,
getLoggedIn
};
};
@@ -7,12 +7,16 @@ import type {
import {
openImportTypePicker
} from '@/composables/importTypePicker';
import {
searchPlaylists
} from './playlists';
import {
searchSongs
} from './songs';
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
const songs = await searchSongs( instance, cb );
const playlists = await searchPlaylists( instance, cb );
openImportTypePicker( [
{
@@ -24,8 +28,8 @@ export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs
{
'name': 'Playlists',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search
'addSelected': playlists.addSelected,
'search': playlists.search
}
] );
};
@@ -1,17 +1,24 @@
import type {
Song
} from '@/ts/dtype/playlist';
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const searchPlaylists = async ( instance: MusicKitInstance ) => {
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
// TODO: Get all playlists
const search = async () => {};
const addSelected = async ( idx: number ): Promise<Song[]> => {
// TODO: Load the playlist content and return songs
const search = async ( term: string ): Promise<Song[]> => {
return [];
};
const addSelected = async ( idx: number ) => {
// TODO: Implement
cb( [] );
};
return {
search,
addSelected
};
};
+26 -3
View File
@@ -1,10 +1,33 @@
<script setup lang="ts">
import PlayerControls from '@/components/PlayerControls.vue';
import QueueViewer from '@/components/QueueViewer.vue';
</script>
<template>
<div>
<h1>Player</h1>
<QueueViewer />
<div class="main-app">
<div class="player-controls">
<PlayerControls />
</div>
<div class="song-queue">
<QueueViewer />
</div>
</div>
</template>
<style lang="scss" scoped>
.main-app {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
flex-direction: column;
.player-controls {
width: 60%;
}
.song-queue {
width: 80%;
}
}
</style>