diff --git a/app/Values/UserPreferences.php b/app/Values/UserPreferences.php index 59380e02..5b54f1fa 100644 --- a/app/Values/UserPreferences.php +++ b/app/Values/UserPreferences.php @@ -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, ]; } diff --git a/docs/development.md b/docs/development.md index 995cfdd7..0cd92a16 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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. diff --git a/docs/usage/profile-preferences.md b/docs/usage/profile-preferences.md index a6d48af0..689e239d 100644 --- a/docs/usage/profile-preferences.md +++ b/docs/usage/profile-preferences.md @@ -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 diff --git a/resources/assets/js/components/profile-preferences/PreferencesForm.vue b/resources/assets/js/components/profile-preferences/PreferencesForm.vue index a569634c..9d87ba3a 100644 --- a/resources/assets/js/components/profile-preferences/PreferencesForm.vue +++ b/resources/assets/js/components/profile-preferences/PreferencesForm.vue @@ -6,6 +6,12 @@ Make uploaded songs public by default +
+ +
@@ -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, Closure const [sortField, setSortField] = requireInjection<[Ref, Closure]>(SongListSortFieldKey) const [sortOrder, setSortOrder] = requireInjection<[Ref, Closure]>(SongListSortOrderKey) const [config] = requireInjection<[Partial]>(SongListConfigKey, [{}]) +const [context] = requireInjection<[SongListContext]>(SongListContextKey) const filterKeywords = requireInjection(SongListFilterKeywordsKey, ref('')) @@ -145,6 +149,12 @@ const lastSelectedRow = ref() const sortFields = ref([]) const songRows = ref([]) +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 }) diff --git a/resources/assets/js/components/song/SongListItem.spec.ts b/resources/assets/js/components/song/SongListItem.spec.ts index b4078abf..f4f30b6e 100644 --- a/resources/assets/js/components/song/SongListItem.spec.ts +++ b/resources/assets/js/components/song/SongListItem.spec.ts @@ -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() }) } } diff --git a/resources/assets/js/components/song/SongListItem.vue b/resources/assets/js/components/song/SongListItem.vue index 9300ce03..3fd16a8e 100644 --- a/resources/assets/js/components/song/SongListItem.vue +++ b/resources/assets/js/components/song/SongListItem.vue @@ -41,8 +41,6 @@