mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
feat: loading local songs, basic association
association used to load existing playlists with local files
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^7.3.1",
|
||||
"@globalhive/vuejs-tour": "^3.0.1",
|
||||
"@kyvg/vue3-notification": "^3.4.2",
|
||||
"music-metadata": "^11.15.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"pinia": "^4.0.2",
|
||||
"vue": "^3.5.40",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import DiskLoader from '@/composables/DiskLoader.vue';
|
||||
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
|
||||
import PopupElement from '@/components/PopupElement.vue';
|
||||
import player from '@/ts/player';
|
||||
@@ -20,6 +21,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<ImportTypePicker />
|
||||
<DiskLoader />
|
||||
<PopupElement v-model="showPopup" show-close>
|
||||
<div class="title">
|
||||
<h1>Add Song(s)</h1>
|
||||
@@ -60,9 +62,9 @@
|
||||
overflow-x: hidden;
|
||||
|
||||
>div {
|
||||
width: 32%;
|
||||
width: 45%;
|
||||
margin: 0.5%;
|
||||
height: 50%;
|
||||
height: 60%;
|
||||
background-color: var(--accent-background);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
@@ -84,6 +86,7 @@
|
||||
>.not-auth-notice {
|
||||
font-size: 0.6rem;
|
||||
margin: 0;
|
||||
width: 70%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
associationOpts,
|
||||
associationResults,
|
||||
isAnalyzing,
|
||||
isShowingAssociationManager,
|
||||
needsFiles,
|
||||
saveAssociations
|
||||
} from './associationManager';
|
||||
import {
|
||||
onMounted,
|
||||
useTemplateRef
|
||||
} from 'vue';
|
||||
import PopupElement from '@/components/PopupElement.vue';
|
||||
import player from '@/ts/player';
|
||||
import {
|
||||
queue
|
||||
} from '@/ts/player/state';
|
||||
|
||||
const fileinput = useTemplateRef( 'fileinput' );
|
||||
|
||||
onMounted( () => {
|
||||
fileinput.value?.addEventListener( 'change', async () => {
|
||||
if ( fileinput.value && fileinput.value.files ) {
|
||||
associationResults.value.concat( await associationOpts.value?.get( fileinput.value.files ) ?? [] );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
|
||||
const deleteSong = ( idx: number ) => {
|
||||
const id = associationResults.value[ idx ]!.song.identifier;
|
||||
|
||||
for ( let i = 0; i < queue.value.length; i++ ) {
|
||||
const song = queue.value[ i ]!;
|
||||
|
||||
if ( song.identifier === id ) {
|
||||
player.removeSong( i );
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
associationResults.value.splice( idx, 1 );
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PopupElement v-model="isShowingAssociationManager">
|
||||
<h2>Load from disk</h2>
|
||||
<div v-if="needsFiles">
|
||||
<p>Some songs in this playlist require local files.</p>
|
||||
<!-- TODO: probably need to list songs here somehow -->
|
||||
<input
|
||||
ref="fileinput"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="associationOpts?.mime ?? '*'"
|
||||
>
|
||||
</div>
|
||||
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length === 0">
|
||||
All files have been associated correctly
|
||||
</div>
|
||||
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length > 0">
|
||||
<button @click="saveAssociations">
|
||||
Save
|
||||
</button>
|
||||
<div v-for="(result, index) in associationResults" :key="index">
|
||||
<p>
|
||||
{{ result.song.name }} by {{ result.song.artist }}
|
||||
</p>
|
||||
<select v-if="result.match === 'multiple'">
|
||||
<option v-for="(file, idx) in result.possibleFiles" :key="idx" :value="idx">
|
||||
{{ file.name }}
|
||||
</option>
|
||||
</select>
|
||||
<div v-else-if="result.match === 'none'">
|
||||
<p>
|
||||
No match
|
||||
</p>
|
||||
<i class="fa-solid fa-trash" @click="() => deleteSong( index )"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
An error occurred. Please try again
|
||||
<!-- FIXME: Button to restore -->
|
||||
</div>
|
||||
</PopupElement>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,14 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import PopupElement from '@/components/PopupElement.vue';
|
||||
import {
|
||||
diskLoaderOpts,
|
||||
isShowingDiskLoader
|
||||
} from './diskLoader';
|
||||
import {
|
||||
onMounted,
|
||||
ref,
|
||||
useTemplateRef
|
||||
} from 'vue';
|
||||
import PopupElement from '@/components/PopupElement.vue';
|
||||
|
||||
const fileinput = useTemplateRef( 'fileinput' );
|
||||
const progress = ref( -1 );
|
||||
|
||||
onMounted( () => {
|
||||
fileinput.value?.addEventListener( 'change', async () => {
|
||||
if ( fileinput.value && fileinput.value.files ) {
|
||||
progress.value = 0;
|
||||
await diskLoaderOpts.value?.process( fileinput.value.files, ( curr: number ) => {
|
||||
progress.value = curr;
|
||||
} );
|
||||
isShowingDiskLoader.value = false;
|
||||
setTimeout( () => {
|
||||
progress.value = -1;
|
||||
}, 1000 );
|
||||
}
|
||||
} );
|
||||
} );
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PopupElement v-model="isShowingDiskLoader" show-close>
|
||||
<h3>Load from disk</h3>
|
||||
<h2>Load from disk</h2>
|
||||
<input
|
||||
ref="fileinput"
|
||||
type="file"
|
||||
multiple
|
||||
:accept="diskLoaderOpts?.mime ?? '*'"
|
||||
>
|
||||
<div v-if="progress >= 0">
|
||||
<p style="margin-bottom: 0;">
|
||||
Analyzing, please wait
|
||||
</p>
|
||||
<progress :value="progress" max="1"></progress>
|
||||
</div>
|
||||
</PopupElement>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type Ref,
|
||||
ref
|
||||
} from 'vue';
|
||||
import {
|
||||
isShowingSearchView,
|
||||
query,
|
||||
results,
|
||||
searchOpts
|
||||
} from './searchManager';
|
||||
import PopupElement from '@/components/PopupElement.vue';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
const results: Ref<Song[]> = ref( [] );
|
||||
const query = ref( '' );
|
||||
const isSearching = ref( false );
|
||||
const addedIndex = ref( -1 );
|
||||
|
||||
@@ -29,7 +25,7 @@
|
||||
} catch { /* empty */ }
|
||||
|
||||
doSearch();
|
||||
} else if ( query.value.length > 2 ) {
|
||||
} else if ( query.value.length > ( searchOpts.value?.minChars ?? 0 ) - 1 ) {
|
||||
try {
|
||||
clearTimeout( timeout );
|
||||
} catch { /* empty */ }
|
||||
@@ -43,7 +39,7 @@
|
||||
|
||||
isSearching.value = true;
|
||||
searchedForQuery = query.value;
|
||||
results.value = await searchOpts.value?.search( query.value ) ?? [];
|
||||
results.value = await searchOpts.value?.search( query.value, 0 ) ?? [];
|
||||
isSearching.value = false;
|
||||
};
|
||||
|
||||
@@ -73,12 +69,14 @@
|
||||
<div v-if="!isSearching && results.length > 0" class="search-results-wrapper">
|
||||
<div v-for="(result, index) in results" :key="index" @click="() => add(index)">
|
||||
<img v-if="result.artwork" :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
|
||||
<i v-else class="fa-solid fa-music img-placeholder"></i>
|
||||
<div>
|
||||
<h4>{{ result.name }}</h4>
|
||||
<p>{{ result.artist }}</p>
|
||||
</div>
|
||||
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
|
||||
</div>
|
||||
<!-- TODO: load more if close to bottom of scroll view -->
|
||||
</div>
|
||||
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
|
||||
No results found
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
type Ref,
|
||||
ref
|
||||
} from 'vue';
|
||||
import type {
|
||||
AssociationResult
|
||||
} from '@/ts/player/plugins/interface';
|
||||
import {
|
||||
queue
|
||||
} from '@/ts/player/state';
|
||||
import {
|
||||
updateIdentifiers
|
||||
} from '@/ts/player/plugins/local/association';
|
||||
|
||||
export const isShowingAssociationManager = ref( false );
|
||||
|
||||
export const needsFiles = ref( true );
|
||||
|
||||
export const isAnalyzing = ref( false );
|
||||
|
||||
export const associationResults: Ref<AssociationResult[]> = ref( [] );
|
||||
|
||||
export const associationOpts: Ref<{
|
||||
'get': ( files: FileList ) => Promise<AssociationResult[]>,
|
||||
'mime': string;
|
||||
} | null> = ref( null );
|
||||
|
||||
export const openAssociationManager = ( cb: ( files: FileList ) => Promise<AssociationResult[]>, mime: string ) => {
|
||||
isShowingAssociationManager.value = true;
|
||||
associationResults.value = [];
|
||||
needsFiles.value = true;
|
||||
isAnalyzing.value = false;
|
||||
associationOpts.value = {
|
||||
'get': cb,
|
||||
'mime': mime
|
||||
};
|
||||
};
|
||||
|
||||
export const saveAssociations = () => {
|
||||
for ( const result of associationResults.value ) {
|
||||
for ( const song of queue.value ) {
|
||||
if ( result.song.identifier === song.identifier ) {
|
||||
updateIdentifiers( song, result.possibleFiles[ result.selectedIdx ?? 0 ]! );
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -7,18 +7,18 @@ import type {
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export interface CloudImport {
|
||||
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
|
||||
'name': string;
|
||||
'type': 'cloud';
|
||||
'search': ( term: string ) => Promise<Song[]>;
|
||||
'search': ( term: string, offset: number ) => Promise<Song[]>;
|
||||
'addSelected': ( index: number ) => void;
|
||||
'minChars'?: number;
|
||||
}
|
||||
|
||||
// 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[]>;
|
||||
'mime': string;
|
||||
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
|
||||
}
|
||||
|
||||
export type ImportTypes = CloudImport | FileImport;
|
||||
|
||||
@@ -5,12 +5,21 @@ import {
|
||||
import type {
|
||||
CloudImport
|
||||
} from './importTypePicker';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const isShowingSearchView = ref( false );
|
||||
|
||||
export const searchOpts: Ref<CloudImport | null> = ref( null );
|
||||
|
||||
export const results: Ref<Song[]> = ref( [] );
|
||||
|
||||
export const query = ref( '' );
|
||||
|
||||
export const openSearchInterface = ( options: CloudImport ) => {
|
||||
searchOpts.value = options;
|
||||
isShowingSearchView.value = true;
|
||||
results.value = [];
|
||||
query.value = '';
|
||||
};
|
||||
|
||||
@@ -30,15 +30,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
>img {
|
||||
height: 7rem;
|
||||
width: 7rem;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
>.fa-solid {
|
||||
margin-left: auto;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
>img,
|
||||
.img-placeholder {
|
||||
height: 7rem;
|
||||
width: 7rem;
|
||||
margin-right: 20px;
|
||||
font-size: 6rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+2
-1
@@ -18,7 +18,8 @@ export interface Song {
|
||||
'identifier': string;
|
||||
|
||||
/**
|
||||
* Any additional identifiers (such as filename, etc) to associate
|
||||
* Any additional identifiers (such as filename, etc) to associate.
|
||||
* Even if this is not used, set this value if local files are needed
|
||||
*/
|
||||
'additional-identifier'?: string;
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ import type {
|
||||
import type {
|
||||
Song
|
||||
} from '../dtype/playlist';
|
||||
import {
|
||||
load
|
||||
} from './playlists/loader';
|
||||
import {
|
||||
playIndex
|
||||
} from './playlists';
|
||||
@@ -137,7 +140,6 @@ 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;
|
||||
@@ -146,6 +148,7 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
export default {
|
||||
play,
|
||||
pause,
|
||||
@@ -167,5 +170,6 @@ export default {
|
||||
playbackPercentage,
|
||||
repeat,
|
||||
shuffle,
|
||||
isPlaying
|
||||
isPlaying,
|
||||
'loadPlaylist': load
|
||||
};
|
||||
|
||||
@@ -15,16 +15,9 @@ import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const addSongList = ( songs: Song[], first: boolean = false ) => {
|
||||
if ( first ) {
|
||||
// FIXME: Needs to insert at current index in queue, but where for rawQueue?
|
||||
// Probably at end if shuffled, else insert into queue, then copy
|
||||
rawQueue.value = songs.concat( rawQueue.value );
|
||||
queue.value = songs.concat( queue.value );
|
||||
} else {
|
||||
rawQueue.value = rawQueue.value.concat( songs );
|
||||
queue.value = queue.value.concat( songs );
|
||||
}
|
||||
export const addSongList = ( songs: Song[] ) => {
|
||||
rawQueue.value = rawQueue.value.concat( songs );
|
||||
queue.value = queue.value.concat( songs );
|
||||
};
|
||||
|
||||
export const clearQueue = () => {
|
||||
|
||||
@@ -1,2 +1,47 @@
|
||||
// TODO: If local files contained, prompt user to select files, then associate based on file names
|
||||
export const load = () => {};
|
||||
import {
|
||||
queue,
|
||||
rawQueue,
|
||||
sources
|
||||
} from '../state';
|
||||
import type {
|
||||
AssociationResult
|
||||
} from '../plugins/interface';
|
||||
import type {
|
||||
Playlist
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
openAssociationManager
|
||||
} from '@/composables/associationManager';
|
||||
|
||||
export const load = ( playlist: Playlist ) => {
|
||||
queue.value = playlist;
|
||||
rawQueue.value = playlist;
|
||||
|
||||
let needToLoadLocalSongs = false;
|
||||
|
||||
for ( const song of playlist ) {
|
||||
if ( song['additional-identifier'] ) {
|
||||
needToLoadLocalSongs = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const fileLoader = async ( files: FileList ) => {
|
||||
const associationResults: AssociationResult[] = [];
|
||||
|
||||
for ( const song of playlist ) {
|
||||
const source = sources[song.source]!;
|
||||
|
||||
if ( source.loading.requiresLocalFiles === true ) {
|
||||
associationResults.push( await source.loading.association( files as FileList, song ) );
|
||||
}
|
||||
}
|
||||
|
||||
return associationResults.filter( val => val.match !== 'exact' );
|
||||
};
|
||||
|
||||
if ( needToLoadLocalSongs ) {
|
||||
// FIXME: Combine mime types
|
||||
openAssociationManager( fileLoader, '' );
|
||||
}
|
||||
};
|
||||
|
||||
+23
-8
@@ -5,9 +5,15 @@ import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
// TODO: Add search interface elements to plugin's args (such as a search interface)
|
||||
export type PlayerSourcePluginInitializer = () => Promise<PlayerSourcePlugin>;
|
||||
|
||||
export interface AssociationResult {
|
||||
'song': Song,
|
||||
'match': 'exact' | 'none' | 'multiple';
|
||||
'possibleFiles': File[],
|
||||
'selectedIdx'?: number;
|
||||
}
|
||||
|
||||
export interface PlayerSourcePlugin {
|
||||
/**
|
||||
* The name of the source that is displayed in the UI
|
||||
@@ -33,17 +39,26 @@ export interface PlayerSourcePlugin {
|
||||
*/
|
||||
'requiresLocalFiles': true;
|
||||
|
||||
/**
|
||||
* The allowed MIME types for loading associated files
|
||||
*/
|
||||
'MIMETypes': string;
|
||||
|
||||
/**
|
||||
* Function used to associate the file to the songs of the playlist
|
||||
* @param files - The files that were picked by the user
|
||||
* @returns A promise resolving to an array of song identifiers (corresponding to Song.identifier, for association)
|
||||
* @param song - The song to update
|
||||
* @returns A promise resolving to the (possibly) updated song and metadata
|
||||
*/
|
||||
'association': ( files: File[] ) => Promise<string[]>;
|
||||
'association': ( files: FileList, song: Song ) => Promise<AssociationResult>;
|
||||
|
||||
/**
|
||||
* Function used to update the additional identifier of the song if the file has changed
|
||||
* @param song - The song that needs updating
|
||||
* @param file - The file that it should be updated to
|
||||
* @returns The updated song
|
||||
*/
|
||||
'updateIdentifiers': ( song: Song, file: File ) => Promise<Song>;
|
||||
|
||||
/**
|
||||
* The mime types that this source's files may have
|
||||
*/
|
||||
'mime': string;
|
||||
} | {
|
||||
/**
|
||||
* Set to true if the user needs to load files
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type {
|
||||
AssociationResult
|
||||
} from '../interface';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const createFileAssociationString = ( file: File ) => {
|
||||
return file.name + '__' + file.size;
|
||||
};
|
||||
|
||||
export const associate = async ( files: FileList, song: Song ): Promise<AssociationResult> => {
|
||||
const candidates: File[] = [];
|
||||
|
||||
if ( !song['additional-identifier'] ) {
|
||||
return {
|
||||
'match': 'none',
|
||||
'song': song,
|
||||
'possibleFiles': []
|
||||
};
|
||||
}
|
||||
|
||||
for ( const file of files ) {
|
||||
if ( createFileAssociationString( file ) === song['additional-identifier'] ) {
|
||||
candidates.push( file );
|
||||
}
|
||||
}
|
||||
|
||||
if ( candidates.length === 0 ) {
|
||||
return {
|
||||
'match': 'none',
|
||||
'song': song,
|
||||
'possibleFiles': []
|
||||
};
|
||||
} else if ( candidates.length === 1 ) {
|
||||
song.identifier = URL.createObjectURL( candidates[ 0 ]! );
|
||||
|
||||
return {
|
||||
'match': 'exact',
|
||||
'song': song,
|
||||
'possibleFiles': []
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
'match': 'multiple',
|
||||
'song': song,
|
||||
'possibleFiles': candidates
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const updateIdentifiers = async ( song: Song, file: File ): Promise<Song> => {
|
||||
song.identifier = URL.createObjectURL( file );
|
||||
song['additional-identifier'] = createFileAssociationString( file );
|
||||
|
||||
return song;
|
||||
};
|
||||
@@ -2,12 +2,19 @@ import type {
|
||||
PlayerSourcePlugin,
|
||||
PlayerSourcePluginInitializer
|
||||
} from '../interface';
|
||||
import {
|
||||
associate,
|
||||
updateIdentifiers
|
||||
} from './association';
|
||||
import {
|
||||
load
|
||||
} from './loader';
|
||||
import {
|
||||
ref
|
||||
} from 'vue';
|
||||
|
||||
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
|
||||
const player = document.createElement( 'audio' );
|
||||
const player = new Audio();
|
||||
|
||||
const playSong = async ( id: string ) => {
|
||||
player.src = id;
|
||||
@@ -18,20 +25,19 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
|
||||
'authorized': ref( true ),
|
||||
'id': 'local',
|
||||
'name': 'Local Disk',
|
||||
'play': player.play,
|
||||
'play': () => player.play(),
|
||||
'getPlaybackPos': () => player.currentTime / player.duration,
|
||||
'getDuration': () => player.duration,
|
||||
playSong,
|
||||
'seekTo': pos => player.currentTime = pos,
|
||||
'pause': player.pause,
|
||||
'seekTo': pos => player.currentTime = pos * player.duration,
|
||||
'pause': () => player.pause(),
|
||||
'stop': () => player.src = '',
|
||||
// TODO: Implement
|
||||
'addSongsFromThisSource': async () => [],
|
||||
'addSongsFromThisSource': load,
|
||||
'loading': {
|
||||
'requiresLocalFiles': true,
|
||||
'MIMETypes': 'audio/mp3,audio/wav',
|
||||
// TODO: Implement
|
||||
'association': async () => []
|
||||
'association': associate,
|
||||
'updateIdentifiers': updateIdentifiers,
|
||||
'mime': 'audio/aac,audio/mpeg,audio/wav,audio/mp4,audio/ogg'
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
createFileAssociationString
|
||||
} from './association';
|
||||
import {
|
||||
initAppleMusicApiSearch
|
||||
} from '@/ts/util/search';
|
||||
import {
|
||||
openDiskLoaderInterface
|
||||
} from '@/composables/diskLoader';
|
||||
import {
|
||||
parseBlob
|
||||
} from 'music-metadata';
|
||||
|
||||
export const load = ( cb: ( songs: Song[] ) => void ) => {
|
||||
const search = initAppleMusicApiSearch();
|
||||
|
||||
const process = async ( files: FileList, status: ( progress: number ) => void ) => {
|
||||
const songs: Song[] = [];
|
||||
|
||||
for ( let i = 0; i < files.length; i++ ) {
|
||||
const file = files[i]!;
|
||||
|
||||
try {
|
||||
songs.push( await generateSongObject( file ) );
|
||||
} catch ( error ) {
|
||||
console.error( '[LOADER] Failed generating song info for file', file, 'with error', error );
|
||||
}
|
||||
|
||||
status( ( i + 1 ) / files.length );
|
||||
}
|
||||
|
||||
cb( songs );
|
||||
};
|
||||
|
||||
openDiskLoaderInterface( {
|
||||
'name': 'From Disk',
|
||||
'process': process,
|
||||
'type': 'file',
|
||||
'mime': 'audio/aac,audio/mpeg,audio/wav,audio/mp4,audio/ogg'
|
||||
} );
|
||||
|
||||
const generateSongObject = async ( file: File ): Promise<Song> => {
|
||||
console.log( 'Analyzing file', file );
|
||||
const url = URL.createObjectURL( file );
|
||||
const blob = await ( await fetch( url ) ).blob();
|
||||
// Load audio file and parse its metadata
|
||||
const data = await parseBlob( blob );
|
||||
const searchTerm = data.common.title
|
||||
? data.common.title + ( data.common.artist ? ' ' + data.common.artist : '' )
|
||||
: file.name.split( '.' )[ 0 ]!.replace( '_', ' ' );
|
||||
const result = ( await search( searchTerm, 1 ) )[ 0 ];
|
||||
|
||||
if ( result ) {
|
||||
result.source = 'local';
|
||||
result['additional-identifier'] = createFileAssociationString( file );
|
||||
result.identifier = url;
|
||||
|
||||
return result;
|
||||
} else {
|
||||
console.warn( '[LOADER] No results found for', searchTerm );
|
||||
|
||||
return {
|
||||
'additional-identifier': file.name,
|
||||
'source': 'local',
|
||||
'identifier': url,
|
||||
'artist': data.common.artist ?? 'Unknown Artist',
|
||||
'name': data.common.title ?? 'Unknown title',
|
||||
'artwork': '',
|
||||
'additional-info': '',
|
||||
'duration': -1
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
@@ -54,7 +54,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
|
||||
'name': 'Apple Music',
|
||||
'seekTo': controls.seekTo,
|
||||
'login': login,
|
||||
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
|
||||
'addSongsFromThisSource': addFromAppleMusic,
|
||||
'loading': {
|
||||
'requiresLocalFiles': false
|
||||
}
|
||||
|
||||
@@ -32,5 +32,12 @@ export interface AppleMusicPlaylistData {
|
||||
'attributes': {
|
||||
'artwork': Artwork;
|
||||
'name': string;
|
||||
},
|
||||
'relationships'?: {
|
||||
'tracks': {
|
||||
'href': string,
|
||||
'next': string,
|
||||
'data': AppleMusicSongData
|
||||
}[]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
@@ -14,16 +11,17 @@ 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 );
|
||||
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void ): Promise<void> => {
|
||||
const songs = await searchSongs( cb );
|
||||
const playlists = await searchPlaylists( cb );
|
||||
|
||||
openImportTypePicker( [
|
||||
{
|
||||
'name': 'Songs',
|
||||
'type': 'cloud',
|
||||
'addSelected': songs.addSelected,
|
||||
'search': songs.search
|
||||
'search': songs.search,
|
||||
'minChars': 3
|
||||
},
|
||||
{
|
||||
'name': 'Playlists',
|
||||
|
||||
@@ -1,21 +1,22 @@
|
||||
import type {
|
||||
AppleMusicPlaylistData
|
||||
AppleMusicPlaylistData,
|
||||
AppleMusicSongData
|
||||
} from './dtype';
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
|
||||
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||
export const searchPlaylists = async ( cb: ( songs: Song[] ) => void ) => {
|
||||
let playlists: Song[] = [];
|
||||
let filtered: Song[] = [];
|
||||
|
||||
const search = async ( term: string ): Promise<Song[]> => {
|
||||
const instance = window.MusicKit.getInstance();
|
||||
|
||||
const search = async ( term: string, offset: number = 0 ): Promise<Song[]> => {
|
||||
if ( playlists.length === 0 ) {
|
||||
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
|
||||
'limit': 100
|
||||
'limit': 100,
|
||||
'offset': offset
|
||||
} ) ).data as {
|
||||
'data': AppleMusicPlaylistData[]
|
||||
} ).data.map( val => {
|
||||
@@ -31,16 +32,36 @@ export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs:
|
||||
} );
|
||||
}
|
||||
|
||||
term = term.toLocaleLowerCase();
|
||||
filtered = playlists.filter( val => {
|
||||
return val.name.includes( term );
|
||||
return val.name.toLocaleLowerCase().includes( term );
|
||||
} );
|
||||
|
||||
return filtered;
|
||||
};
|
||||
|
||||
const addSelected = async ( idx: number ) => {
|
||||
// TODO: Implement
|
||||
cb( [] );
|
||||
try {
|
||||
const results = ( await instance.api.music( '/v1/me/library/playlists/' + filtered[idx]!.identifier + '/tracks' ) ).data as {
|
||||
'data': AppleMusicSongData[]
|
||||
};
|
||||
const songs: Song[] = results.data
|
||||
.map( val => {
|
||||
return {
|
||||
'identifier': val.id,
|
||||
'additional-info': '',
|
||||
'artist': val.attributes.artistName,
|
||||
'name': val.attributes.name,
|
||||
'artwork': window.MusicKit.formatArtworkURL( val.attributes.artwork, 1000, 1000 ),
|
||||
'duration': val.attributes.durationInMillis * 1000,
|
||||
'source': 'applemusic'
|
||||
};
|
||||
} );
|
||||
|
||||
cb( songs );
|
||||
} catch ( error ) {
|
||||
console.error( '[ADD PLAYLIST] Failed to add playlist due to error', error );
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,46 +1,17 @@
|
||||
import type {
|
||||
AppleMusicApiSearchResult,
|
||||
AppleMusicSongData
|
||||
} from './dtype';
|
||||
import type {
|
||||
MusicKitInstance
|
||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
initAppleMusicApiSearch
|
||||
} from '@/ts/util/search';
|
||||
|
||||
export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||
export const searchSongs = async ( cb: ( songs: Song[] ) => void ) => {
|
||||
let songs: Song[] = [];
|
||||
|
||||
const search = async ( term: string ): Promise<Song[]> => {
|
||||
const params = {
|
||||
'term': term,
|
||||
'types': [ 'songs' ]
|
||||
};
|
||||
const searchFunc = initAppleMusicApiSearch();
|
||||
|
||||
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 = [];
|
||||
|
||||
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'
|
||||
} );
|
||||
}
|
||||
const search = async ( term: string, offset: number = 0 ) => {
|
||||
songs = await searchFunc( term, 15, offset );
|
||||
|
||||
return songs;
|
||||
};
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
// Tracking of time and player status
|
||||
let interval = -1;
|
||||
|
||||
export const playbackPercentage = ref( 0 );
|
||||
export const playbackPercentage = ref( 1 );
|
||||
|
||||
export const duration = ref( 0 );
|
||||
export const duration = ref( -1 );
|
||||
|
||||
export const startTracking = () => {
|
||||
if ( interval === -1 ) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import type {
|
||||
AppleMusicApiSearchResult, AppleMusicSongData
|
||||
} from '../player/plugins/musickit/search/dtype';
|
||||
import type {
|
||||
Song
|
||||
} from '../dtype/playlist';
|
||||
|
||||
export const initAppleMusicApiSearch = () => {
|
||||
const instance = window.MusicKit.getInstance();
|
||||
|
||||
return async ( term: string, limit: number = 20, offset: number = 0 ): Promise<Song[]> => {
|
||||
const params = {
|
||||
'term': term,
|
||||
'types': [ 'songs' ],
|
||||
'offset': offset ?? 20,
|
||||
'limit': limit ?? 0
|
||||
};
|
||||
|
||||
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 [];
|
||||
}
|
||||
|
||||
const songs: Song[] = [];
|
||||
|
||||
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;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user