mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
feat: song editor
This commit is contained in:
@@ -64,7 +64,7 @@
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
<SongEditor v-model="showEditSong" editing-song="" />
|
||||
<SongEditor v-model="showEditSong" :song="editingSong" />
|
||||
<AddSong v-model="showAddSong" />
|
||||
<div v-if="queue.length > 0" class="queue-container">
|
||||
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
||||
|
||||
@@ -1,16 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type Ref, ref,
|
||||
watch
|
||||
} from 'vue';
|
||||
import PopupElement from './PopupElement.vue';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import player from '@/ts/player';
|
||||
|
||||
const model = defineModel<boolean>( {
|
||||
'required': true
|
||||
} );
|
||||
const props = defineProps<{
|
||||
'song': Song | null
|
||||
}>();
|
||||
const localSong: Ref<Song> = ref( {
|
||||
'name': '',
|
||||
'artist': '',
|
||||
'artwork': '',
|
||||
'additional-info': '',
|
||||
'duration': -1,
|
||||
'identifier': '',
|
||||
'source': 'local'
|
||||
} );
|
||||
|
||||
watch( props, () => {
|
||||
if ( props.song )
|
||||
localSong.value = props.song;
|
||||
} );
|
||||
|
||||
const search = () => {
|
||||
player.addSongFromSource( 'applemusic', ( songs: Song[] ) => {
|
||||
const song = songs[ 0 ];
|
||||
|
||||
if ( !song ) return;
|
||||
|
||||
localSong.value.artist = song.artist;
|
||||
localSong.value.artwork = song.artwork;
|
||||
localSong.value.name = song.name;
|
||||
}, true );
|
||||
};
|
||||
|
||||
const save = () => {
|
||||
model.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<PopupElement v-model="model" show-close>
|
||||
<h2>Edit Song</h2>
|
||||
<!-- TODO: Need a way to use the search feature to replace the song details, as of course as well text editors -->
|
||||
<button @click="search">
|
||||
Search song on Apple Music
|
||||
</button>
|
||||
|
||||
<label for="song-name">Song title</label>
|
||||
<input id="song-name" v-model="localSong.name" type="text">
|
||||
|
||||
<label for="song-artist">Artist</label>
|
||||
<input id="song-artist" v-model="localSong.artist" type="text">
|
||||
|
||||
<label for="song-artwork">Artwork URL</label>
|
||||
<input id="song-artwork" v-model="localSong.artwork" type="text">
|
||||
|
||||
<label for="song-add-info">Additional Info</label>
|
||||
<input id="song-add-info" v-model="localSong['additional-info']" type="text">
|
||||
|
||||
<button @click="save">
|
||||
Save
|
||||
</button>
|
||||
</PopupElement>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
isShowingAssociationManager.value = false;
|
||||
fullPlayer.value = false;
|
||||
};
|
||||
|
||||
const retry = () => {
|
||||
associationResults.value = [];
|
||||
isAnalyzing.value = false;
|
||||
needsFiles.value = true;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,7 +97,9 @@
|
||||
<div v-else-if="associationResults.length === 0"></div>
|
||||
<div v-else>
|
||||
An error occurred. Please try again
|
||||
<!-- FIXME: Button to restore -->
|
||||
<button @click="retry">
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<button @click="cancel">
|
||||
Cancel
|
||||
|
||||
@@ -49,6 +49,10 @@
|
||||
|
||||
addedIndex.value = idx;
|
||||
searchOpts.value?.addSelected( idx );
|
||||
|
||||
if ( searchOpts.value?.autoClose )
|
||||
isShowingSearchView.value = false;
|
||||
|
||||
addedTimeout = setTimeout( () => {
|
||||
addedIndex.value = -1;
|
||||
}, 2000 );
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface CloudImport {
|
||||
'search': ( term: string, offset: number ) => Promise<Song[]>;
|
||||
'addSelected': ( index: number ) => void;
|
||||
'minChars'?: number;
|
||||
'autoClose': boolean;
|
||||
}
|
||||
|
||||
export interface FileImport {
|
||||
@@ -19,6 +20,7 @@ export interface FileImport {
|
||||
'type': 'file';
|
||||
'mime': string;
|
||||
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
|
||||
'autoClose': boolean;
|
||||
}
|
||||
|
||||
export type ImportTypes = CloudImport | FileImport;
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
} from 'vue';
|
||||
|
||||
export const useAuthStore = defineStore( 'authstore', () => {
|
||||
const isAuth = ref( true );
|
||||
const isAuth = ref( false );
|
||||
|
||||
return {
|
||||
isAuth
|
||||
|
||||
Vendored
-2
@@ -1,5 +1,3 @@
|
||||
// NOTE: For re-associating files with details here, can use dropdown in UI
|
||||
|
||||
export interface Playlist {
|
||||
'name': string;
|
||||
'songs': PlaylistSongs;
|
||||
|
||||
@@ -20,13 +20,12 @@ export const isConnected = ref( false );
|
||||
|
||||
export const useAntiTamper = ref( false );
|
||||
|
||||
// TODO: Anit-Tamper
|
||||
// const antiTamperClients = [];
|
||||
|
||||
let connection: null | EventSource = null;
|
||||
let retries = 0;
|
||||
|
||||
// TODO: Persist room name in local storage
|
||||
|
||||
const createRoom = async ( name: string, antiTamper: boolean ): Promise<boolean> => {
|
||||
if ( !( /^[a-zA-Z0-9-]{3,20}$/ ).test( name ) ) return false;
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ const next = () => {
|
||||
};
|
||||
|
||||
const prev = () => {
|
||||
// TODO: Back to beginning if more than some seconds have passed?
|
||||
if ( playbackPercentage.value * duration.value > 7 )
|
||||
return seekTo( 0 );
|
||||
|
||||
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
|
||||
};
|
||||
|
||||
@@ -136,10 +138,12 @@ const addToSongList = ( songs: Song[] ) => {
|
||||
/**
|
||||
* Add songs to the playlist from given source
|
||||
* @param source - The ID of the source to add from
|
||||
* @param cb - A custom callback to be executed instead of the default, which adds to queue
|
||||
* @param skipLogin - Whether to skip login checks
|
||||
*/
|
||||
const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
||||
if ( sources[source]!.authorized.value ) {
|
||||
sources[source]!.addSongsFromThisSource( addToSongList );
|
||||
const addSongFromSource = async ( source: string, cb?: ( songs: Song[] ) => void, skipLogin?: boolean ): Promise<boolean> => {
|
||||
if ( sources[source]!.authorized.value || skipLogin ) {
|
||||
sources[source]!.addSongsFromThisSource( cb ? cb : addToSongList, skipLogin ? 0 : undefined, skipLogin );
|
||||
} else {
|
||||
sources[source]!.login!();
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ export const load = ( playlist: PlaylistSongs ) => {
|
||||
};
|
||||
|
||||
if ( needToLoadLocalSongs ) {
|
||||
// FIXME: Combine mime types
|
||||
const mime = Object.values( sources )
|
||||
.map( src => {
|
||||
return src.loading.requiresLocalFiles === true ? src.loading.mime : '';
|
||||
|
||||
+4
-1
@@ -116,8 +116,11 @@ 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
|
||||
* @param cb - Added songs are the arguments
|
||||
* @param kindIdx - The index in the list of import types that should be used without showing popup. If unset, will show the picker
|
||||
* @param autoClose - Whether or not to automatically close the interface after a single pick
|
||||
*/
|
||||
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
|
||||
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void, kindIdx?: number, autoClose?: boolean ) => void;
|
||||
|
||||
/**
|
||||
* Fully unload a song. This is called on clear of a playlist
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import {
|
||||
type CloudImport,
|
||||
openImportTypePicker
|
||||
} from '@/composables/importTypePicker';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import {
|
||||
openImportTypePicker
|
||||
} from '@/composables/importTypePicker';
|
||||
openSearchInterface
|
||||
} from '@/composables/searchManager';
|
||||
import {
|
||||
searchPlaylists
|
||||
} from './playlists';
|
||||
@@ -11,23 +15,29 @@ import {
|
||||
searchSongs
|
||||
} from './songs';
|
||||
|
||||
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void ): Promise<void> => {
|
||||
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void, kindIdx?: number, autoClose?: boolean ): Promise<void> => {
|
||||
const songs = await searchSongs( cb );
|
||||
const playlists = await searchPlaylists( cb );
|
||||
|
||||
openImportTypePicker( [
|
||||
const kinds: CloudImport[] = [
|
||||
{
|
||||
'name': 'Songs',
|
||||
'type': 'cloud',
|
||||
'addSelected': songs.addSelected,
|
||||
'search': songs.search,
|
||||
'minChars': 3
|
||||
'minChars': 3,
|
||||
'autoClose': autoClose ?? false
|
||||
},
|
||||
{
|
||||
'name': 'Playlists',
|
||||
'type': 'cloud',
|
||||
'addSelected': playlists.addSelected,
|
||||
'search': playlists.search
|
||||
'search': playlists.search,
|
||||
'autoClose': autoClose ?? false
|
||||
}
|
||||
] );
|
||||
];
|
||||
|
||||
if ( kindIdx === undefined )
|
||||
openImportTypePicker( kinds );
|
||||
else
|
||||
openSearchInterface( kinds[kindIdx]! );
|
||||
};
|
||||
|
||||
@@ -1,16 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
type ComputedRef,
|
||||
computed
|
||||
} from 'vue';
|
||||
import {
|
||||
currentQueue,
|
||||
currentQueueIdx
|
||||
} from '@/ts/shared/state';
|
||||
import CurrentSong from '@/components/player/CurrentSong.vue';
|
||||
import SharedQueue from '@/components/shared/SharedQueue.vue';
|
||||
import type {
|
||||
Song
|
||||
} from '@/ts/dtype/playlist';
|
||||
import shared from '@/ts/shared';
|
||||
|
||||
shared.connect();
|
||||
|
||||
const song: ComputedRef<Song> = computed( () => {
|
||||
if ( currentQueueIdx.value >= 0 )
|
||||
return currentQueue.value[currentQueueIdx.value]!;
|
||||
else
|
||||
return {
|
||||
'artist': 'No artist',
|
||||
'duration': -1,
|
||||
'name': 'Not playing',
|
||||
'artwork': '',
|
||||
'additional-info': '',
|
||||
'identifier': 'nosong-ident',
|
||||
'source': 'local'
|
||||
};
|
||||
} );
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shared-view">
|
||||
<div>
|
||||
<div>
|
||||
<CurrentSong />
|
||||
<CurrentSong v-model="song" />
|
||||
</div>
|
||||
<div>
|
||||
<SharedQueue />
|
||||
|
||||
+32
-24
@@ -1,36 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import SortableList from '@/components/SortableList.vue';
|
||||
import {
|
||||
onMounted,
|
||||
ref
|
||||
} from 'vue';
|
||||
import router from '@/router';
|
||||
import sdk from '@janishutz/login-sdk-browser';
|
||||
import {
|
||||
useAuthStore
|
||||
} from '@/stores/authstore';
|
||||
|
||||
const items = ref( [
|
||||
'test',
|
||||
'test2',
|
||||
'test3',
|
||||
'test4',
|
||||
'test5',
|
||||
'test6',
|
||||
'test7',
|
||||
'test8',
|
||||
'test9',
|
||||
'test10'
|
||||
] );
|
||||
const isLoggingIn = ref( true );
|
||||
const store = useAuthStore();
|
||||
|
||||
sdk.setUp( 'jh-music', 'http://localhost:8080', '/app' );
|
||||
|
||||
onMounted( async () => {
|
||||
store.isAuth = await sdk.verify();
|
||||
|
||||
if ( store.isAuth )
|
||||
router.push( '/app' );
|
||||
|
||||
isLoggingIn.value = false;
|
||||
|
||||
// TODO: Logout button
|
||||
} );
|
||||
|
||||
const login = () => {
|
||||
if ( isLoggingIn.value ) return;
|
||||
|
||||
isLoggingIn.value = true;
|
||||
sdk.login();
|
||||
isLoggingIn.value = false;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>MusicPlayer</h1>
|
||||
<div class="test">
|
||||
<SortableList v-slot="{ item, index }" v-model="items">
|
||||
{{ item }} at {{ index }}
|
||||
</SortableList>
|
||||
</div>
|
||||
<button :class="['fancy-button', isLoggingIn ? 'fancy-button-inactive' : undefined]" @click="login">
|
||||
Log In
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.test {
|
||||
height: 150px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user