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:
@@ -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 ) {
|
||||
|
||||
Reference in New Issue
Block a user