feat: continuous playback

This commit is contained in:
Phan An 2024-03-25 23:59:38 +01:00
parent 67766b49c1
commit d3960ffe62
21 changed files with 99 additions and 54 deletions

View file

@ -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,
];
}

View file

@ -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.

View file

@ -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 Koels browser tab
* Whether to show a translucent, blurred overlay of the current albums art

View file

@ -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" />

View file

@ -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)

View file

@ -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')

View file

@ -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')

View file

@ -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')

View file

@ -70,7 +70,7 @@ const {
onPressEnter,
playSelected,
onScrollBreakpoint
} = useSongList(ref<Song[]>([]))
} = useSongList(ref<Song[]>([]), { type: 'Genre' })
const { SongListControls, config } = useSongListControls('Genre')

View file

@ -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

View file

@ -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')

View file

@ -67,7 +67,7 @@ const {
playSelected,
applyFilter,
onScrollBreakpoint
} = useSongList(recentlyPlayedSongs, { sortable: false })
} = useSongList(recentlyPlayedSongs, { type: 'RecentlyPlayed' }, { sortable: false })
const { SongListControls, config } = useSongListControls('RecentlyPlayed')

View file

@ -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))

View file

@ -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)]
}
}
})

View file

@ -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
})

View file

@ -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()
})
}
}

View file

@ -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">

View file

@ -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,

View file

@ -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 = {

View file

@ -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')

View file

@ -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'