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",
|
"@fortawesome/free-regular-svg-icons": "^7.3.1",
|
||||||
"@globalhive/vuejs-tour": "^3.0.1",
|
"@globalhive/vuejs-tour": "^3.0.1",
|
||||||
"@kyvg/vue3-notification": "^3.4.2",
|
"@kyvg/vue3-notification": "^3.4.2",
|
||||||
|
"music-metadata": "^11.15.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
"pinia": "^4.0.2",
|
"pinia": "^4.0.2",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import DiskLoader from '@/composables/DiskLoader.vue';
|
||||||
import ImportTypePicker from '@/composables/ImportTypePicker.vue';
|
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';
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<ImportTypePicker />
|
<ImportTypePicker />
|
||||||
|
<DiskLoader />
|
||||||
<PopupElement v-model="showPopup" show-close>
|
<PopupElement v-model="showPopup" show-close>
|
||||||
<div class="title">
|
<div class="title">
|
||||||
<h1>Add Song(s)</h1>
|
<h1>Add Song(s)</h1>
|
||||||
@@ -60,9 +62,9 @@
|
|||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|
||||||
>div {
|
>div {
|
||||||
width: 32%;
|
width: 45%;
|
||||||
margin: 0.5%;
|
margin: 0.5%;
|
||||||
height: 50%;
|
height: 60%;
|
||||||
background-color: var(--accent-background);
|
background-color: var(--accent-background);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -84,6 +86,7 @@
|
|||||||
>.not-auth-notice {
|
>.not-auth-notice {
|
||||||
font-size: 0.6rem;
|
font-size: 0.6rem;
|
||||||
margin: 0;
|
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">
|
<script setup lang="ts">
|
||||||
import PopupElement from '@/components/PopupElement.vue';
|
|
||||||
import {
|
import {
|
||||||
|
diskLoaderOpts,
|
||||||
isShowingDiskLoader
|
isShowingDiskLoader
|
||||||
} from './diskLoader';
|
} 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PopupElement v-model="isShowingDiskLoader" show-close>
|
<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>
|
</PopupElement>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {
|
|
||||||
type Ref,
|
|
||||||
ref
|
|
||||||
} from 'vue';
|
|
||||||
import {
|
import {
|
||||||
isShowingSearchView,
|
isShowingSearchView,
|
||||||
|
query,
|
||||||
|
results,
|
||||||
searchOpts
|
searchOpts
|
||||||
} from './searchManager';
|
} from './searchManager';
|
||||||
import PopupElement from '@/components/PopupElement.vue';
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
import type {
|
import {
|
||||||
Song
|
ref
|
||||||
} from '@/ts/dtype/playlist';
|
} from 'vue';
|
||||||
|
|
||||||
const results: Ref<Song[]> = ref( [] );
|
|
||||||
const query = ref( '' );
|
|
||||||
const isSearching = ref( false );
|
const isSearching = ref( false );
|
||||||
const addedIndex = ref( -1 );
|
const addedIndex = ref( -1 );
|
||||||
|
|
||||||
@@ -29,7 +25,7 @@
|
|||||||
} catch { /* empty */ }
|
} catch { /* empty */ }
|
||||||
|
|
||||||
doSearch();
|
doSearch();
|
||||||
} else if ( query.value.length > 2 ) {
|
} else if ( query.value.length > ( searchOpts.value?.minChars ?? 0 ) - 1 ) {
|
||||||
try {
|
try {
|
||||||
clearTimeout( timeout );
|
clearTimeout( timeout );
|
||||||
} catch { /* empty */ }
|
} catch { /* empty */ }
|
||||||
@@ -43,7 +39,7 @@
|
|||||||
|
|
||||||
isSearching.value = true;
|
isSearching.value = true;
|
||||||
searchedForQuery = query.value;
|
searchedForQuery = query.value;
|
||||||
results.value = await searchOpts.value?.search( query.value ) ?? [];
|
results.value = await searchOpts.value?.search( query.value, 0 ) ?? [];
|
||||||
isSearching.value = false;
|
isSearching.value = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -73,12 +69,14 @@
|
|||||||
<div v-if="!isSearching && results.length > 0" 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)">
|
<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">
|
<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>
|
<div>
|
||||||
<h4>{{ result.name }}</h4>
|
<h4>{{ result.name }}</h4>
|
||||||
<p>{{ result.artist }}</p>
|
<p>{{ result.artist }}</p>
|
||||||
</div>
|
</div>
|
||||||
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
|
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<!-- TODO: load more if close to bottom of scroll view -->
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
|
<div v-else-if="!isSearching && results.length === 0" class="search-results-wrapper placeholder">
|
||||||
No results found
|
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';
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
export interface CloudImport {
|
export interface CloudImport {
|
||||||
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
|
|
||||||
'name': string;
|
'name': string;
|
||||||
'type': 'cloud';
|
'type': 'cloud';
|
||||||
'search': ( term: string ) => Promise<Song[]>;
|
'search': ( term: string, offset: number ) => Promise<Song[]>;
|
||||||
'addSelected': ( index: number ) => void;
|
'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 {
|
export interface FileImport {
|
||||||
'name': string;
|
'name': string;
|
||||||
'type': 'file';
|
'type': 'file';
|
||||||
'process': ( files: File[] ) => Promise<Song[]>;
|
'mime': string;
|
||||||
|
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportTypes = CloudImport | FileImport;
|
export type ImportTypes = CloudImport | FileImport;
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
CloudImport
|
CloudImport
|
||||||
} from './importTypePicker';
|
} from './importTypePicker';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
export const isShowingSearchView = ref( false );
|
export const isShowingSearchView = ref( false );
|
||||||
|
|
||||||
export const searchOpts: Ref<CloudImport | null> = ref( null );
|
export const searchOpts: Ref<CloudImport | null> = ref( null );
|
||||||
|
|
||||||
|
export const results: Ref<Song[]> = ref( [] );
|
||||||
|
|
||||||
|
export const query = ref( '' );
|
||||||
|
|
||||||
export const openSearchInterface = ( options: CloudImport ) => {
|
export const openSearchInterface = ( options: CloudImport ) => {
|
||||||
searchOpts.value = options;
|
searchOpts.value = options;
|
||||||
isShowingSearchView.value = true;
|
isShowingSearchView.value = true;
|
||||||
|
results.value = [];
|
||||||
|
query.value = '';
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -30,15 +30,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
>img {
|
|
||||||
height: 7rem;
|
|
||||||
width: 7rem;
|
|
||||||
margin-right: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
>.fa-solid {
|
>.fa-solid {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 2rem;
|
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;
|
'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;
|
'additional-identifier'?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,9 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '../dtype/playlist';
|
} from '../dtype/playlist';
|
||||||
|
import {
|
||||||
|
load
|
||||||
|
} from './playlists/loader';
|
||||||
import {
|
import {
|
||||||
playIndex
|
playIndex
|
||||||
} from './playlists';
|
} from './playlists';
|
||||||
@@ -137,7 +140,6 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
|||||||
if ( sources[source]!.authorized.value ) {
|
if ( sources[source]!.authorized.value ) {
|
||||||
sources[source]!.addSongsFromThisSource( addToSongList );
|
sources[source]!.addSongsFromThisSource( addToSongList );
|
||||||
} else {
|
} else {
|
||||||
// TODO: Make this interact with user
|
|
||||||
sources[source]!.login!();
|
sources[source]!.login!();
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -146,6 +148,7 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -167,5 +170,6 @@ export default {
|
|||||||
playbackPercentage,
|
playbackPercentage,
|
||||||
repeat,
|
repeat,
|
||||||
shuffle,
|
shuffle,
|
||||||
isPlaying
|
isPlaying,
|
||||||
|
'loadPlaylist': load
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,16 +15,9 @@ import type {
|
|||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
export const addSongList = ( songs: Song[], first: boolean = false ) => {
|
export const addSongList = ( songs: Song[] ) => {
|
||||||
if ( first ) {
|
rawQueue.value = rawQueue.value.concat( songs );
|
||||||
// FIXME: Needs to insert at current index in queue, but where for rawQueue?
|
queue.value = queue.value.concat( songs );
|
||||||
// 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 clearQueue = () => {
|
export const clearQueue = () => {
|
||||||
|
|||||||
@@ -1,2 +1,47 @@
|
|||||||
// TODO: If local files contained, prompt user to select files, then associate based on file names
|
import {
|
||||||
export const load = () => {};
|
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
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
// TODO: Add search interface elements to plugin's args (such as a search interface)
|
|
||||||
export type PlayerSourcePluginInitializer = () => Promise<PlayerSourcePlugin>;
|
export type PlayerSourcePluginInitializer = () => Promise<PlayerSourcePlugin>;
|
||||||
|
|
||||||
|
export interface AssociationResult {
|
||||||
|
'song': Song,
|
||||||
|
'match': 'exact' | 'none' | 'multiple';
|
||||||
|
'possibleFiles': File[],
|
||||||
|
'selectedIdx'?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlayerSourcePlugin {
|
export interface PlayerSourcePlugin {
|
||||||
/**
|
/**
|
||||||
* The name of the source that is displayed in the UI
|
* The name of the source that is displayed in the UI
|
||||||
@@ -33,17 +39,26 @@ export interface PlayerSourcePlugin {
|
|||||||
*/
|
*/
|
||||||
'requiresLocalFiles': true;
|
'requiresLocalFiles': true;
|
||||||
|
|
||||||
/**
|
|
||||||
* The allowed MIME types for loading associated files
|
|
||||||
*/
|
|
||||||
'MIMETypes': string;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function used to associate the file to the songs of the playlist
|
* Function used to associate the file to the songs of the playlist
|
||||||
* @param files - The files that were picked by the user
|
* @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
|
* 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,
|
PlayerSourcePlugin,
|
||||||
PlayerSourcePluginInitializer
|
PlayerSourcePluginInitializer
|
||||||
} from '../interface';
|
} from '../interface';
|
||||||
|
import {
|
||||||
|
associate,
|
||||||
|
updateIdentifiers
|
||||||
|
} from './association';
|
||||||
|
import {
|
||||||
|
load
|
||||||
|
} from './loader';
|
||||||
import {
|
import {
|
||||||
ref
|
ref
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
|
export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<PlayerSourcePlugin> => {
|
||||||
const player = document.createElement( 'audio' );
|
const player = new Audio();
|
||||||
|
|
||||||
const playSong = async ( id: string ) => {
|
const playSong = async ( id: string ) => {
|
||||||
player.src = id;
|
player.src = id;
|
||||||
@@ -18,20 +25,19 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
|
|||||||
'authorized': ref( true ),
|
'authorized': ref( true ),
|
||||||
'id': 'local',
|
'id': 'local',
|
||||||
'name': 'Local Disk',
|
'name': 'Local Disk',
|
||||||
'play': player.play,
|
'play': () => player.play(),
|
||||||
'getPlaybackPos': () => player.currentTime / player.duration,
|
'getPlaybackPos': () => player.currentTime / player.duration,
|
||||||
'getDuration': () => player.duration,
|
'getDuration': () => player.duration,
|
||||||
playSong,
|
playSong,
|
||||||
'seekTo': pos => player.currentTime = pos,
|
'seekTo': pos => player.currentTime = pos * player.duration,
|
||||||
'pause': player.pause,
|
'pause': () => player.pause(),
|
||||||
'stop': () => player.src = '',
|
'stop': () => player.src = '',
|
||||||
// TODO: Implement
|
'addSongsFromThisSource': load,
|
||||||
'addSongsFromThisSource': async () => [],
|
|
||||||
'loading': {
|
'loading': {
|
||||||
'requiresLocalFiles': true,
|
'requiresLocalFiles': true,
|
||||||
'MIMETypes': 'audio/mp3,audio/wav',
|
'association': associate,
|
||||||
// TODO: Implement
|
'updateIdentifiers': updateIdentifiers,
|
||||||
'association': async () => []
|
'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',
|
'name': 'Apple Music',
|
||||||
'seekTo': controls.seekTo,
|
'seekTo': controls.seekTo,
|
||||||
'login': login,
|
'login': login,
|
||||||
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
|
'addSongsFromThisSource': addFromAppleMusic,
|
||||||
'loading': {
|
'loading': {
|
||||||
'requiresLocalFiles': false
|
'requiresLocalFiles': false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,5 +32,12 @@ export interface AppleMusicPlaylistData {
|
|||||||
'attributes': {
|
'attributes': {
|
||||||
'artwork': Artwork;
|
'artwork': Artwork;
|
||||||
'name': string;
|
'name': string;
|
||||||
|
},
|
||||||
|
'relationships'?: {
|
||||||
|
'tracks': {
|
||||||
|
'href': string,
|
||||||
|
'next': string,
|
||||||
|
'data': AppleMusicSongData
|
||||||
|
}[]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
import type {
|
|
||||||
MusicKitInstance
|
|
||||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
|
||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
@@ -14,16 +11,17 @@ import {
|
|||||||
searchSongs
|
searchSongs
|
||||||
} from './songs';
|
} from './songs';
|
||||||
|
|
||||||
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
|
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void ): Promise<void> => {
|
||||||
const songs = await searchSongs( instance, cb );
|
const songs = await searchSongs( cb );
|
||||||
const playlists = await searchPlaylists( instance, cb );
|
const playlists = await searchPlaylists( cb );
|
||||||
|
|
||||||
openImportTypePicker( [
|
openImportTypePicker( [
|
||||||
{
|
{
|
||||||
'name': 'Songs',
|
'name': 'Songs',
|
||||||
'type': 'cloud',
|
'type': 'cloud',
|
||||||
'addSelected': songs.addSelected,
|
'addSelected': songs.addSelected,
|
||||||
'search': songs.search
|
'search': songs.search,
|
||||||
|
'minChars': 3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Playlists',
|
'name': 'Playlists',
|
||||||
|
|||||||
@@ -1,21 +1,22 @@
|
|||||||
import type {
|
import type {
|
||||||
AppleMusicPlaylistData
|
AppleMusicPlaylistData,
|
||||||
|
AppleMusicSongData
|
||||||
} from './dtype';
|
} from './dtype';
|
||||||
import type {
|
|
||||||
MusicKitInstance
|
|
||||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
|
||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} 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 playlists: Song[] = [];
|
||||||
let filtered: 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 ) {
|
if ( playlists.length === 0 ) {
|
||||||
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
|
playlists = ( ( await instance.api.music( '/v1/me/library/playlists', {
|
||||||
'limit': 100
|
'limit': 100,
|
||||||
|
'offset': offset
|
||||||
} ) ).data as {
|
} ) ).data as {
|
||||||
'data': AppleMusicPlaylistData[]
|
'data': AppleMusicPlaylistData[]
|
||||||
} ).data.map( val => {
|
} ).data.map( val => {
|
||||||
@@ -31,16 +32,36 @@ export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs:
|
|||||||
} );
|
} );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
term = term.toLocaleLowerCase();
|
||||||
filtered = playlists.filter( val => {
|
filtered = playlists.filter( val => {
|
||||||
return val.name.includes( term );
|
return val.name.toLocaleLowerCase().includes( term );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSelected = async ( idx: number ) => {
|
const addSelected = async ( idx: number ) => {
|
||||||
// TODO: Implement
|
try {
|
||||||
cb( [] );
|
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 {
|
return {
|
||||||
|
|||||||
@@ -1,46 +1,17 @@
|
|||||||
import type {
|
|
||||||
AppleMusicApiSearchResult,
|
|
||||||
AppleMusicSongData
|
|
||||||
} from './dtype';
|
|
||||||
import type {
|
|
||||||
MusicKitInstance
|
|
||||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
|
||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} 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[] = [];
|
let songs: Song[] = [];
|
||||||
|
|
||||||
const search = async ( term: string ): Promise<Song[]> => {
|
const searchFunc = initAppleMusicApiSearch();
|
||||||
const params = {
|
|
||||||
'term': term,
|
|
||||||
'types': [ 'songs' ]
|
|
||||||
};
|
|
||||||
|
|
||||||
let results: AppleMusicSongData[] = [];
|
const search = async ( term: string, offset: number = 0 ) => {
|
||||||
|
songs = await searchFunc( term, 15, offset );
|
||||||
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'
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
return songs;
|
return songs;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -14,9 +14,9 @@ import {
|
|||||||
// Tracking of time and player status
|
// Tracking of time and player status
|
||||||
let interval = -1;
|
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 = () => {
|
export const startTracking = () => {
|
||||||
if ( interval === -1 ) {
|
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