Compare commits

3 Commits
39 changed files with 743 additions and 111 deletions
+2 -3
View File
@@ -9,7 +9,7 @@ const run = () => {
const app = express(); const app = express();
app.use( expressSession( { app.use( expressSession( {
'secret': '', 'secret': 'dev',
'resave': true, 'resave': true,
'saveUninitialized': false 'saveUninitialized': false
} ) ); } ) );
@@ -29,8 +29,7 @@ const run = () => {
__dirname, __dirname,
'/config/apple-music-api.config.secret.json' '/config/apple-music-api.config.secret.json'
) ).toString() ); ) ).toString() );
app.get( '/dev-token', ( _request: express.Request, response: express.Response ) => {
app.get( '/dev-token', ( request: express.Request, response: express.Response ) => {
// sign dev token // sign dev token
const now = new Date().getTime(); const now = new Date().getTime();
const tomorrow = now + ( 24 * 3600 * 1000 ); const tomorrow = now + ( 24 * 3600 * 1000 );
+7 -2
View File
@@ -1,13 +1,18 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>MusicPlayer</title>
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<noscript>This application requires JavaScript to work!</noscript>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>
+3
View File
@@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
import PopupElement from '@/components/PopupElement.vue'; import PopupElement from '@/components/PopupElement.vue';
import player from '@/ts/player'; import player from '@/ts/player';
import { import {
@@ -17,6 +18,7 @@
<template> <template>
<div> <div>
<ImportTypePicker />
<PopupElement v-model="showPopup" show-close> <PopupElement v-model="showPopup" show-close>
<h1>Add Song</h1> <h1>Add Song</h1>
<div class="song-sources"> <div class="song-sources">
@@ -48,6 +50,7 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
cursor: pointer;
>.fa-solid { >.fa-solid {
font-size: 1.5rem; 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>
+1 -1
View File
@@ -79,5 +79,5 @@
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/scss/components/player.scss'; @use '@/scss/components/player.scss';
</style> </style>
+1 -1
View File
@@ -50,5 +50,5 @@
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/scss/components/progressbar.scss'; @use '@/scss/components/progressbar.scss';
</style> </style>
+54 -16
View File
@@ -1,22 +1,46 @@
<script setup lang="ts"> <script setup lang="ts">
import {
type Ref,
type WritableComputedRef,
computed,
ref
} from 'vue';
import AddSong from './AddSong.vue'; import AddSong from './AddSong.vue';
import type {
Song
} from '@/ts/dtype/playlist';
import SongEditor from './SongEditor.vue';
import SortableList from './SortableList.vue'; import SortableList from './SortableList.vue';
import { import {
beautifyTime beautifyTime
} from '@/ts/util/time'; } from '@/ts/util/time';
import player from '@/ts/player'; import player from '@/ts/player';
import {
ref
} from 'vue';
const queue = player.queue; const queue: WritableComputedRef<Song[]> = computed( {
const idx = player.queueIdx; 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 isPlaying = player.isPlaying;
const showAddSong = ref( false ); const showAddSong = ref( false );
const showEditSong = ref( false );
const editingSong: Ref<null | Song> = ref( null );
const addSong = () => { const addSong = () => {
showAddSong.value = true; showAddSong.value = true;
}; };
const editSong = ( idx: number ) => {
showEditSong.value = true;
editingSong.value = player.queue.value[ idx ]!;
};
const deleteSong = ( idx: number ) => {
player.removeSong( idx );
};
</script> </script>
<template> <template>
@@ -26,7 +50,7 @@
<i class="fa-solid fa-plus"></i> <i class="fa-solid fa-plus"></i>
Add Add
</button> </button>
<button> <button @click="player.clearQueue">
<i class="fa-solid fa-xmark"></i> <i class="fa-solid fa-xmark"></i>
Clear Clear
</button> </button>
@@ -40,36 +64,50 @@
Transmit Transmit
</button> </button>
</div> </div>
<SongEditor v-model="showEditSong" editing-song="" />
<AddSong v-model="showAddSong" /> <AddSong v-model="showAddSong" />
<div> <div>
<SortableList v-slot="{ item: song, index }" v-model="queue"> <SortableList v-slot="{ item: song, index }" v-model="queue">
<div class="song-list-element">
<div class="song-cover-wrapper"> <div class="song-cover-wrapper">
<img <img
v-if="song.cover" v-if="song.artwork"
:src="song.cover" :src="song.artwork"
alt="Song cover" alt="Song cover"
class="song-cover" class="song-cover"
> >
<i v-else class="fa-solid fa-music song-cover"></i> <i v-else class="fa-solid fa-music song-cover"></i>
<div v-if="isPlaying && index === idx" class="playing-symbols"> <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-1" class="playing-bar"></div>
<div id="bar-2" class="playing-bar"></div> <div id="bar-2" class="playing-bar"></div>
<div id="bar-3" class="playing-bar"></div> <div id="bar-3" class="playing-bar"></div>
</div> </div>
<i v-else class="fa-solid fa-play play-pause" @click="() => player.playIndex( index )"></i> <i
<i class="fa-solid fa-pause play-pause" @click="player.pause"></i> v-else
class="fa-solid fa-pause"
></i>
</div> </div>
<div> <div v-else class="play-overlay hover">
<i class="fa-solid fa-play" @click="() => player.playIndex( index )"></i>
</div>
</div>
<div class="song-details">
<h3>{{ song.name }}</h3> <h3>{{ song.name }}</h3>
<p>{{ song.artist }}</p> <p>{{ song.artist }}</p>
<p>{{ beautifyTime( song.duration ) }}</p>
<p>{{ song['additional-info'] }}</p> <p>{{ song['additional-info'] }}</p>
</div> </div>
<div> <div class="song-actions">
<i class="fa-solid fa-trash-can"></i> <p>{{ beautifyTime( song.duration ) }}</p>
<i class="fa-solid fa-pen-to-square"></i> <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> </div>
</SortableList> </SortableList>
</div> </div>
</div> </div>
</template> </template>
<style lang="scss" scoped>
@use '@/scss/components/queue.scss';
</style>
+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>
+6 -3
View File
@@ -29,6 +29,8 @@
let moveSpeed = 0; let moveSpeed = 0;
const start = ( ev: MouseEvent, idx: number ) => { const start = ( ev: MouseEvent, idx: number ) => {
if ( array.value.length < 2 ) return;
movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight; movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight;
offset.value = ev.y - ( movableSize.value / 2 ); offset.value = ev.y - ( movableSize.value / 2 );
movingIdx.value = idx; movingIdx.value = idx;
@@ -95,9 +97,10 @@
const before = array.value.slice( 0, movingIdx.value ); const before = array.value.slice( 0, movingIdx.value );
const after = array.value.slice( movingIdx.value + 1 ); const after = array.value.slice( movingIdx.value + 1 );
const el = array.value[ movingIdx.value ]!; const el = array.value[ movingIdx.value ]!;
const arr = before.concat( after );
array.value = before.concat( after ); arr.splice( movingCurrentIdx.value, 0, el );
array.value.splice( movingCurrentIdx.value, 0, el ); array.value = arr;
movingIdx.value = -1; movingIdx.value = -1;
movingCurrentIdx.value = -1; movingCurrentIdx.value = -1;
@@ -156,5 +159,5 @@
</template> </template>
<style lang="scss" scoped> <style lang="scss" scoped>
@import '@/scss/components/sortablelist.scss' @use '@/scss/components/sortablelist.scss'
</style> </style>
+14
View File
@@ -0,0 +1,14 @@
<script setup lang="ts">
import PopupElement from '@/components/PopupElement.vue';
import {
isShowingDiskLoader
} from './diskLoader';
</script>
<template>
<div>
<PopupElement v-model="isShowingDiskLoader" show-close>
<h3>Load from disk</h3>
</PopupElement>
</div>
</template>
+68
View File
@@ -0,0 +1,68 @@
<script setup lang="ts">
import {
type ImportTypes,
importers,
isShowingImportTypePicker
} from './importTypePicker';
import PopupElement from '@/components/PopupElement.vue';
import SearchView from './SearchView.vue';
import {
openDiskLoaderInterface
} from './diskLoader';
import {
openSearchInterface
} from './searchManager';
const openPicker = ( source: ImportTypes ) => {
isShowingImportTypePicker.value = false;
if ( source.type === 'cloud' ) {
openSearchInterface( source );
} else {
openDiskLoaderInterface( source );
}
};
</script>
<template>
<div>
<SearchView />
<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 }}
</div>
</div>
</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>
+92
View File
@@ -0,0 +1,92 @@
<script setup lang="ts">
import {
type Ref,
ref
} from 'vue';
import {
isShowingSearchView,
searchOpts
} from './searchManager';
import PopupElement from '@/components/PopupElement.vue';
import type {
Song
} from '@/ts/dtype/playlist';
const results: Ref<Song[]> = ref( [] );
const query = ref( '' );
const isSearching = ref( false );
const addedIndex = ref( -1 );
let addedTimeout = -1;
let timeout = -1;
let searchedForQuery = '';
const search = ( ev: KeyboardEvent ) => {
if ( ev.key === 'Enter' ) {
// TODO: If no new input, instead of searching, add first song
try {
clearTimeout( timeout );
} catch { /* empty */ }
doSearch();
} else if ( query.value.length > 2 ) {
try {
clearTimeout( timeout );
} catch { /* empty */ }
timeout = setTimeout( doSearch, 250 );
}
};
const doSearch = async () => {
if ( query.value === searchedForQuery ) return;
isSearching.value = true;
searchedForQuery = query.value;
results.value = await searchOpts.value?.search( query.value ) ?? [];
isSearching.value = false;
};
const add = ( idx: number ) => {
try {
clearTimeout( addedTimeout );
} catch { /* empty */ }
addedIndex.value = idx;
searchOpts.value?.addSelected( idx );
addedTimeout = setTimeout( () => {
addedIndex.value = -1;
}, 2000 );
};
</script>
<template>
<div>
<PopupElement v-model="isShowingSearchView" show-close>
<h2>Search {{ searchOpts?.name }}</h2>
<input
v-model="query"
type="text"
placeholder="Search..."
@keypress="search"
>
<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">
<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 placeholder">
Searching...
</div>
</PopupElement>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/search.scss';
</style>
View File
+16
View File
@@ -0,0 +1,16 @@
import {
type Ref,
ref
} from 'vue';
import type {
FileImport
} from './importTypePicker';
export const isShowingDiskLoader = ref( false );
export const diskLoaderOpts: Ref<FileImport | null> = ref( null );
export const openDiskLoaderInterface = ( options: FileImport ) => {
isShowingDiskLoader.value = true;
diskLoaderOpts.value = options;
};
+18 -2
View File
@@ -1,3 +1,7 @@
import {
type Ref,
ref
} from 'vue';
import type { import type {
Song Song
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
@@ -5,13 +9,25 @@ import type {
export interface CloudImport { export interface CloudImport {
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs // TODO: Consider if should make selectable and display album / playlist contents to select specific songs
'name': string; 'name': string;
'kind': 'list' | 'nested-list'; 'type': 'cloud';
'search': ( term: string ) => Promise<Song[]>; 'search': ( term: string ) => Promise<Song[]>;
'addSelected': ( index: number ) => Promise<Song[]>; 'addSelected': ( index: number ) => void;
} }
// TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?) // TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?)
export interface FileImport { export interface FileImport {
'name': string; 'name': string;
'type': 'file';
'process': ( files: File[] ) => Promise<Song[]>; 'process': ( files: File[] ) => Promise<Song[]>;
} }
export type ImportTypes = CloudImport | FileImport;
export const isShowingImportTypePicker = ref( false );
export const importers: Ref<( CloudImport | FileImport )[]> = ref( [] );
export const openImportTypePicker = ( options: ( CloudImport | FileImport )[] ) => {
isShowingImportTypePicker.value = true;
importers.value = options;
};
+16
View File
@@ -0,0 +1,16 @@
import {
type Ref,
ref
} from 'vue';
import type {
CloudImport
} from './importTypePicker';
export const isShowingSearchView = ref( false );
export const searchOpts: Ref<CloudImport | null> = ref( null );
export const openSearchInterface = ( options: CloudImport ) => {
searchOpts.value = options;
isShowingSearchView.value = true;
};
View File
+127
View File
@@ -0,0 +1,127 @@
.song-list-element {
display: flex;
width: 100%;
>.song-cover-wrapper {
height: 10rem;
width: 10rem;
position: relative;
margin-right: 20px;
.song-cover,
.fa-solid {
height: 10rem;
width: 10rem;
font-size: 7rem;
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;
}
}
}
>.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);
}
}
}
+44
View File
@@ -0,0 +1,44 @@
.search-results-wrapper {
width: 70vw;
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 {
height: 7rem;
width: 7rem;
margin-right: 20px;
}
>.fa-solid {
margin-left: auto;
font-size: 2rem;
}
}
}
@@ -14,6 +14,12 @@
} }
>.movable { >.movable {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
margin-bottom: 5px;
&.moving { &.moving {
position: fixed; position: fixed;
} }
+7
View File
@@ -0,0 +1,7 @@
import type {
MusicKitObject
} from 'musickitjs-v3-types/MusicKit';
declare global {
var MusicKit: MusicKitObject;
}
+1 -1
View File
@@ -40,7 +40,7 @@ export interface Song {
/** /**
* The cover image as a URL * The cover image as a URL
*/ */
'cover': string; 'artwork': string;
/** /**
* Song duration * Song duration
+11 -3
View File
@@ -1,5 +1,7 @@
import { import {
addSongList, addSongList,
clearQueue,
removeSong,
shuffleList shuffleList
} from './playlists/add'; } from './playlists/add';
import { import {
@@ -26,12 +28,12 @@ import {
} from './playlists'; } from './playlists';
const next = () => { const next = () => {
playIndex( queueIdx.value + 1 ); playIndex( ( queueIdx.value + 1 ) % queue.value.length );
}; };
const prev = () => { const prev = () => {
// TODO: Back to beginning if more than some seconds have passed? // 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 = () => { const play = () => {
@@ -116,8 +118,12 @@ const getSources = (): string[] => {
return Object.keys( sources ); 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 ) => { const addSongFromSource = async ( source: string ) => {
addSongList( await sources[source]!.addSongsFromThisSource() ); sources[source]!.addSongsFromThisSource( addSongList );
}; };
export default { export default {
@@ -133,6 +139,8 @@ export default {
addSongFromSource, addSongFromSource,
next, next,
prev, prev,
clearQueue,
removeSong,
queue, queue,
queueIdx, queueIdx,
duration, duration,
-1
View File
@@ -35,7 +35,6 @@ export const removeSong = ( idx: number ) => {
const removed = queue.value.splice( idx, 1 )[0]; const removed = queue.value.splice( idx, 1 )[0];
// TODO: Check that this works
for ( let i = 0; i < rawQueue.value.length; i++ ) { for ( let i = 0; i < rawQueue.value.length; i++ ) {
if ( rawQueue.value[ i ] === removed ) { if ( rawQueue.value[ i ] === removed ) {
rawQueue.value.splice( i, 1 ); rawQueue.value.splice( i, 1 );
+6 -1
View File
@@ -90,9 +90,14 @@ export interface PlayerSourcePlugin {
*/ */
'getDuration': () => number; 'getDuration': () => number;
/**
* Check if source is available for playback
*/
'available': () => boolean;
/** /**
* Called when user adds another song to the playlist via this source. * 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 * You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc
*/ */
'addSongsFromThisSource': () => Promise<Song[]>; 'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
} }
+1
View File
@@ -12,6 +12,7 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
}; };
return { return {
'available': () => true,
'id': 'local', 'id': 'local',
'name': 'Local Disk', 'name': 'Local Disk',
'play': player.play, 'play': player.play,
+8 -6
View File
@@ -1,8 +1,10 @@
import 'musickitjs-v3-types';
import type { import type {
PlayerSourcePlugin, PlayerSourcePlugin,
PlayerSourcePluginInitializer PlayerSourcePluginInitializer
} from '../interface'; } from '../interface';
import type {
Song
} from '@/ts/dtype/playlist';
import { import {
addFromAppleMusic addFromAppleMusic
} from './search'; } from './search';
@@ -13,7 +15,7 @@ import {
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => { export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
return new Promise( ( resolve, reject ) => { return new Promise( ( resolve, reject ) => {
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => { const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/', { const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', {
'credentials': 'include' 'credentials': 'include'
} ); } );
@@ -23,8 +25,8 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
'developerToken': token, 'developerToken': token,
'app': { 'app': {
'name': 'MusicPlayer', 'name': 'MusicPlayer',
'build': '4', 'build': '4'
'icon': 'https://music.janishutz.com/logo.jpg' // 'icon': 'https://music.janishutz.com/logo.jpg'
}, },
'storefrontId': storefront.toUpperCase() 'storefrontId': storefront.toUpperCase()
} ); } );
@@ -41,6 +43,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
}; };
return { return {
'available': controls.getLoggedIn,
'play': controls.play, 'play': controls.play,
'pause': controls.pause, 'pause': controls.pause,
'stop': controls.stop, 'stop': controls.stop,
@@ -51,8 +54,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
'name': 'Apple Music', 'name': 'Apple Music',
'seekTo': controls.seekTo, 'seekTo': controls.seekTo,
'login': login, 'login': login,
// TODO: Implement 'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
'addSongsFromThisSource': async () => await addFromAppleMusic(),
'loading': { 'loading': {
'requiresLocalFiles': false 'requiresLocalFiles': false
} }
@@ -34,6 +34,10 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
return musickitInstance.currentPlaybackDuration; return musickitInstance.currentPlaybackDuration;
}; };
const getLoggedIn = (): boolean => {
return musickitInstance.isAuthorized;
};
return { return {
play, play,
pause, pause,
@@ -41,6 +45,7 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
playSong, playSong,
stop, stop,
getPlaybackPos, getPlaybackPos,
getDuration getDuration,
getLoggedIn
}; };
}; };
@@ -1,10 +0,0 @@
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
export const searchAlbums = async ( instance: MusicKitInstance, term: string ) => {
const addSelected = ( idx: number ) => {
// TODO: Load album's songs and return them
Promise.resolve();
};
};
+26
View File
@@ -0,0 +1,26 @@
import type {
Artwork
} from 'musickitjs-v3-types/enums';
export interface AppleMusicApiSearchResult {
'results': {
'songs': {
'data': AppleMusicSongData[],
'href': string;
}
};
}
export interface AppleMusicSongData {
'id': string,
'type': string;
'href': string;
'attributes': {
'albumName': string;
'artistName': string;
'artwork': Artwork,
'name': string;
'genreNames': string[];
'durationInMillis': number;
}
}
@@ -4,7 +4,32 @@ import type {
import type { import type {
Song Song
} from '@/ts/dtype/playlist'; } from '@/ts/dtype/playlist';
import {
openImportTypePicker
} from '@/composables/importTypePicker';
import {
searchPlaylists
} from './playlists';
import {
searchSongs
} from './songs';
export const addFromAppleMusic = async ( instance: MusicKitInstance ): Promise<Song[]> => { export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
return []; const songs = await searchSongs( instance, cb );
const playlists = await searchPlaylists( instance, cb );
openImportTypePicker( [
{
'name': 'Songs',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search
},
{
'name': 'Playlists',
'type': 'cloud',
'addSelected': playlists.addSelected,
'search': playlists.search
}
] );
}; };
@@ -1,10 +1,24 @@
import type { import type {
MusicKitInstance MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance'; } from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const searchPlaylists = async ( instance: MusicKitInstance, userPlaylists: boolean = true ) => { export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
const addSelected = ( idx: number ) => { // TODO: Get all playlists
// TODO: Load the playlist content and return songs
Promise.resolve(); const search = async ( term: string ): Promise<Song[]> => {
return [];
};
const addSelected = async ( idx: number ) => {
// TODO: Implement
cb( [] );
};
return {
search,
addSelected
}; };
}; };
@@ -1,20 +1,46 @@
import type {
AppleMusicApiSearchResult
} from './dtype';
import type { import type {
MusicKitInstance MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance'; } from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const seachSongs = async ( instance: MusicKitInstance ) => { export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
const search = async ( term: string ) => { let songs: Song[] = [];
const search = async ( term: string ): Promise<Song[]> => {
const params = { const params = {
'term': term, 'term': term,
'types': [ 'songs' ], 'types': [ 'songs' ]
'l': 'en-us' };
const results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
songs = [];
for ( const result of results ) {
songs.push( {
'duration': Math.round( result.attributes.durationInMillis / 1000 ),
'name': result.attributes.name,
'additional-info': '',
'artist': result.attributes.artistName,
'artwork': window.MusicKit.formatArtworkURL( result.attributes.artwork, 1000, 1000 ),
'identifier': result.id,
'source': 'applemusic'
} );
}
return songs;
}; };
await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ); const addSelected = async ( idx: number ) => {
cb( [ songs[idx]! ] );
}; };
const addSelected = ( idx: number ) => { return {
// TODO: Need to only return the selected song search,
Promise.resolve(); addSelected
}; };
}; };
+22
View File
@@ -11,6 +11,12 @@ import type {
import type { import type {
RepeatMode RepeatMode
} from '../dtype/player'; } from '../dtype/player';
import {
useLocalPlayer
} from './plugins/local';
import {
useMusicKit
} from './plugins/musickit';
export const sources: { export const sources: {
[key: string]: PlayerSourcePlugin [key: string]: PlayerSourcePlugin
@@ -29,3 +35,19 @@ export const isPlaying = ref( false );
export const shuffle = ref( false ); export const shuffle = ref( false );
export const repeat: Ref<RepeatMode> = ref( 'off' ); export const repeat: Ref<RepeatMode> = ref( 'off' );
const initSources = async () => {
try {
sources['applemusic'] = await useMusicKit();
} catch {
console.warn( 'MusicKitJS intialization failed' );
}
try {
sources['local'] = await useLocalPlayer();
} catch {
console.warn( 'Local Player intialization failed' );
}
};
initSources();
+25 -2
View File
@@ -1,10 +1,33 @@
<script setup lang="ts"> <script setup lang="ts">
import PlayerControls from '@/components/PlayerControls.vue';
import QueueViewer from '@/components/QueueViewer.vue'; import QueueViewer from '@/components/QueueViewer.vue';
</script> </script>
<template> <template>
<div> <div class="main-app">
<h1>Player</h1> <div class="player-controls">
<PlayerControls />
</div>
<div class="song-queue">
<QueueViewer /> <QueueViewer />
</div> </div>
</div>
</template> </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>
+1
View File
@@ -13,6 +13,7 @@
- [ ] About page - [ ] About page
- [ ] Apple-Music signin - [ ] Apple-Music signin
- [ ] Loading existing playlists with local music and association of that - [ ] Loading existing playlists with local music and association of that
- [ ] Remote screens can use polling (once a minute or so) or SSE for updating (/fancy uses SSE by default, /share uses polling by default). Should reduce server load
# Backend # Backend
- [ ] Implement all endpoints - [ ] Implement all endpoints
+6 -4
View File
@@ -13,13 +13,15 @@
// Bundler mode provides a smoother developer experience. // Bundler mode provides a smoother developer experience.
"module": "preserve", "module": "preserve",
"moduleResolution": "bundler", "moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages. // Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"], "types": [
"node"
],
"typeRoots": [
"./src/ts/dtype/global.d.ts"
],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only. // Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true, "noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking. // `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory. // Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"