feat: add songs via Apple Music

This commit is contained in:
2026-09-01 12:51:39 +02:00
parent a2b93e9624
commit 3a0f8210dd
33 changed files with 513 additions and 98 deletions
+1 -2
View File
@@ -29,8 +29,7 @@ const run = () => {
__dirname,
'/config/apple-music-api.config.secret.json'
) ).toString() );
app.get( '/dev-token', ( request: express.Request, response: express.Response ) => {
app.get( '/dev-token', ( _request: express.Request, response: express.Response ) => {
// sign dev token
const now = new Date().getTime();
const tomorrow = now + ( 24 * 3600 * 1000 );
+7 -2
View File
@@ -1,13 +1,18 @@
<!DOCTYPE html>
<html lang="">
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<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>
<body>
<div id="app"></div>
<noscript>This application requires JavaScript to work!</noscript>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+2
View File
@@ -1,4 +1,5 @@
<script setup lang="ts">
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
import PopupElement from '@/components/PopupElement.vue';
import player from '@/ts/player';
import {
@@ -17,6 +18,7 @@
<template>
<div>
<ImportTypePicker />
<PopupElement v-model="showPopup" show-close>
<h1>Add Song</h1>
<div class="song-sources">
+1 -1
View File
@@ -79,5 +79,5 @@
</template>
<style lang="scss" scoped>
@import '@/scss/components/player.scss';
@use '@/scss/components/player.scss';
</style>
+1 -1
View File
@@ -50,5 +50,5 @@
</template>
<style lang="scss" scoped>
@import '@/scss/components/progressbar.scss';
@use '@/scss/components/progressbar.scss';
</style>
+21 -8
View File
@@ -10,7 +10,7 @@
} from 'vue';
const queue = player.queue;
const idx = player.queueIdx;
const queueIndex = player.queueIdx;
const isPlaying = player.isPlaying;
const showAddSong = ref( false );
@@ -26,7 +26,7 @@
<i class="fa-solid fa-plus"></i>
Add
</button>
<button>
<button @click="player.clearQueue">
<i class="fa-solid fa-xmark"></i>
Clear
</button>
@@ -43,21 +43,29 @@
<AddSong v-model="showAddSong" />
<div>
<SortableList v-slot="{ item: song, index }" v-model="queue">
<div class="song-list-element">
<div class="song-cover-wrapper">
<img
v-if="song.cover"
:src="song.cover"
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 v-if="isPlaying && index === idx" class="playing-symbols">
<div v-if="index === queueIndex" 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-play play-pause" @click="() => player.playIndex( index )"></i>
<i class="fa-solid fa-pause play-pause" @click="player.pause"></i>
<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>
</div>
<div>
<h3>{{ song.name }}</h3>
@@ -66,10 +74,15 @@
<p>{{ song['additional-info'] }}</p>
</div>
<div>
<i class="fa-solid fa-trash-can"></i>
<i v-if="index !== queueIndex" class="fa-solid fa-trash-can"></i>
<i class="fa-solid fa-pen-to-square"></i>
</div>
</div>
</SortableList>
</div>
</div>
</template>
<style lang="scss" scoped>
@use '@/scss/components/queue.scss';
</style>
+4 -3
View File
@@ -95,9 +95,10 @@
const before = array.value.slice( 0, movingIdx.value );
const after = array.value.slice( movingIdx.value + 1 );
const el = array.value[ movingIdx.value ]!;
const arr = before.concat( after );
array.value = before.concat( after );
array.value.splice( movingCurrentIdx.value, 0, el );
arr.splice( movingCurrentIdx.value, 0, el );
array.value = arr;
movingIdx.value = -1;
movingCurrentIdx.value = -1;
@@ -156,5 +157,5 @@
</template>
<style lang="scss" scoped>
@import '@/scss/components/sortablelist.scss'
@use '@/scss/components/sortablelist.scss'
</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>
+40
View File
@@ -0,0 +1,40 @@
<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">
<h3>What would you like to add?</h3>
<div class="import-type-list">
<div v-for="(source, index) in importers" :key="index" @click="openPicker( source )">
{{ source.name }}
</div>
</div>
</PopupElement>
</div>
</template>
+79
View File
@@ -0,0 +1,79 @@
<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 );
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 ) => {
searchOpts.value?.addSelected( idx );
};
</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">
<h4>{{ result.name }}</h4>
<p>{{ result.artist }}</p>
</div>
</div>
<div v-else class="search-results-wrapper">
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 {
Song
} from '@/ts/dtype/playlist';
@@ -5,13 +9,25 @@ import type {
export interface CloudImport {
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
'name': string;
'kind': 'list' | 'nested-list';
'type': 'cloud';
'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?)
export interface FileImport {
'name': string;
'type': 'file';
'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
+92
View File
@@ -0,0 +1,92 @@
.song-list-element {
display: flex;
width: 100%;
>.song-cover-wrapper {
height: 10rem;
width: 10rem;
position: relative;
.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%;
&.hover {
display: none;
}
}
&:hover {
>.play-overlay.hover {
display: block;
}
}
}
}
.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);
}
}
}
+12
View File
@@ -0,0 +1,12 @@
.search-results-wrapper {
width: 70vw;
height: 60vh;
overflow-x: hidden;
overflow-y: scroll;
>div {
>img {
width: 10%;
}
}
}
@@ -14,6 +14,11 @@
}
>.movable {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
&.moving {
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
*/
'cover': string;
'artwork': string;
/**
* Song duration
+3 -1
View File
@@ -1,5 +1,6 @@
import {
addSongList,
clearQueue,
shuffleList
} from './playlists/add';
import {
@@ -117,7 +118,7 @@ const getSources = (): string[] => {
};
const addSongFromSource = async ( source: string ) => {
addSongList( await sources[source]!.addSongsFromThisSource() );
sources[source]!.addSongsFromThisSource( addSongList );
};
export default {
@@ -133,6 +134,7 @@ export default {
addSongFromSource,
next,
prev,
clearQueue,
queue,
queueIdx,
duration,
+1 -1
View File
@@ -94,5 +94,5 @@ 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
*/
'addSongsFromThisSource': () => Promise<Song[]>;
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
}
+7 -6
View File
@@ -1,8 +1,10 @@
import 'musickitjs-v3-types';
import type {
PlayerSourcePlugin,
PlayerSourcePluginInitializer
} from '../interface';
import type {
Song
} from '@/ts/dtype/playlist';
import {
addFromAppleMusic
} from './search';
@@ -13,7 +15,7 @@ import {
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
return new Promise( ( resolve, reject ) => {
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'
} );
@@ -23,8 +25,8 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
'developerToken': token,
'app': {
'name': 'MusicPlayer',
'build': '4',
'icon': 'https://music.janishutz.com/logo.jpg'
'build': '4'
// 'icon': 'https://music.janishutz.com/logo.jpg'
},
'storefrontId': storefront.toUpperCase()
} );
@@ -51,8 +53,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
'name': 'Apple Music',
'seekTo': controls.seekTo,
'login': login,
// TODO: Implement
'addSongsFromThisSource': async () => await addFromAppleMusic(),
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
'loading': {
'requiresLocalFiles': false
}
@@ -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,28 @@ import type {
import type {
Song
} from '@/ts/dtype/playlist';
import {
openImportTypePicker
} from '@/composables/importTypePicker';
import {
searchSongs
} from './songs';
export const addFromAppleMusic = async ( instance: MusicKitInstance ): Promise<Song[]> => {
return [];
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
const songs = await searchSongs( instance, cb );
openImportTypePicker( [
{
'name': 'Songs',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search
},
{
'name': 'Playlists',
'type': 'cloud',
'addSelected': songs.addSelected,
'search': songs.search
}
] );
};
@@ -1,10 +1,17 @@
import type {
Song
} from '@/ts/dtype/playlist';
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
export const searchPlaylists = async ( instance: MusicKitInstance, userPlaylists: boolean = true ) => {
const addSelected = ( idx: number ) => {
export const searchPlaylists = async ( instance: MusicKitInstance ) => {
// TODO: Get all playlists
const search = async () => {};
const addSelected = async ( idx: number ): Promise<Song[]> => {
// TODO: Load the playlist content and return songs
Promise.resolve();
return [];
};
};
@@ -1,20 +1,46 @@
import type {
AppleMusicApiSearchResult
} from './dtype';
import type {
MusicKitInstance
} from 'musickitjs-v3-types/MusicKitInstance';
import type {
Song
} from '@/ts/dtype/playlist';
export const seachSongs = async ( instance: MusicKitInstance ) => {
const search = async ( term: string ) => {
export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
let songs: Song[] = [];
const search = async ( term: string ): Promise<Song[]> => {
const params = {
'term': term,
'types': [ 'songs' ],
'l': 'en-us'
'types': [ 'songs' ]
};
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 ) => {
// TODO: Need to only return the selected song
Promise.resolve();
return {
search,
addSelected
};
};
+22
View File
@@ -11,6 +11,12 @@ import type {
import type {
RepeatMode
} from '../dtype/player';
import {
useLocalPlayer
} from './plugins/local';
import {
useMusicKit
} from './plugins/musickit';
export const sources: {
[key: string]: PlayerSourcePlugin
@@ -29,3 +35,19 @@ export const isPlaying = ref( false );
export const shuffle = ref( false );
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();
+1
View File
@@ -13,6 +13,7 @@
- [ ] About page
- [ ] Apple-Music signin
- [ ] 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
- [ ] Implement all endpoints
+6 -4
View File
@@ -13,13 +13,15 @@
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// 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.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"