Add onKeyboardShortcut composable

Fixes #1726
This commit is contained in:
Kasper Seweryn 2022-04-19 20:51:23 +02:00 committed by Georg Krause
parent db7c0444b4
commit b3022c26b6
5 changed files with 212 additions and 166 deletions

View File

@ -9,8 +9,7 @@ import SetInstanceModal from '~/components/SetInstanceModal.vue'
import ShortcutsModal from '~/components/ShortcutsModal.vue' import ShortcutsModal from '~/components/ShortcutsModal.vue'
import FilterModal from '~/components/moderation/FilterModal.vue' import FilterModal from '~/components/moderation/FilterModal.vue'
import ReportModal from '~/components/moderation/ReportModal.vue' import ReportModal from '~/components/moderation/ReportModal.vue'
import { useIntervalFn, useWindowSize } from '@vueuse/core' import { useIntervalFn, useToggle, useWindowSize } from '@vueuse/core'
import GlobalEvents from '~/components/utils/global-events.vue'
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue' import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import store from '~/store' import store from '~/store'
@ -23,6 +22,7 @@ import {
} from '~/types' } from '~/types'
import useWebSocketHandler from '~/composables/useWebSocketHandler' import useWebSocketHandler from '~/composables/useWebSocketHandler'
import { getClientOnlyRadio } from '~/radios' import { getClientOnlyRadio } from '~/radios'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
// Tracks // Tracks
const currentTrack = computed(() => store.getters['queue/currentTrack']) const currentTrack = computed(() => store.getters['queue/currentTrack'])
@ -102,14 +102,18 @@ useWebSocketHandler('Listen', (event) => {
}) })
// Time ago // Time ago
// TODO (wvffle): Migrate to useTimeAgo
useIntervalFn(() => { useIntervalFn(() => {
// used to redraw ago dates every minute // used to redraw ago dates every minute
store.commit('ui/computeLastDate') store.commit('ui/computeLastDate')
}, 1000 * 60) }, 1000 * 60)
// Shortcuts
const [showShortcutsModal, toggleShortcutsModal] = useToggle(false)
onKeyboardShortcut('h', () => toggleShortcutsModal())
const { width } = useWindowSize() const { width } = useWindowSize()
const player = ref() const player = ref()
const showShortcutsModal = ref(false)
const showSetInstanceModal = ref(false) const showSetInstanceModal = ref(false)
</script> </script>
@ -131,7 +135,7 @@ const showSetInstanceModal = ref(false)
<sidebar <sidebar
:width="width" :width="width"
@show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal" @show:set-instance-modal="showSetInstanceModal = !showSetInstanceModal"
@show:shortcuts-modal="showShortcutsModal = !showShortcutsModal" @show:shortcuts-modal="toggleShortcutsModal"
/> />
<set-instance-modal <set-instance-modal
:show="showSetInstanceModal" :show="showSetInstanceModal"
@ -146,9 +150,9 @@ const showSetInstanceModal = ref(false)
</transition> </transition>
<router-view <router-view
v-slot="{ Component }"
role="main" role="main"
:class="{hidden: store.state.ui.queueFocused}" :class="{hidden: store.state.ui.queueFocused}"
v-slot="{ Component }"
> >
<Suspense v-if="Component"> <Suspense v-if="Component">
<component :is="Component" /> <component :is="Component" />
@ -168,7 +172,6 @@ const showSetInstanceModal = ref(false)
:show="showShortcutsModal" :show="showShortcutsModal"
@update:show="showShortcutsModal = $event" @update:show="showShortcutsModal = $event"
/> />
<GlobalEvents @keydown.h.exact="showShortcutsModal = !showShortcutsModal" />
</div> </div>
</template> </template>

View File

@ -156,7 +156,7 @@
:title="labels.play" :title="labels.play"
:aria-label="labels.play" :aria-label="labels.play"
class="circular button control" class="circular button control"
@click.prevent.stop="resumePlayback" @click.prevent.stop="playback = true"
> >
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" /> <i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
</button> </button>
@ -165,7 +165,7 @@
:title="labels.pause" :title="labels.pause"
:aria-label="labels.pause" :aria-label="labels.pause"
class="circular button control" class="circular button control"
@click.prevent.stop="pausePlayback" @click.prevent.stop="playback = false"
> >
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" /> <i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
</button> </button>
@ -234,7 +234,7 @@
</button> </button>
<button <button
class="circular control button" class="circular control button"
:disabled="queue.tracks.length === 0" :disabled="queueIsEmpty"
:title="labels.shuffle" :title="labels.shuffle"
:aria-label="labels.shuffle" :aria-label="labels.shuffle"
@click.prevent.stop="shuffle()" @click.prevent.stop="shuffle()"
@ -245,7 +245,7 @@
/> />
<i <i
v-else v-else
:class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']" :class="['ui', 'random', {'disabled': queueIsEmpty}, 'icon']"
/> />
</button> </button>
</div> </div>
@ -316,30 +316,11 @@
</div> </div>
</div> </div>
</div> </div>
<GlobalEvents
@keydown.p.prevent.exact="togglePlayback"
@keydown.esc.prevent.exact="$store.commit('ui/queueFocused', null)"
@keydown.ctrl.shift.left.prevent.exact="previous"
@keydown.ctrl.shift.right.prevent.exact="next"
@keydown.shift.down.prevent.exact="$store.commit('player/incrementVolume', -0.1)"
@keydown.shift.up.prevent.exact="$store.commit('player/incrementVolume', 0.1)"
@keydown.right.prevent.exact="seek (5)"
@keydown.left.prevent.exact="seek (-5)"
@keydown.shift.right.prevent.exact="seek (30)"
@keydown.shift.left.prevent.exact="seek (-30)"
@keydown.m.prevent.exact="toggleMute"
@keydown.l.exact="$store.commit('player/toggleLooping')"
@keydown.s.exact="shuffle"
@keydown.f.exact="$store.dispatch('favorites/toggle', currentTrack.id)"
@keydown.q.exact="clean"
@keydown.e.exact="toggleMobilePlayer"
/>
</section> </section>
</template> </template>
<script> <script>
import { mapState, mapGetters, mapActions } from 'vuex' import { useStore, mapState, mapGetters, mapActions } from 'vuex'
import GlobalEvents from '~/components/utils/global-events.vue'
import { toLinearVolumeScale } from '~/audio/volume.js' import { toLinearVolumeScale } from '~/audio/volume.js'
import { Howl, Howler } from 'howler' import { Howl, Howler } from 'howler'
import { throttle, reverse } from 'lodash-es' import { throttle, reverse } from 'lodash-es'
@ -348,13 +329,122 @@ import VolumeControl from './VolumeControl.vue'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue' import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue' import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
import updateQueryString from '~/composables/updateQueryString' import updateQueryString from '~/composables/updateQueryString'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
import { useThrottleFn, useTimeoutFn, useToggle } from '@vueuse/core'
import { computed, watch, defineEmits } from 'vue'
import { useGettext } from 'vue3-gettext'
export default { export default {
components: { components: {
VolumeControl, VolumeControl,
TrackFavoriteIcon, TrackFavoriteIcon,
TrackPlaylistIcon, TrackPlaylistIcon
GlobalEvents },
setup () {
const emit = defineEmits(['next', 'previous'])
const store = useStore()
const { $pgettext } = useGettext()
const queue = computed(() => store.state.queue)
const queueIsEmpty = computed(() => queue.value.tracks.length === 0)
const currentTrack = computed(() => store.getters['queue/currentTrack'])
const currentTime = computed(() => store.state.player.currentTime)
const toggleMobilePlayer = () => {
store.commit('ui/queueFocused', ['queue', 'player'].indexOf(store.state.ui.queueFocused) > -1 ? null : 'player')
}
const shuffledMessage = $pgettext('Content/Queue/Message', 'Queue shuffled!')
const shuffle = useThrottleFn(() => {
if (queueIsEmpty.value) return
useTimeoutFn(async () => {
await store.dispatch('queue/shuffle')
store.commit('ui/addMessage', {
content: shuffledMessage,
date: new Date()
})
}, 100)
}, 101, false)
const seek = (step) => {
if (step > 0) {
// seek right
if (currentTime.value + step < this.duration) {
store.dispatch('player/updateProgress', (currentTime.value + step))
} else {
this.next() // parenthesis where missing here
}
} else {
// seek left
const position = Math.max(currentTime.value + step, 0)
store.dispatch('player/updateProgress', position)
}
}
const next = async () => {
await store.dispatch('queue/next')
emit('next')
}
const previous = async () => {
await store.dispatch('queue/previous')
emit('previous')
}
// Playback
const playing = computed(() => store.state.player.playing)
const [playback, togglePlayback] = useToggle()
watch(playback, isPlaying => store.dispatch(
isPlaying
? 'player/resumePlayback'
: 'player/pausePlayback'
))
// Add controls for notification drawer
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', () => (playback.value = true))
navigator.mediaSession.setActionHandler('pause', () => (playback.value = false))
navigator.mediaSession.setActionHandler('seekforward', () => seek(5))
navigator.mediaSession.setActionHandler('seekbackward', () => seek(-5))
navigator.mediaSession.setActionHandler('nexttrack', next)
navigator.mediaSession.setActionHandler('previoustrack', previous)
}
// Key binds
onKeyboardShortcut('e', toggleMobilePlayer)
onKeyboardShortcut('p', togglePlayback)
onKeyboardShortcut('s', shuffle)
onKeyboardShortcut('q', () => store.dispatch('queue/clean'))
onKeyboardShortcut('m', () => store.dispatch('player/toggleMute'))
onKeyboardShortcut('l', () => store.commit('player/toggleLooping'))
onKeyboardShortcut('f', () => store.dispatch('favorites/toggle', currentTrack.value?.id))
onKeyboardShortcut('escape', () => store.commit('ui/queueFocused', null))
onKeyboardShortcut(['shift', 'up'], () => store.commit('player/incrementVolume', 0.1), true)
onKeyboardShortcut(['shift', 'down'], () => store.commit('player/incrementVolume', -0.1), true)
onKeyboardShortcut('right', () => seek(5), true)
onKeyboardShortcut(['shift', 'right'], () => seek(30), true)
onKeyboardShortcut('left', () => seek(-5), true)
onKeyboardShortcut(['shift', 'left'], () => seek(-30), true)
onKeyboardShortcut(['ctrl', 'shift', 'left'], previous, true)
onKeyboardShortcut(['ctrl', 'shift', 'right'], next, true)
return {
queue,
queueIsEmpty,
currentTrack,
toggleMobilePlayer,
shuffle,
next,
previous,
playback,
playing
}
}, },
data () { data () {
return { return {
@ -379,18 +469,14 @@ export default {
computed: { computed: {
...mapState({ ...mapState({
currentIndex: state => state.queue.currentIndex, currentIndex: state => state.queue.currentIndex,
playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio, isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume, volume: state => state.player.volume,
looping: state => state.player.looping, looping: state => state.player.looping,
duration: state => state.player.duration, duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress, bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored, errored: state => state.player.errored
currentTime: state => state.player.currentTime,
queue: state => state.queue
}), }),
...mapGetters({ ...mapGetters({
currentTrack: 'queue/currentTrack',
hasNext: 'queue/hasNext', hasNext: 'queue/hasNext',
hasPrevious: 'queue/hasPrevious', hasPrevious: 'queue/hasPrevious',
emptyQueue: 'queue/isEmpty', emptyQueue: 'queue/isEmpty',
@ -512,31 +598,17 @@ export default {
this.getSound(this.currentTrack) this.getSound(this.currentTrack)
this.updateMetadata() this.updateMetadata()
} }
// Add controls for notification drawer
if ('mediaSession' in navigator) {
navigator.mediaSession.setActionHandler('play', this.resumePlayback)
navigator.mediaSession.setActionHandler('pause', this.pausePlayback)
navigator.mediaSession.setActionHandler('seekforward', this.seekForward)
navigator.mediaSession.setActionHandler('seekbackward', this.seekBackward)
navigator.mediaSession.setActionHandler('nexttrack', this.next)
navigator.mediaSession.setActionHandler('previoustrack', this.previous)
}
}, },
beforeDestroy () { beforeUnmount () {
this.dummyAudio.unload() this.dummyAudio.unload()
this.observeProgress(false) this.observeProgress(false)
}, },
destroyed () { unmounted () {
}, },
methods: { methods: {
...mapActions({ ...mapActions({
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback',
togglePlayback: 'player/togglePlayback',
mute: 'player/mute', mute: 'player/mute',
unmute: 'player/unmute', unmute: 'player/unmute'
clean: 'queue/clean',
toggleMute: 'player/toggleMute'
}), }),
async getTrackData (trackData) { async getTrackData (trackData) {
// use previously fetched trackData // use previously fetched trackData
@ -549,36 +621,6 @@ export default {
() => null () => null
) )
}, },
shuffle () {
const disabled = this.queue.tracks.length === 0
if (this.isShuffling || disabled) {
return
}
const self = this
const msg = this.$pgettext('Content/Queue/Message', 'Queue shuffled!')
this.isShuffling = true
setTimeout(() => {
self.$store.dispatch('queue/shuffle', () => {
self.isShuffling = false
self.$store.commit('ui/addMessage', {
content: msg,
date: new Date()
})
})
}, 100)
},
next () {
const self = this
this.$store.dispatch('queue/next').then(() => {
self.$emit('next')
})
},
previous () {
const self = this
this.$store.dispatch('queue/previous').then(() => {
self.$emit('previous')
})
},
handleError ({ sound, error }) { handleError ({ sound, error }) {
this.$store.commit('player/isLoadingAudio', false) this.$store.commit('player/isLoadingAudio', false)
this.$store.dispatch('player/trackErrored') this.$store.dispatch('player/trackErrored')
@ -737,20 +779,6 @@ export default {
} }
} }
}, },
seek (step) {
if (step > 0) {
// seek right
if (this.currentTime + step < this.duration) {
this.$store.dispatch('player/updateProgress', (this.currentTime + step))
} else {
this.next() // parenthesis where missing here
}
} else {
// seek left
const position = Math.max(this.currentTime + step, 0)
this.$store.dispatch('player/updateProgress', position)
}
},
seekForward () { seekForward () {
this.seek(5) this.seek(5)
}, },
@ -864,13 +892,6 @@ export default {
this.observeProgress(true) this.observeProgress(true)
} }
}, },
toggleMobilePlayer () {
if (['queue', 'player'].indexOf(this.$store.state.ui.queueFocused) > -1) {
this.$store.commit('ui/queueFocused', null)
} else {
this.$store.commit('ui/queueFocused', 'player')
}
},
switchTab () { switchTab () {
if (this.$store.state.ui.queueFocused === 'player') { if (this.$store.state.ui.queueFocused === 'player') {
this.$store.commit('ui/queueFocused', 'queue') this.$store.commit('ui/queueFocused', 'queue')

View File

@ -14,9 +14,6 @@
</div> </div>
<div class="results" /> <div class="results" />
<slot name="after" /> <slot name="after" />
<GlobalEvents
@keydown.shift.f.prevent.exact="focusSearch"
/>
</div> </div>
</template> </template>
@ -24,11 +21,19 @@
import jQuery from 'jquery' import jQuery from 'jquery'
import router from '~/router' import router from '~/router'
import { trim } from 'lodash-es' import { trim } from 'lodash-es'
import GlobalEvents from '~/components/utils/global-events.vue' import { useFocus } from '@vueuse/core'
import { ref } from 'vue'
import onKeyboardShortcut from '~/composables/onKeyboardShortcut'
export default { export default {
components: { setup () {
GlobalEvents const search = ref()
const { focused } = useFocus(search)
onKeyboardShortcut(['shift', 'f'], () => (focused.value = true), true)
return {
search
}
}, },
computed: { computed: {
labels () { labels () {
@ -244,9 +249,6 @@ export default {
}) })
}, },
methods: { methods: {
focusSearch () {
this.$refs.search.focus()
},
extractObjId (query) { extractObjId (query) {
query = trim(query) query = trim(query)
query = trim(query, '@') query = trim(query, '@')

View File

@ -1,52 +0,0 @@
<script>
import $ from 'jquery'
const modifiersRE = /^[~!&]*/
const nonEventNameCharsRE = /\W+/
const names = {
'!': 'capture',
'~': 'once',
'&': 'passive'
}
function extractEventOptions (eventDescriptor) {
const [modifiers] = eventDescriptor.match(modifiersRE)
return modifiers.split('').reduce((options, modifier) => {
options[names[modifier]] = true
return options
}, {})
}
export default {
mounted () {
this._listeners = Object.create(null)
Object.keys(this.$listeners).forEach(event => {
const handler = this.$listeners[event]
const wrapper = function (event) {
// we check here the event is not triggered from an input
// to avoid collisions
if (!$(event.target).is('.field, :input, [contenteditable]')) {
handler(event)
}
}
document.addEventListener(
event.replace(nonEventNameCharsRE, ''),
wrapper,
extractEventOptions(event)
)
this._listeners[event] = handler
})
},
beforeDestroy () {
for (const event in this._listeners) {
document.removeEventListener(
event.replace(nonEventNameCharsRE, ''),
this._listeners[event]
)
}
},
render: h => h()
}
</script>

View File

@ -0,0 +1,72 @@
import { DefaultMagicKeysAliasMap, tryOnScopeDispose, useActiveElement, useEventListener } from '@vueuse/core'
import { computed, reactive } from 'vue'
import { isEqual, isMatch } from 'lodash-es'
type KeyFilter = string | string[]
interface Entry {
handler: () => unknown,
prevent: boolean,
__location?: string
}
const combinations = reactive(new Map())
const activeElement = useActiveElement()
const bodyIsActive = computed(() => activeElement.value === document.body)
const current = new Set()
useEventListener(window, 'keydown', (event) => {
if (!bodyIsActive.value) return
current.add(event.key.toLowerCase())
const currentArray = [...current]
for (const [requiredKeys, { handler, prevent }] of combinations.entries()) {
if (isEqual(currentArray, requiredKeys)) {
if (prevent) event.preventDefault()
handler()
}
}
})
useEventListener(window, 'keyup', (event) => {
current.delete(event.key.toLowerCase())
})
export default (key: KeyFilter, handler: () => unknown, prevent = false) => {
const combination = (Array.isArray(key) ? key : [key as string]).map(key => {
return DefaultMagicKeysAliasMap[key] ?? key
})
const entry: Entry = { prevent, handler }
if (import.meta.env.DEV) {
entry.__location = new Error().stack?.split('\n', 2).pop()
// TODO: Get correct line number somehow?
// Currently $3 is a line number that should work in .ts files,
// though in .vue files we need to get the line of the script plus
// the position of <script> tag in SFC
?.replace(/^(.+?)@.+\/(.+\..+?)(?:\?.+?|):(\d+):.+$/, 'Method $1 in $2')
// NOTE: Inform about possible combination collision
for (const [keys, { __location }] of combinations.entries()) {
const collisions = []
if (isMatch(keys, combination) || isMatch(combination, keys)) {
collisions.push(`${__location}: ${keys.join(' + ')}`)
}
if (collisions.length) {
console.warn([
'onKeyboardShortcut detected a possible collision in:',
`${entry.__location}: ${combination.join(' + ')}`,
...collisions
].join('\n'))
}
}
}
combinations.set(combination, entry)
const stop = () => combinations.delete(combination)
tryOnScopeDispose(stop)
return stop
}