mirror of
https://github.com/koel/koel
synced 2024-11-10 06:34:14 +00:00
feat: continuous playback
This commit is contained in:
parent
67766b49c1
commit
d3960ffe62
21 changed files with 99 additions and 54 deletions
|
@ -16,6 +16,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
|||
'show_album_art_overlay' => 'boolean',
|
||||
'lyrics_zoom_level' => 'integer',
|
||||
'make_uploads_public' => 'boolean',
|
||||
'continuous_playback' => 'boolean',
|
||||
];
|
||||
|
||||
private const CUSTOMIZABLE_KEYS = [
|
||||
|
@ -34,6 +35,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
|||
'lyrics_zoom_level',
|
||||
'visualizer',
|
||||
'active_extra_panel_tab',
|
||||
'continuous_playback',
|
||||
];
|
||||
|
||||
private const ALL_KEYS = self::CUSTOMIZABLE_KEYS + ['lastfm_session_key'];
|
||||
|
@ -51,6 +53,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
|||
public bool $showAlbumArtOverlay,
|
||||
public bool $makeUploadsPublic,
|
||||
public bool $supportBarNoBugging,
|
||||
public bool $continuousPlayback,
|
||||
public int $lyricsZoomLevel,
|
||||
public string $visualizer,
|
||||
public ?string $activeExtraPanelTab,
|
||||
|
@ -77,6 +80,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
|||
showAlbumArtOverlay: $data['show_album_art_overlay'] ?? true,
|
||||
makeUploadsPublic: $data['make_uploads_public'] ?? false,
|
||||
supportBarNoBugging: $data['support_bar_no_bugging'] ?? false,
|
||||
continuousPlayback: $data['continuous_playback'] ?? false,
|
||||
lyricsZoomLevel: $data['lyrics_zoom_level'] ?? 1,
|
||||
visualizer: $data['visualizer'] ?? 'default',
|
||||
activeExtraPanelTab: $data['active_extra_panel_tab'] ?? null,
|
||||
|
@ -133,6 +137,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
|||
'lyrics_zoom_level' => $this->lyricsZoomLevel,
|
||||
'visualizer' => $this->visualizer,
|
||||
'active_extra_panel_tab' => $this->activeExtraPanelTab,
|
||||
'continuous_playback' => $this->continuousPlayback,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
@ -19,15 +19,20 @@ composer install
|
|||
yarn install
|
||||
```
|
||||
|
||||
You can now start the development server with `yarn dev`, which is simply a wrapper around `php artisan serve`:
|
||||
You can now start the development server with `yarn dev`:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
$ php artisan serve
|
||||
$ yarn dev
|
||||
|
||||
INFO Server running on [http://127.0.0.1:8000].
|
||||
VITE v5.1.6 ready in 1549 ms
|
||||
|
||||
Press Ctrl+C to stop the server
|
||||
➜ Local: http://localhost:5173/
|
||||
➜ Network: use --host to expose
|
||||
➜ press h + enter to show help
|
||||
|
||||
LARAVEL v9.52.0 plugin v1.0.2
|
||||
|
||||
➜ APP_URL: http://localhost:8000
|
||||
```
|
||||
|
||||
A development version of Koel should now be available at `http://localhost:8000` with full HMR support.
|
||||
|
|
|
@ -35,6 +35,7 @@ More themes are to be added in the future, along with the ability to create your
|
|||
|
||||
Koel allows you to set a couple of preferences:
|
||||
|
||||
* Whether playing a song should trigger continuous playback of the entire playlist, album, artist, or genre
|
||||
* Whether to show a notification whenever a new song starts playing
|
||||
* Whether to confirm before closing Koel’s browser tab
|
||||
* Whether to show a translucent, blurred overlay of the current album’s art
|
||||
|
|
|
@ -6,6 +6,12 @@
|
|||
Make uploaded songs public by default
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.continuous_playback" name="continuous_playback" />
|
||||
Playing a song triggers continuous playback of the entire playlist, album, artist, or genre
|
||||
</label>
|
||||
</div>
|
||||
<div v-if="isPhone" class="form-row">
|
||||
<label>
|
||||
<CheckBox v-model="preferences.show_now_playing_notification" name="notify" />
|
||||
|
|
|
@ -112,11 +112,11 @@ const { showErrorDialog } = useDialogBox()
|
|||
const { getRouteParam, go, onScreenActivated } = useRouter()
|
||||
|
||||
const albumId = ref<number>()
|
||||
const album = ref<Album>()
|
||||
const album = ref<Album | undefined>()
|
||||
const songs = ref<Song[]>([])
|
||||
const loading = ref(false)
|
||||
let otherAlbums = ref<Album[]>()
|
||||
let info = ref<ArtistInfo | null>()
|
||||
let otherAlbums = ref<Album[] | undefined>()
|
||||
let info = ref<ArtistInfo | undefined>()
|
||||
|
||||
const {
|
||||
SongList,
|
||||
|
@ -126,13 +126,14 @@ const {
|
|||
showingControls,
|
||||
isPhone,
|
||||
duration,
|
||||
context,
|
||||
sort,
|
||||
onPressEnter,
|
||||
playAll,
|
||||
playSelected,
|
||||
applyFilter,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(songs)
|
||||
} = useSongList(songs, { type: 'Album' })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Album')
|
||||
|
||||
|
@ -169,6 +170,8 @@ watch(albumId, async id => {
|
|||
songStore.fetchForAlbum(id)
|
||||
])
|
||||
|
||||
context.entity = album.value
|
||||
|
||||
sort('track')
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
|
|
|
@ -87,7 +87,7 @@ const {
|
|||
onPressEnter,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(toRef(songStore.state, 'songs'), { sortable: true })
|
||||
} = useSongList(toRef(songStore.state, 'songs'), { type: 'Songs' }, { sortable: true })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Songs')
|
||||
|
||||
|
|
|
@ -121,6 +121,7 @@ const {
|
|||
songList,
|
||||
showingControls,
|
||||
isPhone,
|
||||
context,
|
||||
duration,
|
||||
sort,
|
||||
onPressEnter,
|
||||
|
@ -128,7 +129,7 @@ const {
|
|||
playSelected,
|
||||
applyFilter,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(songs)
|
||||
} = useSongList(songs, { type: 'Artist' })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Artist')
|
||||
|
||||
|
@ -157,6 +158,8 @@ watch(artistId, async id => {
|
|||
artistStore.resolve(id),
|
||||
songStore.fetchForArtist(id)
|
||||
])
|
||||
|
||||
context.entity = artist.value
|
||||
} catch (error) {
|
||||
logger.error(error)
|
||||
showErrorDialog('Failed to load artist. Please try again.', 'Error')
|
||||
|
|
|
@ -89,7 +89,7 @@ const {
|
|||
applyFilter,
|
||||
onScrollBreakpoint,
|
||||
sort
|
||||
} = useSongList(toRef(favoriteStore.state, 'songs'))
|
||||
} = useSongList(toRef(favoriteStore.state, 'songs'), { type: 'Favorites' })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Favorites')
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@ const {
|
|||
onPressEnter,
|
||||
playSelected,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(ref<Song[]>([]))
|
||||
} = useSongList(ref<Song[]>([]), { type: 'Genre' })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Genre')
|
||||
|
||||
|
|
|
@ -101,6 +101,7 @@ const {
|
|||
selectedSongs,
|
||||
showingControls,
|
||||
isPhone,
|
||||
context,
|
||||
sortField,
|
||||
onPressEnter,
|
||||
playAll,
|
||||
|
@ -109,7 +110,7 @@ const {
|
|||
onScrollBreakpoint,
|
||||
sort: baseSort,
|
||||
config: listConfig
|
||||
} = useSongList(ref<Song[] | CollaborativeSong[]>([]))
|
||||
} = useSongList(ref<Song[] | CollaborativeSong[]>([]), { type: 'Playlist' })
|
||||
|
||||
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
||||
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
||||
|
@ -165,6 +166,7 @@ watch(playlistId, async id => {
|
|||
sortField.value = null
|
||||
|
||||
playlist.value = playlistStore.byId(id)
|
||||
context.entity = playlist.value
|
||||
|
||||
// reset this config value to its default to not cause rows to be mal-rendered
|
||||
listConfig.collaborative = false
|
||||
|
|
|
@ -79,7 +79,7 @@ const {
|
|||
playSelected,
|
||||
applyFilter,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(toRef(queueStore.state, 'songs'), { reorderable: true, sortable: false })
|
||||
} = useSongList(toRef(queueStore.state, 'songs'), { type: 'Queue' }, { reorderable: true, sortable: false })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Queue')
|
||||
|
||||
|
|
|
@ -67,7 +67,7 @@ const {
|
|||
playSelected,
|
||||
applyFilter,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(recentlyPlayedSongs, { sortable: false })
|
||||
} = useSongList(recentlyPlayedSongs, { type: 'RecentlyPlayed' }, { sortable: false })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('RecentlyPlayed')
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ const {
|
|||
applyFilter,
|
||||
sort,
|
||||
onScrollBreakpoint
|
||||
} = useSongList(toRef(searchStore.state, 'songs'))
|
||||
} = useSongList(toRef(searchStore.state, 'songs'), { type: 'Search.Songs' })
|
||||
|
||||
const { SongListControls, config } = useSongListControls('Search.Songs')
|
||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||
|
|
|
@ -3,7 +3,14 @@ import { expect, it } from 'vitest'
|
|||
import factory from '@/__tests__/factory'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import { arrayify } from '@/utils'
|
||||
import { SelectedSongsKey, SongListConfigKey, SongListSortFieldKey, SongListSortOrderKey, SongsKey } from '@/symbols'
|
||||
import {
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListContextKey,
|
||||
SongListSortFieldKey,
|
||||
SongListSortOrderKey,
|
||||
SongsKey
|
||||
} from '@/symbols'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import SongList from './SongList.vue'
|
||||
|
||||
|
@ -16,6 +23,9 @@ new class extends UnitTestCase {
|
|||
sortable: true,
|
||||
reorderable: true
|
||||
},
|
||||
context: SongListContext = {
|
||||
type: 'Album',
|
||||
},
|
||||
selectedSongs: Song[] = [],
|
||||
sortField: SongListSortField = 'title',
|
||||
sortOrder: SortOrder = 'asc'
|
||||
|
@ -38,10 +48,11 @@ new class extends UnitTestCase {
|
|||
},
|
||||
provide: {
|
||||
[<symbol>SongsKey]: [ref(songs)],
|
||||
[<symbol>SelectedSongsKey]: [ref(selectedSongs), value => (selectedSongs = value)],
|
||||
[<symbol>SelectedSongsKey]: [ref(selectedSongs), (value: Song[]) => (selectedSongs = value)],
|
||||
[<symbol>SongListConfigKey]: [config],
|
||||
[<symbol>SongListSortFieldKey]: [sortFieldRef, value => (sortFieldRef.value = value)],
|
||||
[<symbol>SongListSortOrderKey]: [sortOrderRef, value => (sortOrderRef.value = value)]
|
||||
[<symbol>SongListContextKey]: [context],
|
||||
[<symbol>SongListSortFieldKey]: [sortFieldRef, (value: SongListSortField) => (sortFieldRef.value = value)],
|
||||
[<symbol>SongListSortOrderKey]: [sortOrderRef, (value: SortOrder) => (sortOrderRef.value = value)]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
|
@ -90,11 +90,11 @@
|
|||
@click="onClick(item, $event)"
|
||||
@dragleave="onDragLeave"
|
||||
@dragstart="onDragStart(item, $event)"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragOver"
|
||||
@drop.prevent="onDrop(item, $event)"
|
||||
@dragend.prevent="onDragEnd"
|
||||
@contextmenu.prevent="openContextMenu(item, $event)"
|
||||
@play="onSongPlay(item.song)"
|
||||
/>
|
||||
</VirtualScroller>
|
||||
</div>
|
||||
|
@ -106,10 +106,13 @@ import isMobile from 'ismobilejs'
|
|||
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, nextTick, onMounted, Ref, ref, watch } from 'vue'
|
||||
import { eventBus, requireInjection } from '@/utils'
|
||||
import { preferenceStore, queueStore } from '@/stores'
|
||||
import { useDraggable, useDroppable } from '@/composables'
|
||||
import { playbackService } from '@/services'
|
||||
import {
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListContextKey,
|
||||
SongListFilterKeywordsKey,
|
||||
SongListSortFieldKey,
|
||||
SongListSortOrderKey,
|
||||
|
@ -137,6 +140,7 @@ const [selectedSongs, setSelectedSongs] = requireInjection<[Ref<Song[]>, Closure
|
|||
const [sortField, setSortField] = requireInjection<[Ref<SongListSortField>, Closure]>(SongListSortFieldKey)
|
||||
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
||||
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
|
||||
const [context] = requireInjection<[SongListContext]>(SongListContextKey)
|
||||
|
||||
const filterKeywords = requireInjection(SongListFilterKeywordsKey, ref(''))
|
||||
|
||||
|
@ -145,6 +149,12 @@ const lastSelectedRow = ref<SongRow>()
|
|||
const sortFields = ref<SongListSortField[]>([])
|
||||
const songRows = ref<SongRow[]>([])
|
||||
|
||||
const shouldTriggerContinuousPlayback = computed(() => {
|
||||
return preferenceStore.continuous_playback
|
||||
&& typeof context.type !== 'undefined'
|
||||
&& ['Playlist', 'Album', 'Artist', 'Genre'].includes(context.type)
|
||||
})
|
||||
|
||||
watch(
|
||||
songRows,
|
||||
() => setSelectedSongs(songRows.value.filter(({ selected }) => selected).map(({ song }) => song)),
|
||||
|
@ -293,7 +303,6 @@ const onDragOver = throttle((event: DragEvent) => {
|
|||
if (!config.reorderable) return
|
||||
|
||||
if (acceptsDrop(event)) {
|
||||
// console.log(event)
|
||||
const target = event.target as HTMLElement
|
||||
const rect = target.getBoundingClientRect()
|
||||
const midPoint = rect.top + rect.height / 2
|
||||
|
@ -304,17 +313,6 @@ const onDragOver = throttle((event: DragEvent) => {
|
|||
return false
|
||||
}, 50)
|
||||
|
||||
const onDragEnter = (event: DragEvent) => {
|
||||
if (!config.reorderable) return
|
||||
|
||||
if (acceptsDrop(event)) {
|
||||
|
||||
// ;(event.target as HTMLElement).closest('.song-item')?.classList.add('droppable')
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const onDrop = (row: SongRow, event: DragEvent) => {
|
||||
if (!config.reorderable || !getDroppedData(event) || !selectedSongs.value.length) {
|
||||
wrapper.value?.classList.remove('dragging')
|
||||
|
@ -365,6 +363,16 @@ const openContextMenu = async (row: SongRow, event: MouseEvent) => {
|
|||
eventBus.emit('SONG_CONTEXT_MENU_REQUESTED', event, selectedSongs.value)
|
||||
}
|
||||
|
||||
const onSongPlay = (song: Song) => {
|
||||
if (shouldTriggerContinuousPlayback.value) {
|
||||
queueStore.replaceQueueWith(getAllSongsWithSort())
|
||||
} else {
|
||||
queueStore.queueIfNotQueued(song)
|
||||
}
|
||||
|
||||
playbackService.play(song)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getAllSongsWithSort
|
||||
})
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
import { expect, it } from 'vitest'
|
||||
import factory from '@/__tests__/factory'
|
||||
import { queueStore } from '@/stores'
|
||||
import { playbackService } from '@/services'
|
||||
import { screen } from '@testing-library/vue'
|
||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||
import SongListItem from './SongListItem.vue'
|
||||
|
@ -41,15 +39,10 @@ new class extends UnitTestCase {
|
|||
expect(html()).toMatchSnapshot()
|
||||
})
|
||||
|
||||
it('plays on double click', async () => {
|
||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
||||
const playMock = this.mock(playbackService, 'play')
|
||||
this.renderComponent()
|
||||
|
||||
it('emits play event on double click', async () => {
|
||||
const { emitted } = this.renderComponent()
|
||||
await this.user.dblClick(screen.getByTestId('song-item'))
|
||||
|
||||
expect(queueMock).toHaveBeenCalledWith(row.song)
|
||||
expect(playMock).toHaveBeenCalledWith(row.song)
|
||||
expect(emitted().play).toBeTruthy()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -41,8 +41,6 @@
|
|||
<script lang="ts" setup>
|
||||
import { faSquareUpRight } from '@fortawesome/free-solid-svg-icons'
|
||||
import { computed, toRefs } from 'vue'
|
||||
import { playbackService } from '@/services'
|
||||
import { queueStore } from '@/stores'
|
||||
import { requireInjection, secondsToHis } from '@/utils'
|
||||
import { useAuthorization, useKoelPlus } from '@/composables'
|
||||
import { SongListConfigKey } from '@/symbols'
|
||||
|
@ -60,19 +58,16 @@ const { isPlus } = useKoelPlus()
|
|||
const props = defineProps<{ item: SongRow }>()
|
||||
const { item } = toRefs(props)
|
||||
|
||||
const emit = defineEmits<{ (e: 'play', song: Song): void }>()
|
||||
|
||||
const song = computed<Song | CollaborativeSong>(() => item.value.song)
|
||||
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
||||
const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
|
||||
const fmtLength = secondsToHis(song.value.length)
|
||||
|
||||
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => {
|
||||
return (song.value as CollaborativeSong).collaboration.user;
|
||||
})
|
||||
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => (song.value as CollaborativeSong).collaboration.user)
|
||||
|
||||
const play = () => {
|
||||
queueStore.queueIfNotQueued(song.value)
|
||||
playbackService.play(song.value)
|
||||
}
|
||||
const play = () => emit('play', song.value)
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useRouter } from '@/composables'
|
|||
import {
|
||||
SelectedSongsKey,
|
||||
SongListConfigKey,
|
||||
SongListContextKey,
|
||||
SongListFilterKeywordsKey,
|
||||
SongListSortFieldKey,
|
||||
SongListSortOrderKey,
|
||||
|
@ -21,10 +22,12 @@ import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
|||
|
||||
export const useSongList = (
|
||||
songs: Ref<Song[]>,
|
||||
context: SongListContext = {},
|
||||
config: Partial<SongListConfig> = { sortable: true, reorderable: false, collaborative: false, hasCustomSort: false }
|
||||
) => {
|
||||
const filterKeywords = ref('')
|
||||
config = reactive(config)
|
||||
context = reactive(context)
|
||||
const { isCurrentScreen, go } = useRouter()
|
||||
|
||||
const songList = ref<InstanceType<typeof SongList>>()
|
||||
|
@ -111,6 +114,7 @@ export const useSongList = (
|
|||
provideReadonly(SongsKey, songs, false)
|
||||
provideReadonly(SelectedSongsKey, selectedSongs, false)
|
||||
provideReadonly(SongListConfigKey, config)
|
||||
provideReadonly(SongListContextKey, context)
|
||||
provideReadonly(SongListSortFieldKey, sortField)
|
||||
provideReadonly(SongListSortOrderKey, sortOrder)
|
||||
|
||||
|
@ -122,6 +126,7 @@ export const useSongList = (
|
|||
ThumbnailStack,
|
||||
songs,
|
||||
config,
|
||||
context,
|
||||
headerLayout,
|
||||
sortField,
|
||||
sortOrder,
|
||||
|
|
|
@ -20,7 +20,8 @@ export const defaultPreferences: UserPreferences = {
|
|||
theme: null,
|
||||
visualizer: 'default',
|
||||
active_extra_panel_tab: null,
|
||||
make_uploads_public: false
|
||||
make_uploads_public: false,
|
||||
continuous_playback: false
|
||||
}
|
||||
|
||||
const preferenceStore = {
|
||||
|
|
|
@ -18,5 +18,6 @@ export const SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> =
|
|||
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
|
||||
export const SongListSortOrderKey: ReadonlyInjectionKey<Ref<SortOrder>> = Symbol('SongListSortOrder')
|
||||
export const SongListFilterKeywordsKey: InjectionKey<Ref<string>> = Symbol('SongListFilterKeywords')
|
||||
export const SongListContextKey: InjectionKey<Ref<SongListContext>> = Symbol('SongListContext')
|
||||
|
||||
export const ModalContextKey: InjectionKey<Ref<Record<string, any>>> = Symbol('ModalContext')
|
||||
|
|
6
resources/assets/js/types.d.ts
vendored
6
resources/assets/js/types.d.ts
vendored
|
@ -268,6 +268,7 @@ interface UserPreferences extends Record<string, any> {
|
|||
show_now_playing_notification: boolean
|
||||
repeat_mode: RepeatMode
|
||||
confirm_before_closing: boolean
|
||||
continuous_playback: boolean
|
||||
equalizer: EqualizerPreset,
|
||||
artists_view_mode: ArtistAlbumViewMode | null,
|
||||
albums_view_mode: ArtistAlbumViewMode | null,
|
||||
|
@ -406,6 +407,11 @@ interface SongListConfig {
|
|||
hasCustomSort: boolean
|
||||
}
|
||||
|
||||
type SongListContext = {
|
||||
entity?: Playlist | Album | Artist | Genre,
|
||||
type?: Extract<ScreenName, 'Songs' | 'Album' | 'Artist' | 'Playlist' | 'Favorites' | 'RecentlyPlayed' | 'Queue' | 'Genre' | 'Search.Songs'>
|
||||
}
|
||||
|
||||
type SongListSortField =
|
||||
keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>
|
||||
| 'position'
|
||||
|
|
Loading…
Reference in a new issue