parent
db7c0444b4
commit
b3022c26b6
|
@ -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>
|
||||
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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, '@')
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue