feat: song editor

This commit is contained in:
2026-09-08 09:52:40 +02:00
parent 6d9853f2c1
commit 047a3a8b29
16 changed files with 169 additions and 51 deletions
+2 -3
View File
@@ -43,7 +43,8 @@ const run = () => {
'frontendURL': config.webUrl,
'corsWhitelist': [ config.webUrl ],
'recheckTimeout': 300 * 1000,
'advancedVerification': 'sdk'
'advancedVerification': 'sdk',
'defaultRedirectURL': '/app'
},
app,
async () => {
@@ -75,8 +76,6 @@ const run = () => {
response.redirect( config.webUrl ?? 'https://music.janishutz.com' );
} );
// TODO: Need way for frontend to get the connection type for shares
// Load extra routes
devtoken.routes( app, foss );
-1
View File
@@ -16,7 +16,6 @@ import {
const routes = ( app: express.Application, foss: boolean, config: Config ) => {
const sdk = getLoginSdk( foss );
// FIXME: Here, if not in sse mode, simply close the connection after sending 'poll'
app.get( '/room/:id/connect', corsManager.middleware( false ), sseMiddleware( 'client', config ) );
app.get( '/room/:id/poll', corsManager.middleware( false ), ( request: express.Request, response: express.Response ) => {
+1 -1
View File
@@ -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">
+60 -1
View File
@@ -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>
+9 -1
View File
@@ -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
+4
View File
@@ -49,6 +49,10 @@
addedIndex.value = idx;
searchOpts.value?.addSelected( idx );
if ( searchOpts.value?.autoClose )
isShowingSearchView.value = false;
addedTimeout = setTimeout( () => {
addedIndex.value = -1;
}, 2000 );
+2
View File
@@ -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;
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from 'vue';
export const useAuthStore = defineStore( 'authstore', () => {
const isAuth = ref( true );
const isAuth = ref( false );
return {
isAuth
-2
View File
@@ -1,5 +1,3 @@
// NOTE: For re-associating files with details here, can use dropdown in UI
export interface Playlist {
'name': string;
'songs': PlaylistSongs;
+1 -2
View File
@@ -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;
+8 -4
View File
@@ -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!();
-1
View File
@@ -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
View File
@@ -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]! );
};
+27 -1
View File
@@ -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
View File
@@ -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>