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

View File

@ -156,7 +156,7 @@
:title="labels.play"
:aria-label="labels.play"
class="circular button control"
@click.prevent.stop="resumePlayback"
@click.prevent.stop="playback = true"
>
<i :class="['ui', 'big', 'play', {'disabled': !currentTrack}, 'icon']" />
</button>
@ -165,7 +165,7 @@
:title="labels.pause"
:aria-label="labels.pause"
class="circular button control"
@click.prevent.stop="pausePlayback"
@click.prevent.stop="playback = false"
>
<i :class="['ui', 'big', 'pause', {'disabled': !currentTrack}, 'icon']" />
</button>
@ -234,7 +234,7 @@
</button>
<button
class="circular control button"
:disabled="queue.tracks.length === 0"
:disabled="queueIsEmpty"
:title="labels.shuffle"
:aria-label="labels.shuffle"
@click.prevent.stop="shuffle()"
@ -245,7 +245,7 @@
/>
<i
v-else
:class="['ui', 'random', {'disabled': queue.tracks.length === 0}, 'icon']"
:class="['ui', 'random', {'disabled': queueIsEmpty}, 'icon']"
/>
</button>
</div>
@ -316,30 +316,11 @@
</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>
</template>
<script>
import { mapState, mapGetters, mapActions } from 'vuex'
import GlobalEvents from '~/components/utils/global-events.vue'
import { useStore, mapState, mapGetters, mapActions } from 'vuex'
import { toLinearVolumeScale } from '~/audio/volume.js'
import { Howl, Howler } from 'howler'
import { throttle, reverse } from 'lodash-es'
@ -348,13 +329,122 @@ import VolumeControl from './VolumeControl.vue'
import TrackFavoriteIcon from '~/components/favorites/TrackFavoriteIcon.vue'
import TrackPlaylistIcon from '~/components/playlists/TrackPlaylistIcon.vue'
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 {
components: {
VolumeControl,
TrackFavoriteIcon,
TrackPlaylistIcon,
GlobalEvents
TrackPlaylistIcon
},
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 () {
return {
@ -379,18 +469,14 @@ export default {
computed: {
...mapState({
currentIndex: state => state.queue.currentIndex,
playing: state => state.player.playing,
isLoadingAudio: state => state.player.isLoadingAudio,
volume: state => state.player.volume,
looping: state => state.player.looping,
duration: state => state.player.duration,
bufferProgress: state => state.player.bufferProgress,
errored: state => state.player.errored,
currentTime: state => state.player.currentTime,
queue: state => state.queue
errored: state => state.player.errored
}),
...mapGetters({
currentTrack: 'queue/currentTrack',
hasNext: 'queue/hasNext',
hasPrevious: 'queue/hasPrevious',
emptyQueue: 'queue/isEmpty',
@ -512,31 +598,17 @@ export default {
this.getSound(this.currentTrack)
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.observeProgress(false)
},
destroyed () {
unmounted () {
},
methods: {
...mapActions({
resumePlayback: 'player/resumePlayback',
pausePlayback: 'player/pausePlayback',
togglePlayback: 'player/togglePlayback',
mute: 'player/mute',
unmute: 'player/unmute',
clean: 'queue/clean',
toggleMute: 'player/toggleMute'
unmute: 'player/unmute'
}),
async getTrackData (trackData) {
// use previously fetched trackData
@ -549,36 +621,6 @@ export default {
() => 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 }) {
this.$store.commit('player/isLoadingAudio', false)
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 () {
this.seek(5)
},
@ -864,13 +892,6 @@ export default {
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 () {
if (this.$store.state.ui.queueFocused === 'player') {
this.$store.commit('ui/queueFocused', 'queue')

View File

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