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',
|
'show_album_art_overlay' => 'boolean',
|
||||||
'lyrics_zoom_level' => 'integer',
|
'lyrics_zoom_level' => 'integer',
|
||||||
'make_uploads_public' => 'boolean',
|
'make_uploads_public' => 'boolean',
|
||||||
|
'continuous_playback' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const CUSTOMIZABLE_KEYS = [
|
private const CUSTOMIZABLE_KEYS = [
|
||||||
|
@ -34,6 +35,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
||||||
'lyrics_zoom_level',
|
'lyrics_zoom_level',
|
||||||
'visualizer',
|
'visualizer',
|
||||||
'active_extra_panel_tab',
|
'active_extra_panel_tab',
|
||||||
|
'continuous_playback',
|
||||||
];
|
];
|
||||||
|
|
||||||
private const ALL_KEYS = self::CUSTOMIZABLE_KEYS + ['lastfm_session_key'];
|
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 $showAlbumArtOverlay,
|
||||||
public bool $makeUploadsPublic,
|
public bool $makeUploadsPublic,
|
||||||
public bool $supportBarNoBugging,
|
public bool $supportBarNoBugging,
|
||||||
|
public bool $continuousPlayback,
|
||||||
public int $lyricsZoomLevel,
|
public int $lyricsZoomLevel,
|
||||||
public string $visualizer,
|
public string $visualizer,
|
||||||
public ?string $activeExtraPanelTab,
|
public ?string $activeExtraPanelTab,
|
||||||
|
@ -77,6 +80,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
||||||
showAlbumArtOverlay: $data['show_album_art_overlay'] ?? true,
|
showAlbumArtOverlay: $data['show_album_art_overlay'] ?? true,
|
||||||
makeUploadsPublic: $data['make_uploads_public'] ?? false,
|
makeUploadsPublic: $data['make_uploads_public'] ?? false,
|
||||||
supportBarNoBugging: $data['support_bar_no_bugging'] ?? false,
|
supportBarNoBugging: $data['support_bar_no_bugging'] ?? false,
|
||||||
|
continuousPlayback: $data['continuous_playback'] ?? false,
|
||||||
lyricsZoomLevel: $data['lyrics_zoom_level'] ?? 1,
|
lyricsZoomLevel: $data['lyrics_zoom_level'] ?? 1,
|
||||||
visualizer: $data['visualizer'] ?? 'default',
|
visualizer: $data['visualizer'] ?? 'default',
|
||||||
activeExtraPanelTab: $data['active_extra_panel_tab'] ?? null,
|
activeExtraPanelTab: $data['active_extra_panel_tab'] ?? null,
|
||||||
|
@ -133,6 +137,7 @@ final class UserPreferences implements Arrayable, JsonSerializable
|
||||||
'lyrics_zoom_level' => $this->lyricsZoomLevel,
|
'lyrics_zoom_level' => $this->lyricsZoomLevel,
|
||||||
'visualizer' => $this->visualizer,
|
'visualizer' => $this->visualizer,
|
||||||
'active_extra_panel_tab' => $this->activeExtraPanelTab,
|
'active_extra_panel_tab' => $this->activeExtraPanelTab,
|
||||||
|
'continuous_playback' => $this->continuousPlayback,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -19,15 +19,20 @@ composer install
|
||||||
yarn 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
|
```bash
|
||||||
yarn dev
|
$ yarn dev
|
||||||
$ php artisan serve
|
|
||||||
|
|
||||||
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.
|
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:
|
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 show a notification whenever a new song starts playing
|
||||||
* Whether to confirm before closing Koel’s browser tab
|
* Whether to confirm before closing Koel’s browser tab
|
||||||
* Whether to show a translucent, blurred overlay of the current album’s art
|
* Whether to show a translucent, blurred overlay of the current album’s art
|
||||||
|
|
|
@ -6,6 +6,12 @@
|
||||||
Make uploaded songs public by default
|
Make uploaded songs public by default
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
<div v-if="isPhone" class="form-row">
|
||||||
<label>
|
<label>
|
||||||
<CheckBox v-model="preferences.show_now_playing_notification" name="notify" />
|
<CheckBox v-model="preferences.show_now_playing_notification" name="notify" />
|
||||||
|
|
|
@ -112,11 +112,11 @@ const { showErrorDialog } = useDialogBox()
|
||||||
const { getRouteParam, go, onScreenActivated } = useRouter()
|
const { getRouteParam, go, onScreenActivated } = useRouter()
|
||||||
|
|
||||||
const albumId = ref<number>()
|
const albumId = ref<number>()
|
||||||
const album = ref<Album>()
|
const album = ref<Album | undefined>()
|
||||||
const songs = ref<Song[]>([])
|
const songs = ref<Song[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
let otherAlbums = ref<Album[]>()
|
let otherAlbums = ref<Album[] | undefined>()
|
||||||
let info = ref<ArtistInfo | null>()
|
let info = ref<ArtistInfo | undefined>()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
SongList,
|
SongList,
|
||||||
|
@ -126,13 +126,14 @@ const {
|
||||||
showingControls,
|
showingControls,
|
||||||
isPhone,
|
isPhone,
|
||||||
duration,
|
duration,
|
||||||
|
context,
|
||||||
sort,
|
sort,
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
playAll,
|
playAll,
|
||||||
playSelected,
|
playSelected,
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(songs)
|
} = useSongList(songs, { type: 'Album' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Album')
|
const { SongListControls, config } = useSongListControls('Album')
|
||||||
|
|
||||||
|
@ -169,6 +170,8 @@ watch(albumId, async id => {
|
||||||
songStore.fetchForAlbum(id)
|
songStore.fetchForAlbum(id)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
context.entity = album.value
|
||||||
|
|
||||||
sort('track')
|
sort('track')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
|
|
|
@ -87,7 +87,7 @@ const {
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
playSelected,
|
playSelected,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(toRef(songStore.state, 'songs'), { sortable: true })
|
} = useSongList(toRef(songStore.state, 'songs'), { type: 'Songs' }, { sortable: true })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Songs')
|
const { SongListControls, config } = useSongListControls('Songs')
|
||||||
|
|
||||||
|
|
|
@ -121,6 +121,7 @@ const {
|
||||||
songList,
|
songList,
|
||||||
showingControls,
|
showingControls,
|
||||||
isPhone,
|
isPhone,
|
||||||
|
context,
|
||||||
duration,
|
duration,
|
||||||
sort,
|
sort,
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
|
@ -128,7 +129,7 @@ const {
|
||||||
playSelected,
|
playSelected,
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(songs)
|
} = useSongList(songs, { type: 'Artist' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Artist')
|
const { SongListControls, config } = useSongListControls('Artist')
|
||||||
|
|
||||||
|
@ -157,6 +158,8 @@ watch(artistId, async id => {
|
||||||
artistStore.resolve(id),
|
artistStore.resolve(id),
|
||||||
songStore.fetchForArtist(id)
|
songStore.fetchForArtist(id)
|
||||||
])
|
])
|
||||||
|
|
||||||
|
context.entity = artist.value
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error)
|
logger.error(error)
|
||||||
showErrorDialog('Failed to load artist. Please try again.', 'Error')
|
showErrorDialog('Failed to load artist. Please try again.', 'Error')
|
||||||
|
|
|
@ -89,7 +89,7 @@ const {
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint,
|
onScrollBreakpoint,
|
||||||
sort
|
sort
|
||||||
} = useSongList(toRef(favoriteStore.state, 'songs'))
|
} = useSongList(toRef(favoriteStore.state, 'songs'), { type: 'Favorites' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Favorites')
|
const { SongListControls, config } = useSongListControls('Favorites')
|
||||||
|
|
||||||
|
|
|
@ -70,7 +70,7 @@ const {
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
playSelected,
|
playSelected,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(ref<Song[]>([]))
|
} = useSongList(ref<Song[]>([]), { type: 'Genre' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Genre')
|
const { SongListControls, config } = useSongListControls('Genre')
|
||||||
|
|
||||||
|
|
|
@ -101,6 +101,7 @@ const {
|
||||||
selectedSongs,
|
selectedSongs,
|
||||||
showingControls,
|
showingControls,
|
||||||
isPhone,
|
isPhone,
|
||||||
|
context,
|
||||||
sortField,
|
sortField,
|
||||||
onPressEnter,
|
onPressEnter,
|
||||||
playAll,
|
playAll,
|
||||||
|
@ -109,7 +110,7 @@ const {
|
||||||
onScrollBreakpoint,
|
onScrollBreakpoint,
|
||||||
sort: baseSort,
|
sort: baseSort,
|
||||||
config: listConfig
|
config: listConfig
|
||||||
} = useSongList(ref<Song[] | CollaborativeSong[]>([]))
|
} = useSongList(ref<Song[] | CollaborativeSong[]>([]), { type: 'Playlist' })
|
||||||
|
|
||||||
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
const { SongListControls, config: controlsConfig } = useSongListControls('Playlist')
|
||||||
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
const { removeSongsFromPlaylist } = usePlaylistManagement()
|
||||||
|
@ -165,6 +166,7 @@ watch(playlistId, async id => {
|
||||||
sortField.value = null
|
sortField.value = null
|
||||||
|
|
||||||
playlist.value = playlistStore.byId(id)
|
playlist.value = playlistStore.byId(id)
|
||||||
|
context.entity = playlist.value
|
||||||
|
|
||||||
// reset this config value to its default to not cause rows to be mal-rendered
|
// reset this config value to its default to not cause rows to be mal-rendered
|
||||||
listConfig.collaborative = false
|
listConfig.collaborative = false
|
||||||
|
|
|
@ -79,7 +79,7 @@ const {
|
||||||
playSelected,
|
playSelected,
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint
|
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')
|
const { SongListControls, config } = useSongListControls('Queue')
|
||||||
|
|
||||||
|
|
|
@ -67,7 +67,7 @@ const {
|
||||||
playSelected,
|
playSelected,
|
||||||
applyFilter,
|
applyFilter,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(recentlyPlayedSongs, { sortable: false })
|
} = useSongList(recentlyPlayedSongs, { type: 'RecentlyPlayed' }, { sortable: false })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('RecentlyPlayed')
|
const { SongListControls, config } = useSongListControls('RecentlyPlayed')
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ const {
|
||||||
applyFilter,
|
applyFilter,
|
||||||
sort,
|
sort,
|
||||||
onScrollBreakpoint
|
onScrollBreakpoint
|
||||||
} = useSongList(toRef(searchStore.state, 'songs'))
|
} = useSongList(toRef(searchStore.state, 'songs'), { type: 'Search.Songs' })
|
||||||
|
|
||||||
const { SongListControls, config } = useSongListControls('Search.Songs')
|
const { SongListControls, config } = useSongListControls('Search.Songs')
|
||||||
const decodedQ = computed(() => decodeURIComponent(q.value))
|
const decodedQ = computed(() => decodeURIComponent(q.value))
|
||||||
|
|
|
@ -3,7 +3,14 @@ import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import { arrayify } from '@/utils'
|
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 { screen } from '@testing-library/vue'
|
||||||
import SongList from './SongList.vue'
|
import SongList from './SongList.vue'
|
||||||
|
|
||||||
|
@ -16,6 +23,9 @@ new class extends UnitTestCase {
|
||||||
sortable: true,
|
sortable: true,
|
||||||
reorderable: true
|
reorderable: true
|
||||||
},
|
},
|
||||||
|
context: SongListContext = {
|
||||||
|
type: 'Album',
|
||||||
|
},
|
||||||
selectedSongs: Song[] = [],
|
selectedSongs: Song[] = [],
|
||||||
sortField: SongListSortField = 'title',
|
sortField: SongListSortField = 'title',
|
||||||
sortOrder: SortOrder = 'asc'
|
sortOrder: SortOrder = 'asc'
|
||||||
|
@ -38,10 +48,11 @@ new class extends UnitTestCase {
|
||||||
},
|
},
|
||||||
provide: {
|
provide: {
|
||||||
[<symbol>SongsKey]: [ref(songs)],
|
[<symbol>SongsKey]: [ref(songs)],
|
||||||
[<symbol>SelectedSongsKey]: [ref(selectedSongs), value => (selectedSongs = value)],
|
[<symbol>SelectedSongsKey]: [ref(selectedSongs), (value: Song[]) => (selectedSongs = value)],
|
||||||
[<symbol>SongListConfigKey]: [config],
|
[<symbol>SongListConfigKey]: [config],
|
||||||
[<symbol>SongListSortFieldKey]: [sortFieldRef, value => (sortFieldRef.value = value)],
|
[<symbol>SongListContextKey]: [context],
|
||||||
[<symbol>SongListSortOrderKey]: [sortOrderRef, value => (sortOrderRef.value = value)]
|
[<symbol>SongListSortFieldKey]: [sortFieldRef, (value: SongListSortField) => (sortFieldRef.value = value)],
|
||||||
|
[<symbol>SongListSortOrderKey]: [sortOrderRef, (value: SortOrder) => (sortOrderRef.value = value)]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
@ -90,11 +90,11 @@
|
||||||
@click="onClick(item, $event)"
|
@click="onClick(item, $event)"
|
||||||
@dragleave="onDragLeave"
|
@dragleave="onDragLeave"
|
||||||
@dragstart="onDragStart(item, $event)"
|
@dragstart="onDragStart(item, $event)"
|
||||||
@dragenter.prevent="onDragEnter"
|
|
||||||
@dragover.prevent="onDragOver"
|
@dragover.prevent="onDragOver"
|
||||||
@drop.prevent="onDrop(item, $event)"
|
@drop.prevent="onDrop(item, $event)"
|
||||||
@dragend.prevent="onDragEnd"
|
@dragend.prevent="onDragEnd"
|
||||||
@contextmenu.prevent="openContextMenu(item, $event)"
|
@contextmenu.prevent="openContextMenu(item, $event)"
|
||||||
|
@play="onSongPlay(item.song)"
|
||||||
/>
|
/>
|
||||||
</VirtualScroller>
|
</VirtualScroller>
|
||||||
</div>
|
</div>
|
||||||
|
@ -106,10 +106,13 @@ import isMobile from 'ismobilejs'
|
||||||
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
import { faCaretDown, faCaretUp } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, nextTick, onMounted, Ref, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, Ref, ref, watch } from 'vue'
|
||||||
import { eventBus, requireInjection } from '@/utils'
|
import { eventBus, requireInjection } from '@/utils'
|
||||||
|
import { preferenceStore, queueStore } from '@/stores'
|
||||||
import { useDraggable, useDroppable } from '@/composables'
|
import { useDraggable, useDroppable } from '@/composables'
|
||||||
|
import { playbackService } from '@/services'
|
||||||
import {
|
import {
|
||||||
SelectedSongsKey,
|
SelectedSongsKey,
|
||||||
SongListConfigKey,
|
SongListConfigKey,
|
||||||
|
SongListContextKey,
|
||||||
SongListFilterKeywordsKey,
|
SongListFilterKeywordsKey,
|
||||||
SongListSortFieldKey,
|
SongListSortFieldKey,
|
||||||
SongListSortOrderKey,
|
SongListSortOrderKey,
|
||||||
|
@ -137,6 +140,7 @@ const [selectedSongs, setSelectedSongs] = requireInjection<[Ref<Song[]>, Closure
|
||||||
const [sortField, setSortField] = requireInjection<[Ref<SongListSortField>, Closure]>(SongListSortFieldKey)
|
const [sortField, setSortField] = requireInjection<[Ref<SongListSortField>, Closure]>(SongListSortFieldKey)
|
||||||
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
const [sortOrder, setSortOrder] = requireInjection<[Ref<SortOrder>, Closure]>(SongListSortOrderKey)
|
||||||
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
|
const [config] = requireInjection<[Partial<SongListConfig>]>(SongListConfigKey, [{}])
|
||||||
|
const [context] = requireInjection<[SongListContext]>(SongListContextKey)
|
||||||
|
|
||||||
const filterKeywords = requireInjection(SongListFilterKeywordsKey, ref(''))
|
const filterKeywords = requireInjection(SongListFilterKeywordsKey, ref(''))
|
||||||
|
|
||||||
|
@ -145,6 +149,12 @@ const lastSelectedRow = ref<SongRow>()
|
||||||
const sortFields = ref<SongListSortField[]>([])
|
const sortFields = ref<SongListSortField[]>([])
|
||||||
const songRows = ref<SongRow[]>([])
|
const songRows = ref<SongRow[]>([])
|
||||||
|
|
||||||
|
const shouldTriggerContinuousPlayback = computed(() => {
|
||||||
|
return preferenceStore.continuous_playback
|
||||||
|
&& typeof context.type !== 'undefined'
|
||||||
|
&& ['Playlist', 'Album', 'Artist', 'Genre'].includes(context.type)
|
||||||
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
songRows,
|
songRows,
|
||||||
() => setSelectedSongs(songRows.value.filter(({ selected }) => selected).map(({ song }) => song)),
|
() => setSelectedSongs(songRows.value.filter(({ selected }) => selected).map(({ song }) => song)),
|
||||||
|
@ -293,7 +303,6 @@ const onDragOver = throttle((event: DragEvent) => {
|
||||||
if (!config.reorderable) return
|
if (!config.reorderable) return
|
||||||
|
|
||||||
if (acceptsDrop(event)) {
|
if (acceptsDrop(event)) {
|
||||||
// console.log(event)
|
|
||||||
const target = event.target as HTMLElement
|
const target = event.target as HTMLElement
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect()
|
||||||
const midPoint = rect.top + rect.height / 2
|
const midPoint = rect.top + rect.height / 2
|
||||||
|
@ -304,17 +313,6 @@ const onDragOver = throttle((event: DragEvent) => {
|
||||||
return false
|
return false
|
||||||
}, 50)
|
}, 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) => {
|
const onDrop = (row: SongRow, event: DragEvent) => {
|
||||||
if (!config.reorderable || !getDroppedData(event) || !selectedSongs.value.length) {
|
if (!config.reorderable || !getDroppedData(event) || !selectedSongs.value.length) {
|
||||||
wrapper.value?.classList.remove('dragging')
|
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)
|
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({
|
defineExpose({
|
||||||
getAllSongsWithSort
|
getAllSongsWithSort
|
||||||
})
|
})
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import { expect, it } from 'vitest'
|
import { expect, it } from 'vitest'
|
||||||
import factory from '@/__tests__/factory'
|
import factory from '@/__tests__/factory'
|
||||||
import { queueStore } from '@/stores'
|
|
||||||
import { playbackService } from '@/services'
|
|
||||||
import { screen } from '@testing-library/vue'
|
import { screen } from '@testing-library/vue'
|
||||||
import UnitTestCase from '@/__tests__/UnitTestCase'
|
import UnitTestCase from '@/__tests__/UnitTestCase'
|
||||||
import SongListItem from './SongListItem.vue'
|
import SongListItem from './SongListItem.vue'
|
||||||
|
@ -41,15 +39,10 @@ new class extends UnitTestCase {
|
||||||
expect(html()).toMatchSnapshot()
|
expect(html()).toMatchSnapshot()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('plays on double click', async () => {
|
it('emits play event on double click', async () => {
|
||||||
const queueMock = this.mock(queueStore, 'queueIfNotQueued')
|
const { emitted } = this.renderComponent()
|
||||||
const playMock = this.mock(playbackService, 'play')
|
|
||||||
this.renderComponent()
|
|
||||||
|
|
||||||
await this.user.dblClick(screen.getByTestId('song-item'))
|
await this.user.dblClick(screen.getByTestId('song-item'))
|
||||||
|
expect(emitted().play).toBeTruthy()
|
||||||
expect(queueMock).toHaveBeenCalledWith(row.song)
|
|
||||||
expect(playMock).toHaveBeenCalledWith(row.song)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -41,8 +41,6 @@
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { faSquareUpRight } from '@fortawesome/free-solid-svg-icons'
|
import { faSquareUpRight } from '@fortawesome/free-solid-svg-icons'
|
||||||
import { computed, toRefs } from 'vue'
|
import { computed, toRefs } from 'vue'
|
||||||
import { playbackService } from '@/services'
|
|
||||||
import { queueStore } from '@/stores'
|
|
||||||
import { requireInjection, secondsToHis } from '@/utils'
|
import { requireInjection, secondsToHis } from '@/utils'
|
||||||
import { useAuthorization, useKoelPlus } from '@/composables'
|
import { useAuthorization, useKoelPlus } from '@/composables'
|
||||||
import { SongListConfigKey } from '@/symbols'
|
import { SongListConfigKey } from '@/symbols'
|
||||||
|
@ -60,19 +58,16 @@ const { isPlus } = useKoelPlus()
|
||||||
const props = defineProps<{ item: SongRow }>()
|
const props = defineProps<{ item: SongRow }>()
|
||||||
const { item } = toRefs(props)
|
const { item } = toRefs(props)
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'play', song: Song): void }>()
|
||||||
|
|
||||||
const song = computed<Song | CollaborativeSong>(() => item.value.song)
|
const song = computed<Song | CollaborativeSong>(() => item.value.song)
|
||||||
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
const playing = computed(() => ['Playing', 'Paused'].includes(song.value.playback_state!))
|
||||||
const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
|
const external = computed(() => isPlus.value && song.value.owner_id !== currentUser.value?.id)
|
||||||
const fmtLength = secondsToHis(song.value.length)
|
const fmtLength = secondsToHis(song.value.length)
|
||||||
|
|
||||||
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => {
|
const collaborator = computed<Pick<User, 'name' | 'avatar'>>(() => (song.value as CollaborativeSong).collaboration.user)
|
||||||
return (song.value as CollaborativeSong).collaboration.user;
|
|
||||||
})
|
|
||||||
|
|
||||||
const play = () => {
|
const play = () => emit('play', song.value)
|
||||||
queueStore.queueIfNotQueued(song.value)
|
|
||||||
playbackService.play(song.value)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -9,6 +9,7 @@ import { useRouter } from '@/composables'
|
||||||
import {
|
import {
|
||||||
SelectedSongsKey,
|
SelectedSongsKey,
|
||||||
SongListConfigKey,
|
SongListConfigKey,
|
||||||
|
SongListContextKey,
|
||||||
SongListFilterKeywordsKey,
|
SongListFilterKeywordsKey,
|
||||||
SongListSortFieldKey,
|
SongListSortFieldKey,
|
||||||
SongListSortOrderKey,
|
SongListSortOrderKey,
|
||||||
|
@ -21,10 +22,12 @@ import ThumbnailStack from '@/components/ui/ThumbnailStack.vue'
|
||||||
|
|
||||||
export const useSongList = (
|
export const useSongList = (
|
||||||
songs: Ref<Song[]>,
|
songs: Ref<Song[]>,
|
||||||
|
context: SongListContext = {},
|
||||||
config: Partial<SongListConfig> = { sortable: true, reorderable: false, collaborative: false, hasCustomSort: false }
|
config: Partial<SongListConfig> = { sortable: true, reorderable: false, collaborative: false, hasCustomSort: false }
|
||||||
) => {
|
) => {
|
||||||
const filterKeywords = ref('')
|
const filterKeywords = ref('')
|
||||||
config = reactive(config)
|
config = reactive(config)
|
||||||
|
context = reactive(context)
|
||||||
const { isCurrentScreen, go } = useRouter()
|
const { isCurrentScreen, go } = useRouter()
|
||||||
|
|
||||||
const songList = ref<InstanceType<typeof SongList>>()
|
const songList = ref<InstanceType<typeof SongList>>()
|
||||||
|
@ -111,6 +114,7 @@ export const useSongList = (
|
||||||
provideReadonly(SongsKey, songs, false)
|
provideReadonly(SongsKey, songs, false)
|
||||||
provideReadonly(SelectedSongsKey, selectedSongs, false)
|
provideReadonly(SelectedSongsKey, selectedSongs, false)
|
||||||
provideReadonly(SongListConfigKey, config)
|
provideReadonly(SongListConfigKey, config)
|
||||||
|
provideReadonly(SongListContextKey, context)
|
||||||
provideReadonly(SongListSortFieldKey, sortField)
|
provideReadonly(SongListSortFieldKey, sortField)
|
||||||
provideReadonly(SongListSortOrderKey, sortOrder)
|
provideReadonly(SongListSortOrderKey, sortOrder)
|
||||||
|
|
||||||
|
@ -122,6 +126,7 @@ export const useSongList = (
|
||||||
ThumbnailStack,
|
ThumbnailStack,
|
||||||
songs,
|
songs,
|
||||||
config,
|
config,
|
||||||
|
context,
|
||||||
headerLayout,
|
headerLayout,
|
||||||
sortField,
|
sortField,
|
||||||
sortOrder,
|
sortOrder,
|
||||||
|
|
|
@ -20,7 +20,8 @@ export const defaultPreferences: UserPreferences = {
|
||||||
theme: null,
|
theme: null,
|
||||||
visualizer: 'default',
|
visualizer: 'default',
|
||||||
active_extra_panel_tab: null,
|
active_extra_panel_tab: null,
|
||||||
make_uploads_public: false
|
make_uploads_public: false,
|
||||||
|
continuous_playback: false
|
||||||
}
|
}
|
||||||
|
|
||||||
const preferenceStore = {
|
const preferenceStore = {
|
||||||
|
|
|
@ -18,5 +18,6 @@ export const SongListConfigKey: ReadonlyInjectionKey<Partial<SongListConfig>> =
|
||||||
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
|
export const SongListSortFieldKey: ReadonlyInjectionKey<Ref<SongListSortField>> = Symbol('SongListSortField')
|
||||||
export const SongListSortOrderKey: ReadonlyInjectionKey<Ref<SortOrder>> = Symbol('SongListSortOrder')
|
export const SongListSortOrderKey: ReadonlyInjectionKey<Ref<SortOrder>> = Symbol('SongListSortOrder')
|
||||||
export const SongListFilterKeywordsKey: InjectionKey<Ref<string>> = Symbol('SongListFilterKeywords')
|
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')
|
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
|
show_now_playing_notification: boolean
|
||||||
repeat_mode: RepeatMode
|
repeat_mode: RepeatMode
|
||||||
confirm_before_closing: boolean
|
confirm_before_closing: boolean
|
||||||
|
continuous_playback: boolean
|
||||||
equalizer: EqualizerPreset,
|
equalizer: EqualizerPreset,
|
||||||
artists_view_mode: ArtistAlbumViewMode | null,
|
artists_view_mode: ArtistAlbumViewMode | null,
|
||||||
albums_view_mode: ArtistAlbumViewMode | null,
|
albums_view_mode: ArtistAlbumViewMode | null,
|
||||||
|
@ -406,6 +407,11 @@ interface SongListConfig {
|
||||||
hasCustomSort: boolean
|
hasCustomSort: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SongListContext = {
|
||||||
|
entity?: Playlist | Album | Artist | Genre,
|
||||||
|
type?: Extract<ScreenName, 'Songs' | 'Album' | 'Artist' | 'Playlist' | 'Favorites' | 'RecentlyPlayed' | 'Queue' | 'Genre' | 'Search.Songs'>
|
||||||
|
}
|
||||||
|
|
||||||
type SongListSortField =
|
type SongListSortField =
|
||||||
keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>
|
keyof Pick<Song, 'track' | 'disc' | 'title' | 'album_name' | 'length' | 'artist_name' | 'created_at'>
|
||||||
| 'position'
|
| 'position'
|
||||||
|
|
Loading…
Reference in a new issue