mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
faa271edd6 | ||
|
|
3a0f8210dd | ||
|
|
a2b93e9624 |
+3
-4
@@ -9,7 +9,7 @@ const run = () => {
|
|||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
app.use( expressSession( {
|
app.use( expressSession( {
|
||||||
'secret': '',
|
'secret': 'dev',
|
||||||
'resave': true,
|
'resave': true,
|
||||||
'saveUninitialized': false
|
'saveUninitialized': false
|
||||||
} ) );
|
} ) );
|
||||||
@@ -29,9 +29,8 @@ const run = () => {
|
|||||||
__dirname,
|
__dirname,
|
||||||
'/config/apple-music-api.config.secret.json'
|
'/config/apple-music-api.config.secret.json'
|
||||||
) ).toString() );
|
) ).toString() );
|
||||||
|
app.get( '/dev-token', ( _request: express.Request, response: express.Response ) => {
|
||||||
app.get( '/dev-token', ( request: express.Request, response: express.Response ) => {
|
// sign dev token
|
||||||
// sign dev token
|
|
||||||
const now = new Date().getTime();
|
const now = new Date().getTime();
|
||||||
const tomorrow = now + ( 24 * 3600 * 1000 );
|
const tomorrow = now + ( 24 * 3600 * 1000 );
|
||||||
const jwtToken = jwt.sign( {
|
const jwtToken = jwt.sign( {
|
||||||
|
|||||||
+11
-6
@@ -1,13 +1,18 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" href="/favicon.ico">
|
<link rel="icon" href="/favicon.ico">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Vite App</title>
|
<title>MusicPlayer</title>
|
||||||
</head>
|
<script src="https://js-cdn.music.apple.com/musickit/v3/musickit.js"></script>
|
||||||
<body>
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
<noscript>This application requires JavaScript to work!</noscript>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
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';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<ImportTypePicker />
|
||||||
<PopupElement v-model="showPopup" show-close>
|
<PopupElement v-model="showPopup" show-close>
|
||||||
<h1>Add Song</h1>
|
<h1>Add Song</h1>
|
||||||
<div class="song-sources">
|
<div class="song-sources">
|
||||||
@@ -48,6 +50,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
>.fa-solid {
|
>.fa-solid {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from './PopupElement.vue';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement>
|
||||||
|
<h2></h2>
|
||||||
|
<button>Ok</button>
|
||||||
|
<button>Cancel</button>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -79,5 +79,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import '@/scss/components/player.scss';
|
@use '@/scss/components/player.scss';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -50,5 +50,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import '@/scss/components/progressbar.scss';
|
@use '@/scss/components/progressbar.scss';
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,22 +1,46 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
type WritableComputedRef,
|
||||||
|
computed,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
import AddSong from './AddSong.vue';
|
import AddSong from './AddSong.vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
import SongEditor from './SongEditor.vue';
|
||||||
import SortableList from './SortableList.vue';
|
import SortableList from './SortableList.vue';
|
||||||
import {
|
import {
|
||||||
beautifyTime
|
beautifyTime
|
||||||
} from '@/ts/util/time';
|
} from '@/ts/util/time';
|
||||||
import player from '@/ts/player';
|
import player from '@/ts/player';
|
||||||
import {
|
|
||||||
ref
|
|
||||||
} from 'vue';
|
|
||||||
|
|
||||||
const queue = player.queue;
|
const queue: WritableComputedRef<Song[]> = computed( {
|
||||||
const idx = player.queueIdx;
|
get () {
|
||||||
|
return player.queue.value.slice( player.queueIdx.value );
|
||||||
|
},
|
||||||
|
set ( val ) {
|
||||||
|
player.queue.value = player.queue.value.slice( 0, player.queueIdx.value ).concat( val );
|
||||||
|
}
|
||||||
|
} );
|
||||||
const isPlaying = player.isPlaying;
|
const isPlaying = player.isPlaying;
|
||||||
const showAddSong = ref( false );
|
const showAddSong = ref( false );
|
||||||
|
const showEditSong = ref( false );
|
||||||
|
const editingSong: Ref<null | Song> = ref( null );
|
||||||
|
|
||||||
const addSong = () => {
|
const addSong = () => {
|
||||||
showAddSong.value = true;
|
showAddSong.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editSong = ( idx: number ) => {
|
||||||
|
showEditSong.value = true;
|
||||||
|
editingSong.value = player.queue.value[ idx ]!;
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSong = ( idx: number ) => {
|
||||||
|
player.removeSong( idx );
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,7 +50,7 @@
|
|||||||
<i class="fa-solid fa-plus"></i>
|
<i class="fa-solid fa-plus"></i>
|
||||||
Add
|
Add
|
||||||
</button>
|
</button>
|
||||||
<button>
|
<button @click="player.clearQueue">
|
||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
@@ -40,36 +64,50 @@
|
|||||||
Transmit
|
Transmit
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<SongEditor v-model="showEditSong" editing-song="" />
|
||||||
<AddSong v-model="showAddSong" />
|
<AddSong v-model="showAddSong" />
|
||||||
<div>
|
<div>
|
||||||
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
||||||
<div class="song-cover-wrapper">
|
<div class="song-list-element">
|
||||||
<img
|
<div class="song-cover-wrapper">
|
||||||
v-if="song.cover"
|
<img
|
||||||
:src="song.cover"
|
v-if="song.artwork"
|
||||||
alt="Song cover"
|
:src="song.artwork"
|
||||||
class="song-cover"
|
alt="Song cover"
|
||||||
>
|
class="song-cover"
|
||||||
<i v-else class="fa-solid fa-music song-cover"></i>
|
>
|
||||||
<div v-if="isPlaying && index === idx" class="playing-symbols">
|
<i v-else class="fa-solid fa-music song-cover"></i>
|
||||||
<div id="bar-1" class="playing-bar"></div>
|
<div v-if="index === 0" class="play-overlay">
|
||||||
<div id="bar-2" class="playing-bar"></div>
|
<div v-if="isPlaying" class="playing-symbols">
|
||||||
<div id="bar-3" class="playing-bar"></div>
|
<div id="bar-1" class="playing-bar"></div>
|
||||||
|
<div id="bar-2" class="playing-bar"></div>
|
||||||
|
<div id="bar-3" class="playing-bar"></div>
|
||||||
|
</div>
|
||||||
|
<i
|
||||||
|
v-else
|
||||||
|
class="fa-solid fa-pause"
|
||||||
|
></i>
|
||||||
|
</div>
|
||||||
|
<div v-else class="play-overlay hover">
|
||||||
|
<i class="fa-solid fa-play" @click="() => player.playIndex( index )"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="song-details">
|
||||||
|
<h3>{{ song.name }}</h3>
|
||||||
|
<p>{{ song.artist }}</p>
|
||||||
|
<p>{{ song['additional-info'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<p>{{ beautifyTime( song.duration ) }}</p>
|
||||||
|
<i v-if="index !== 0" class="fa-solid fa-trash-can" @click="() => deleteSong( index )"></i>
|
||||||
|
<i class="fa-solid fa-pen-to-square" @click="() => editSong( index )"></i>
|
||||||
</div>
|
</div>
|
||||||
<i v-else class="fa-solid fa-play play-pause" @click="() => player.playIndex( index )"></i>
|
|
||||||
<i class="fa-solid fa-pause play-pause" @click="player.pause"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h3>{{ song.name }}</h3>
|
|
||||||
<p>{{ song.artist }}</p>
|
|
||||||
<p>{{ beautifyTime( song.duration ) }}</p>
|
|
||||||
<p>{{ song['additional-info'] }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<i class="fa-solid fa-trash-can"></i>
|
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
|
||||||
</div>
|
</div>
|
||||||
</SortableList>
|
</SortableList>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/queue.scss';
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from './PopupElement.vue';
|
||||||
|
|
||||||
|
const model = defineModel<boolean>( {
|
||||||
|
'required': true
|
||||||
|
} );
|
||||||
|
</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 -->
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -29,6 +29,8 @@
|
|||||||
let moveSpeed = 0;
|
let moveSpeed = 0;
|
||||||
|
|
||||||
const start = ( ev: MouseEvent, idx: number ) => {
|
const start = ( ev: MouseEvent, idx: number ) => {
|
||||||
|
if ( array.value.length < 2 ) return;
|
||||||
|
|
||||||
movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight;
|
movableSize.value = document.getElementById( 'movable-' + idx )!.scrollHeight;
|
||||||
offset.value = ev.y - ( movableSize.value / 2 );
|
offset.value = ev.y - ( movableSize.value / 2 );
|
||||||
movingIdx.value = idx;
|
movingIdx.value = idx;
|
||||||
@@ -95,9 +97,10 @@
|
|||||||
const before = array.value.slice( 0, movingIdx.value );
|
const before = array.value.slice( 0, movingIdx.value );
|
||||||
const after = array.value.slice( movingIdx.value + 1 );
|
const after = array.value.slice( movingIdx.value + 1 );
|
||||||
const el = array.value[ movingIdx.value ]!;
|
const el = array.value[ movingIdx.value ]!;
|
||||||
|
const arr = before.concat( after );
|
||||||
|
|
||||||
array.value = before.concat( after );
|
arr.splice( movingCurrentIdx.value, 0, el );
|
||||||
array.value.splice( movingCurrentIdx.value, 0, el );
|
array.value = arr;
|
||||||
movingIdx.value = -1;
|
movingIdx.value = -1;
|
||||||
movingCurrentIdx.value = -1;
|
movingCurrentIdx.value = -1;
|
||||||
|
|
||||||
@@ -156,5 +159,5 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@import '@/scss/components/sortablelist.scss'
|
@use '@/scss/components/sortablelist.scss'
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import {
|
||||||
|
isShowingDiskLoader
|
||||||
|
} from './diskLoader';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="isShowingDiskLoader" show-close>
|
||||||
|
<h3>Load from disk</h3>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type ImportTypes,
|
||||||
|
importers,
|
||||||
|
isShowingImportTypePicker
|
||||||
|
} from './importTypePicker';
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import SearchView from './SearchView.vue';
|
||||||
|
import {
|
||||||
|
openDiskLoaderInterface
|
||||||
|
} from './diskLoader';
|
||||||
|
import {
|
||||||
|
openSearchInterface
|
||||||
|
} from './searchManager';
|
||||||
|
|
||||||
|
const openPicker = ( source: ImportTypes ) => {
|
||||||
|
isShowingImportTypePicker.value = false;
|
||||||
|
|
||||||
|
|
||||||
|
if ( source.type === 'cloud' ) {
|
||||||
|
openSearchInterface( source );
|
||||||
|
} else {
|
||||||
|
openDiskLoaderInterface( source );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<SearchView />
|
||||||
|
<PopupElement v-model="isShowingImportTypePicker" show-close>
|
||||||
|
<h2>What would you like to add?</h2>
|
||||||
|
<div class="import-type-list">
|
||||||
|
<div v-for="(source, index) in importers" :key="index" @click="openPicker( source )">
|
||||||
|
{{ source.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.import-type-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 50vw;
|
||||||
|
height: 35vh;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 32%;
|
||||||
|
margin: 0.5%;
|
||||||
|
height: 50%;
|
||||||
|
background-color: var(--accent-background);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
isShowingSearchView,
|
||||||
|
searchOpts
|
||||||
|
} from './searchManager';
|
||||||
|
import PopupElement from '@/components/PopupElement.vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
|
const results: Ref<Song[]> = ref( [] );
|
||||||
|
const query = ref( '' );
|
||||||
|
const isSearching = ref( false );
|
||||||
|
const addedIndex = ref( -1 );
|
||||||
|
|
||||||
|
let addedTimeout = -1;
|
||||||
|
let timeout = -1;
|
||||||
|
let searchedForQuery = '';
|
||||||
|
|
||||||
|
const search = ( ev: KeyboardEvent ) => {
|
||||||
|
if ( ev.key === 'Enter' ) {
|
||||||
|
// TODO: If no new input, instead of searching, add first song
|
||||||
|
try {
|
||||||
|
clearTimeout( timeout );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
doSearch();
|
||||||
|
} else if ( query.value.length > 2 ) {
|
||||||
|
try {
|
||||||
|
clearTimeout( timeout );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
timeout = setTimeout( doSearch, 250 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
if ( query.value === searchedForQuery ) return;
|
||||||
|
|
||||||
|
isSearching.value = true;
|
||||||
|
searchedForQuery = query.value;
|
||||||
|
results.value = await searchOpts.value?.search( query.value ) ?? [];
|
||||||
|
isSearching.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const add = ( idx: number ) => {
|
||||||
|
try {
|
||||||
|
clearTimeout( addedTimeout );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
addedIndex.value = idx;
|
||||||
|
searchOpts.value?.addSelected( idx );
|
||||||
|
addedTimeout = setTimeout( () => {
|
||||||
|
addedIndex.value = -1;
|
||||||
|
}, 2000 );
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="isShowingSearchView" show-close>
|
||||||
|
<h2>Search {{ searchOpts?.name }}</h2>
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search..."
|
||||||
|
@keypress="search"
|
||||||
|
>
|
||||||
|
<div v-if="!isSearching" class="search-results-wrapper">
|
||||||
|
<div v-for="(result, index) in results" :key="index" @click="() => add(index)">
|
||||||
|
<img :src="result.artwork" :alt="'Album artwork of ' + result.name + ' by ' + result.artist">
|
||||||
|
<div>
|
||||||
|
<h4>{{ result.name }}</h4>
|
||||||
|
<p>{{ result.artist }}</p>
|
||||||
|
</div>
|
||||||
|
<i v-if="addedIndex === index" class="fa-solid fa-check"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="search-results-wrapper placeholder">
|
||||||
|
Searching...
|
||||||
|
</div>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/search.scss';
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
FileImport
|
||||||
|
} from './importTypePicker';
|
||||||
|
|
||||||
|
export const isShowingDiskLoader = ref( false );
|
||||||
|
|
||||||
|
export const diskLoaderOpts: Ref<FileImport | null> = ref( null );
|
||||||
|
|
||||||
|
export const openDiskLoaderInterface = ( options: FileImport ) => {
|
||||||
|
isShowingDiskLoader.value = true;
|
||||||
|
diskLoaderOpts.value = options;
|
||||||
|
};
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
@@ -5,13 +9,25 @@ import type {
|
|||||||
export interface CloudImport {
|
export interface CloudImport {
|
||||||
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
|
// TODO: Consider if should make selectable and display album / playlist contents to select specific songs
|
||||||
'name': string;
|
'name': string;
|
||||||
'kind': 'list' | 'nested-list';
|
'type': 'cloud';
|
||||||
'search': ( term: string ) => Promise<Song[]>;
|
'search': ( term: string ) => Promise<Song[]>;
|
||||||
'addSelected': ( index: number ) => Promise<Song[]>;
|
'addSelected': ( index: number ) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Need to add a way to access Apple Music API everywhere (maybe configure MusicKitJS globally and consume it in its plugin later?)
|
// 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';
|
||||||
'process': ( files: File[] ) => Promise<Song[]>;
|
'process': ( files: File[] ) => Promise<Song[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ImportTypes = CloudImport | FileImport;
|
||||||
|
|
||||||
|
export const isShowingImportTypePicker = ref( false );
|
||||||
|
|
||||||
|
export const importers: Ref<( CloudImport | FileImport )[]> = ref( [] );
|
||||||
|
|
||||||
|
export const openImportTypePicker = ( options: ( CloudImport | FileImport )[] ) => {
|
||||||
|
isShowingImportTypePicker.value = true;
|
||||||
|
importers.value = options;
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
CloudImport
|
||||||
|
} from './importTypePicker';
|
||||||
|
|
||||||
|
export const isShowingSearchView = ref( false );
|
||||||
|
|
||||||
|
export const searchOpts: Ref<CloudImport | null> = ref( null );
|
||||||
|
|
||||||
|
export const openSearchInterface = ( options: CloudImport ) => {
|
||||||
|
searchOpts.value = options;
|
||||||
|
isShowingSearchView.value = true;
|
||||||
|
};
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
.song-list-element {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
>.song-cover-wrapper {
|
||||||
|
height: 10rem;
|
||||||
|
width: 10rem;
|
||||||
|
position: relative;
|
||||||
|
margin-right: 20px;
|
||||||
|
|
||||||
|
.song-cover,
|
||||||
|
.fa-solid {
|
||||||
|
height: 10rem;
|
||||||
|
width: 10rem;
|
||||||
|
font-size: 7rem;
|
||||||
|
line-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.play-overlay {
|
||||||
|
background-color: var(--overlay-color);
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.hover {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
>.play-overlay.hover {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.song-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
|
||||||
|
>* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>h3 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.song-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: 10px;
|
||||||
|
|
||||||
|
>* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.playing-symbols {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20%;
|
||||||
|
padding-right: 20%;
|
||||||
|
width: 60%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.6);
|
||||||
|
|
||||||
|
.playing-bar {
|
||||||
|
height: 40%;
|
||||||
|
background-color: white;
|
||||||
|
width: 12%;
|
||||||
|
border-radius: 50px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bar-1 {
|
||||||
|
animation: music-playing 0.9s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bar-2 {
|
||||||
|
animation: music-playing 0.9s infinite ease-in-out;
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bar-3 {
|
||||||
|
animation: music-playing 0.9s infinite ease-in-out;
|
||||||
|
animation-delay: 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes music-playing {
|
||||||
|
0% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scaleY(0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
transform: scaleY(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.search-results-wrapper {
|
||||||
|
width: 70vw;
|
||||||
|
height: 60vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.placeholder {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
width: 75%;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
>* {
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>img {
|
||||||
|
height: 7rem;
|
||||||
|
width: 7rem;
|
||||||
|
margin-right: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
>.movable {
|
>.movable {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
|
||||||
&.moving {
|
&.moving {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import type {
|
||||||
|
MusicKitObject
|
||||||
|
} from 'musickitjs-v3-types/MusicKit';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
var MusicKit: MusicKitObject;
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -40,7 +40,7 @@ export interface Song {
|
|||||||
/**
|
/**
|
||||||
* The cover image as a URL
|
* The cover image as a URL
|
||||||
*/
|
*/
|
||||||
'cover': string;
|
'artwork': string;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Song duration
|
* Song duration
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
addSongList,
|
addSongList,
|
||||||
|
clearQueue,
|
||||||
|
removeSong,
|
||||||
shuffleList
|
shuffleList
|
||||||
} from './playlists/add';
|
} from './playlists/add';
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +28,12 @@ import {
|
|||||||
} from './playlists';
|
} from './playlists';
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
playIndex( queueIdx.value + 1 );
|
playIndex( ( queueIdx.value + 1 ) % queue.value.length );
|
||||||
};
|
};
|
||||||
|
|
||||||
const prev = () => {
|
const prev = () => {
|
||||||
// TODO: Back to beginning if more than some seconds have passed?
|
// TODO: Back to beginning if more than some seconds have passed?
|
||||||
playIndex( queueIdx.value - 1 );
|
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
|
||||||
};
|
};
|
||||||
|
|
||||||
const play = () => {
|
const play = () => {
|
||||||
@@ -116,8 +118,12 @@ const getSources = (): string[] => {
|
|||||||
return Object.keys( sources );
|
return Object.keys( sources );
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add songs to the playlist from given source
|
||||||
|
* @param source - The ID of the source to add from
|
||||||
|
*/
|
||||||
const addSongFromSource = async ( source: string ) => {
|
const addSongFromSource = async ( source: string ) => {
|
||||||
addSongList( await sources[source]!.addSongsFromThisSource() );
|
sources[source]!.addSongsFromThisSource( addSongList );
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -133,6 +139,8 @@ export default {
|
|||||||
addSongFromSource,
|
addSongFromSource,
|
||||||
next,
|
next,
|
||||||
prev,
|
prev,
|
||||||
|
clearQueue,
|
||||||
|
removeSong,
|
||||||
queue,
|
queue,
|
||||||
queueIdx,
|
queueIdx,
|
||||||
duration,
|
duration,
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ export const removeSong = ( idx: number ) => {
|
|||||||
|
|
||||||
const removed = queue.value.splice( idx, 1 )[0];
|
const removed = queue.value.splice( idx, 1 )[0];
|
||||||
|
|
||||||
// TODO: Check that this works
|
|
||||||
for ( let i = 0; i < rawQueue.value.length; i++ ) {
|
for ( let i = 0; i < rawQueue.value.length; i++ ) {
|
||||||
if ( rawQueue.value[ i ] === removed ) {
|
if ( rawQueue.value[ i ] === removed ) {
|
||||||
rawQueue.value.splice( i, 1 );
|
rawQueue.value.splice( i, 1 );
|
||||||
|
|||||||
+6
-1
@@ -90,9 +90,14 @@ export interface PlayerSourcePlugin {
|
|||||||
*/
|
*/
|
||||||
'getDuration': () => number;
|
'getDuration': () => number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if source is available for playback
|
||||||
|
*/
|
||||||
|
'available': () => boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Called when user adds another song to the playlist via this source.
|
* 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
|
* You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc
|
||||||
*/
|
*/
|
||||||
'addSongsFromThisSource': () => Promise<Song[]>;
|
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export const useLocalPlayer: PlayerSourcePluginInitializer = async (): Promise<P
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
'available': () => true,
|
||||||
'id': 'local',
|
'id': 'local',
|
||||||
'name': 'Local Disk',
|
'name': 'Local Disk',
|
||||||
'play': player.play,
|
'play': player.play,
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import 'musickitjs-v3-types';
|
|
||||||
import type {
|
import type {
|
||||||
PlayerSourcePlugin,
|
PlayerSourcePlugin,
|
||||||
PlayerSourcePluginInitializer
|
PlayerSourcePluginInitializer
|
||||||
} from '../interface';
|
} from '../interface';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
import {
|
import {
|
||||||
addFromAppleMusic
|
addFromAppleMusic
|
||||||
} from './search';
|
} from './search';
|
||||||
@@ -13,7 +15,7 @@ import {
|
|||||||
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
|
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
|
||||||
return new Promise( ( resolve, reject ) => {
|
return new Promise( ( resolve, reject ) => {
|
||||||
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
|
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
|
||||||
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/', {
|
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', {
|
||||||
'credentials': 'include'
|
'credentials': 'include'
|
||||||
} );
|
} );
|
||||||
|
|
||||||
@@ -23,8 +25,8 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
|
|||||||
'developerToken': token,
|
'developerToken': token,
|
||||||
'app': {
|
'app': {
|
||||||
'name': 'MusicPlayer',
|
'name': 'MusicPlayer',
|
||||||
'build': '4',
|
'build': '4'
|
||||||
'icon': 'https://music.janishutz.com/logo.jpg'
|
// 'icon': 'https://music.janishutz.com/logo.jpg'
|
||||||
},
|
},
|
||||||
'storefrontId': storefront.toUpperCase()
|
'storefrontId': storefront.toUpperCase()
|
||||||
} );
|
} );
|
||||||
@@ -41,6 +43,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
'available': controls.getLoggedIn,
|
||||||
'play': controls.play,
|
'play': controls.play,
|
||||||
'pause': controls.pause,
|
'pause': controls.pause,
|
||||||
'stop': controls.stop,
|
'stop': controls.stop,
|
||||||
@@ -51,8 +54,7 @@ export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string =
|
|||||||
'name': 'Apple Music',
|
'name': 'Apple Music',
|
||||||
'seekTo': controls.seekTo,
|
'seekTo': controls.seekTo,
|
||||||
'login': login,
|
'login': login,
|
||||||
// TODO: Implement
|
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => addFromAppleMusic( instance, cb ),
|
||||||
'addSongsFromThisSource': async () => await addFromAppleMusic(),
|
|
||||||
'loading': {
|
'loading': {
|
||||||
'requiresLocalFiles': false
|
'requiresLocalFiles': false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
|
|||||||
return musickitInstance.currentPlaybackDuration;
|
return musickitInstance.currentPlaybackDuration;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getLoggedIn = (): boolean => {
|
||||||
|
return musickitInstance.isAuthorized;
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
@@ -41,6 +45,7 @@ export const musicKitPlayback = ( musickitInstance: MusicKitInstance ) => {
|
|||||||
playSong,
|
playSong,
|
||||||
stop,
|
stop,
|
||||||
getPlaybackPos,
|
getPlaybackPos,
|
||||||
getDuration
|
getDuration,
|
||||||
|
getLoggedIn
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
import type {
|
|
||||||
MusicKitInstance
|
|
||||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
|
||||||
|
|
||||||
export const searchAlbums = async ( instance: MusicKitInstance, term: string ) => {
|
|
||||||
const addSelected = ( idx: number ) => {
|
|
||||||
// TODO: Load album's songs and return them
|
|
||||||
Promise.resolve();
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type {
|
||||||
|
Artwork
|
||||||
|
} from 'musickitjs-v3-types/enums';
|
||||||
|
|
||||||
|
export interface AppleMusicApiSearchResult {
|
||||||
|
'results': {
|
||||||
|
'songs': {
|
||||||
|
'data': AppleMusicSongData[],
|
||||||
|
'href': string;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AppleMusicSongData {
|
||||||
|
'id': string,
|
||||||
|
'type': string;
|
||||||
|
'href': string;
|
||||||
|
'attributes': {
|
||||||
|
'albumName': string;
|
||||||
|
'artistName': string;
|
||||||
|
'artwork': Artwork,
|
||||||
|
'name': string;
|
||||||
|
'genreNames': string[];
|
||||||
|
'durationInMillis': number;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,32 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
|
import {
|
||||||
|
openImportTypePicker
|
||||||
|
} from '@/composables/importTypePicker';
|
||||||
|
import {
|
||||||
|
searchPlaylists
|
||||||
|
} from './playlists';
|
||||||
|
import {
|
||||||
|
searchSongs
|
||||||
|
} from './songs';
|
||||||
|
|
||||||
export const addFromAppleMusic = async ( instance: MusicKitInstance ): Promise<Song[]> => {
|
export const addFromAppleMusic = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ): Promise<void> => {
|
||||||
return [];
|
const songs = await searchSongs( instance, cb );
|
||||||
|
const playlists = await searchPlaylists( instance, cb );
|
||||||
|
|
||||||
|
openImportTypePicker( [
|
||||||
|
{
|
||||||
|
'name': 'Songs',
|
||||||
|
'type': 'cloud',
|
||||||
|
'addSelected': songs.addSelected,
|
||||||
|
'search': songs.search
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'name': 'Playlists',
|
||||||
|
'type': 'cloud',
|
||||||
|
'addSelected': playlists.addSelected,
|
||||||
|
'search': playlists.search
|
||||||
|
}
|
||||||
|
] );
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import type {
|
import type {
|
||||||
MusicKitInstance
|
MusicKitInstance
|
||||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
export const searchPlaylists = async ( instance: MusicKitInstance, userPlaylists: boolean = true ) => {
|
export const searchPlaylists = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||||
const addSelected = ( idx: number ) => {
|
// TODO: Get all playlists
|
||||||
// TODO: Load the playlist content and return songs
|
|
||||||
Promise.resolve();
|
const search = async ( term: string ): Promise<Song[]> => {
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const addSelected = async ( idx: number ) => {
|
||||||
|
// TODO: Implement
|
||||||
|
cb( [] );
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
addSelected
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,20 +1,46 @@
|
|||||||
|
import type {
|
||||||
|
AppleMusicApiSearchResult
|
||||||
|
} from './dtype';
|
||||||
import type {
|
import type {
|
||||||
MusicKitInstance
|
MusicKitInstance
|
||||||
} from 'musickitjs-v3-types/MusicKitInstance';
|
} from 'musickitjs-v3-types/MusicKitInstance';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
|
||||||
export const seachSongs = async ( instance: MusicKitInstance ) => {
|
export const searchSongs = async ( instance: MusicKitInstance, cb: ( songs: Song[] ) => void ) => {
|
||||||
const search = async ( term: string ) => {
|
let songs: Song[] = [];
|
||||||
|
|
||||||
|
const search = async ( term: string ): Promise<Song[]> => {
|
||||||
const params = {
|
const params = {
|
||||||
'term': term,
|
'term': term,
|
||||||
'types': [ 'songs' ],
|
'types': [ 'songs' ]
|
||||||
'l': 'en-us'
|
|
||||||
};
|
};
|
||||||
|
const results = ( ( await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params ) ).data as AppleMusicApiSearchResult ).results.songs.data;
|
||||||
|
|
||||||
await instance.api.music( '/v1/catalog/{{storefrontId}}/search', params );
|
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;
|
||||||
};
|
};
|
||||||
|
|
||||||
const addSelected = ( idx: number ) => {
|
const addSelected = async ( idx: number ) => {
|
||||||
// TODO: Need to only return the selected song
|
cb( [ songs[idx]! ] );
|
||||||
Promise.resolve();
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
search,
|
||||||
|
addSelected
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
RepeatMode
|
RepeatMode
|
||||||
} from '../dtype/player';
|
} from '../dtype/player';
|
||||||
|
import {
|
||||||
|
useLocalPlayer
|
||||||
|
} from './plugins/local';
|
||||||
|
import {
|
||||||
|
useMusicKit
|
||||||
|
} from './plugins/musickit';
|
||||||
|
|
||||||
export const sources: {
|
export const sources: {
|
||||||
[key: string]: PlayerSourcePlugin
|
[key: string]: PlayerSourcePlugin
|
||||||
@@ -29,3 +35,19 @@ export const isPlaying = ref( false );
|
|||||||
export const shuffle = ref( false );
|
export const shuffle = ref( false );
|
||||||
|
|
||||||
export const repeat: Ref<RepeatMode> = ref( 'off' );
|
export const repeat: Ref<RepeatMode> = ref( 'off' );
|
||||||
|
|
||||||
|
const initSources = async () => {
|
||||||
|
try {
|
||||||
|
sources['applemusic'] = await useMusicKit();
|
||||||
|
} catch {
|
||||||
|
console.warn( 'MusicKitJS intialization failed' );
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sources['local'] = await useLocalPlayer();
|
||||||
|
} catch {
|
||||||
|
console.warn( 'Local Player intialization failed' );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initSources();
|
||||||
|
|||||||
@@ -1,10 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import PlayerControls from '@/components/PlayerControls.vue';
|
||||||
import QueueViewer from '@/components/QueueViewer.vue';
|
import QueueViewer from '@/components/QueueViewer.vue';
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="main-app">
|
||||||
<h1>Player</h1>
|
<div class="player-controls">
|
||||||
<QueueViewer />
|
<PlayerControls />
|
||||||
|
</div>
|
||||||
|
<div class="song-queue">
|
||||||
|
<QueueViewer />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.main-app {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.player-controls {
|
||||||
|
width: 60%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song-queue {
|
||||||
|
width: 80%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
- [ ] About page
|
- [ ] About page
|
||||||
- [ ] Apple-Music signin
|
- [ ] Apple-Music signin
|
||||||
- [ ] Loading existing playlists with local music and association of that
|
- [ ] Loading existing playlists with local music and association of that
|
||||||
|
- [ ] Remote screens can use polling (once a minute or so) or SSE for updating (/fancy uses SSE by default, /share uses polling by default). Should reduce server load
|
||||||
|
|
||||||
# Backend
|
# Backend
|
||||||
- [ ] Implement all endpoints
|
- [ ] Implement all endpoints
|
||||||
|
|||||||
+26
-24
@@ -1,27 +1,29 @@
|
|||||||
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
{
|
{
|
||||||
"extends": "@tsconfig/node24/tsconfig.json",
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
"include": [
|
"include": [
|
||||||
"vite.config.*",
|
"vite.config.*",
|
||||||
"vitest.config.*",
|
"vitest.config.*",
|
||||||
"cypress.config.*",
|
"cypress.config.*",
|
||||||
"playwright.config.*",
|
"playwright.config.*",
|
||||||
"eslint.config.*"
|
"eslint.config.*"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
// Most tools use transpilation instead of Node.js's native type-stripping.
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
// Bundler mode provides a smoother developer experience.
|
// Bundler mode provides a smoother developer experience.
|
||||||
"module": "preserve",
|
"module": "preserve",
|
||||||
"moduleResolution": "bundler",
|
"moduleResolution": "bundler",
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
"types": [
|
||||||
"types": ["node"],
|
"node"
|
||||||
|
],
|
||||||
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
"typeRoots": [
|
||||||
"noEmit": true,
|
"./src/ts/dtype/global.d.ts"
|
||||||
|
],
|
||||||
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
// Specified here to keep it out of the root directory.
|
"noEmit": true,
|
||||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
}
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user