Merge branch 'stable' into develop

This commit is contained in:
Georg Krause 2023-06-28 13:05:47 +02:00
commit 232ca0f050
30 changed files with 623 additions and 513 deletions

View file

@ -17,6 +17,7 @@ export interface Sound {
readonly audioNode: IAudioNode<IAudioContext>
readonly isErrored: Ref<boolean>
readonly isLoaded: Ref<boolean>
readonly isDisposed: Ref<boolean>
readonly currentTime: number
readonly playable: boolean
readonly duration: number
@ -51,6 +52,7 @@ export class HTMLSound implements Sound {
readonly isErrored = ref(false)
readonly isLoaded = ref(false)
readonly isDisposed = ref(false)
audioNode = createAudioSource(this.#audio)
onSoundLoop: EventHookOn<HTMLSound>
@ -112,12 +114,15 @@ export class HTMLSound implements Sound {
}
async preload () {
this.isDisposed.value = false
this.isErrored.value = false
console.log('CALLING PRELOAD ON', this)
this.#audio.load()
}
async dispose () {
if (this.isDisposed.value) return
// Remove all event listeners
this.#scope.stop()
@ -128,10 +133,17 @@ export class HTMLSound implements Sound {
// Cancel any request downloading the source
this.#audio.src = ''
this.#audio.load()
this.isDisposed.value = true
}
async play () {
return this.#audio.play()
try {
await this.#audio.play()
} catch (err) {
console.error('>> AUDIO PLAY ERROR', err, this)
this.isErrored.value = true
}
}
async pause () {

View file

@ -33,7 +33,7 @@ const suggestedInstances = computed(() => {
...store.state.instance.knownInstances,
serverUrl.endsWith('/') ? serverUrl : serverUrl + '/',
store.getters['instance/defaultInstance']
]).slice(1)
])
})
watch(() => store.state.instance.instanceUrl, () => store.dispatch('instance/fetchSettings'))

View file

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, reactive, watch, watchEffect, onMounted } from 'vue'
import { computed, ref, reactive, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
@ -84,7 +84,11 @@ const fetchCandidates = async () => {
}
}
watch(filters, fetchCandidates)
// NOTE: Whenever we modify filters array, we refetch the candidates automatically
watch(filters, fetchCandidates, {
deep: true
})
const checkErrors = computed(() => checkResult.value?.errors ?? [])
const isPublic = ref(true)
@ -107,6 +111,7 @@ const fetchFilters = async () => {
}
}
let filterId = Number.MIN_SAFE_INTEGER
const isLoading = ref(false)
const fetchData = async () => {
isLoading.value = true
@ -114,10 +119,10 @@ const fetchData = async () => {
try {
const response = await axios.get(`radios/radios/${props.id}/`)
filters.length = 0
filters.push(...response.data.config.map((filter: FilterConfig) => ({
config: filter,
filter: availableFilters.find(available => available.type === filter.type),
hash: +new Date()
filters.push(...response.data.config.map((config: FilterConfig) => ({
config,
filter: availableFilters.find(available => available.type === config.type),
hash: filterId++
})))
radioName.value = response.data.name
@ -130,28 +135,19 @@ const fetchData = async () => {
isLoading.value = false
}
fetchFilters().then(() => watchEffect(fetchData))
fetchFilters().then(() => fetchData())
const add = async () => {
if (currentFilter.value) {
filters.push({
config: {} as FilterConfig,
filter: currentFilter.value,
hash: +new Date()
})
}
return fetchCandidates()
}
const updateConfig = async (index: number, field: keyof FilterConfig, value: unknown) => {
filters[index].config[field] = value
return fetchCandidates()
if (!currentFilter.value) return
filters.push({
config: {} as FilterConfig,
filter: currentFilter.value,
hash: +new Date()
})
}
const deleteFilter = async (index: number) => {
filters.splice(index, 1)
return fetchCandidates()
}
const success = ref(false)
@ -325,11 +321,8 @@ onMounted(() => {
<builder-filter
v-for="(f, index) in filters"
:key="f.hash"
:index="index"
:config="f.config"
:filter="f.filter"
@update-config="updateConfig"
@delete="deleteFilter"
v-model:data="filters[index]"
@delete="deleteFilter(index)"
/>
</tbody>
</table>

View file

@ -6,8 +6,8 @@ import type { Track } from '~/types'
import axios from 'axios'
import $ from 'jquery'
import { useCurrentElement } from '@vueuse/core'
import { ref, onMounted, watch } from 'vue'
import { useCurrentElement, useVModel } from '@vueuse/core'
import { ref, onMounted, watch, computed } from 'vue'
import { useStore } from '~/store'
import { clone } from 'lodash-es'
@ -20,35 +20,33 @@ type Filter = { candidates: { count: number, sample: Track[] } }
type ResponseType = { filters: Array<Filter> }
interface Events {
(e: 'update-config', index: number, name: string, value: number[] | boolean): void
(e: 'delete', index: number): void
(e: 'update:data', name: string, value: number[] | boolean): void
(e: 'delete'): void
}
interface Props {
index: number
filter: BuilderFilter
config: FilterConfig
data: {
filter: BuilderFilter
config: FilterConfig
}
}
const emit = defineEmits<Events>()
const props = defineProps<Props>()
const data = useVModel(props, 'data', emit)
const store = useStore()
const checkResult = ref<Filter | null>(null)
const showCandidadesModal = ref(false)
const exclude = ref(props.config.not)
const exclude = computed({
get: () => data.value.config.not,
set: (value: boolean) => (data.value.config.not = value)
})
const el = useCurrentElement()
onMounted(() => {
for (const field of props.filter.fields) {
const selector = ['.dropdown']
if (field.type === 'list') {
selector.push('.multiple')
}
for (const field of data.value.filter.fields) {
const settings: SemanticUI.DropdownSettings = {
onChange (value) {
value = $(this).dropdown('get value').split(',')
@ -57,15 +55,19 @@ onMounted(() => {
value = value.map((number: string) => parseInt(number))
}
value.value = value
emit('update-config', props.index, field.name, value)
data.value.config[field.name] = value
fetchCandidates()
}
}
let selector = field.type === 'list'
? '.dropdown.multiple'
: '.dropdown'
if (field.autocomplete) {
selector.push('.autocomplete')
// @ts-expect-error custom field?
selector += '.autocomplete'
// @ts-expect-error Semantic UI types are incomplete
settings.fields = field.autocomplete_fields
settings.minCharacters = 1
settings.apiSettings = {
@ -85,15 +87,15 @@ onMounted(() => {
}
}
$(el.value).find(selector.join('')).dropdown(settings)
$(el.value).find(selector).dropdown(settings)
}
})
const fetchCandidates = async () => {
const params = {
filters: [{
...clone(props.config),
type: props.filter.type
...clone(data.value.config),
type: data.value.filter.type
}]
}
@ -106,11 +108,12 @@ const fetchCandidates = async () => {
}
watch(exclude, fetchCandidates)
fetchCandidates()
</script>
<template>
<tr>
<td>{{ filter.label }}</td>
<td>{{ data.filter.label }}</td>
<td>
<div class="ui toggle checkbox">
<input
@ -118,7 +121,6 @@ watch(exclude, fetchCandidates)
v-model="exclude"
name="public"
type="checkbox"
@change="$emit('update-config', index, 'not', exclude)"
>
<label
for="exclude-filter"
@ -130,33 +132,34 @@ watch(exclude, fetchCandidates)
</td>
<td>
<div
v-for="f in filter.fields"
v-for="f in data.filter.fields"
:key="f.name"
class="ui field"
>
<div :class="['ui', 'search', 'selection', 'dropdown', {'autocomplete': f.autocomplete}, {'multiple': f.type === 'list'}]">
<div :class="['ui', 'search', 'selection', 'dropdown', { autocomplete: f.autocomplete }, { multiple: f.type === 'list' }]">
<i class="dropdown icon" />
<div class="default text">
{{ f.placeholder }}
</div>
<input
v-if="f.type === 'list' && config[f.name as keyof FilterConfig]"
v-if="f.type === 'list' && data.config[f.name as keyof FilterConfig]"
:id="f.name"
:value="(config[f.name as keyof FilterConfig] as string[]).join(',')"
:value="(data.config[f.name as keyof FilterConfig] as string[]).join(',')"
type="hidden"
>
<div
v-if="typeof config[f.name as keyof FilterConfig] === 'object'"
v-if="typeof data.config[f.name as keyof FilterConfig] === 'object'"
class="ui menu"
>
<div
v-for="(v, i) in config[f.name as keyof FilterConfig] as object"
:key="i"
v-for="(v, i) in data.config[f.name as keyof FilterConfig] as object"
v-once
:key="data.config.ids?.[i] ?? v"
class="ui item"
:data-value="v"
>
<template v-if="config.names">
{{ config.names[i] }}
<template v-if="data.config.names">
{{ data.config.names[i] }}
</template>
<template v-else>
{{ v }}
@ -170,7 +173,7 @@ watch(exclude, fetchCandidates)
<a
v-if="checkResult"
href=""
:class="['ui', {'success': checkResult.candidates.count > 10}, 'label']"
:class="['ui', { success: checkResult.candidates.count > 10 }, 'label']"
@click.prevent="showCandidadesModal = !showCandidadesModal"
>
{{ $t('components.library.radios.Filter.matchingTracks', checkResult.candidates.count) }}
@ -200,7 +203,7 @@ watch(exclude, fetchCandidates)
<td>
<button
class="ui danger button"
@click="$emit('delete', index)"
@click="emit('delete')"
>
{{ $t('components.library.radios.Filter.removeButton') }}
</button>

View file

@ -61,9 +61,7 @@ interface TargetType {
}
type Targets = Exclude<StateTarget, undefined>['type']
const targets = reactive({
track: {}
}) as Record<Targets, Record<string, TargetType>>
const targets = reactive<Record<Targets, Record<string, TargetType>>>(Object.create(null))
const fetchTargets = async () => {
// we request target data via the API so we can display previous state
@ -96,6 +94,7 @@ const fetchTargets = async () => {
})
for (const payload of response?.data?.results ?? []) {
targets[key as keyof typeof targets] ??= Object.create(null)
targets[key as keyof typeof targets][payload.id] = {
payload,
currentState: configs[key as keyof typeof targets].fields.reduce((state, field) => {

View file

@ -324,7 +324,7 @@ export const useQueue = createGlobalState(() => {
const clear = async () => {
await currentSound.value?.pause()
await currentSound.value?.seekTo(0)
currentSound.value?.dispose()
await currentSound.value?.dispose()
clearRadio.value = true

View file

@ -101,6 +101,11 @@ export const useTracks = createGlobalState(() => {
setTimeout(() => playNext(), 0)
})
// NOTE: When the sound is disposed, we need to delete it from the cache (#2157)
whenever(sound.isDisposed, () => {
soundCache.delete(track.id)
})
// NOTE: Bump current track to ensure that it lives despite enqueueing 3 tracks as next track:
//
// In every queue we have 3 tracks that are cached, in the order, they're being played:

View file

@ -7,6 +7,9 @@ import axios from 'axios'
import useLogger from '~/composables/useLogger'
import useFormData from '~/composables/useFormData'
import { clear as clearIDB } from 'idb-keyval'
import { useQueue } from '~/composables/audio/queue'
export type Permission = 'settings' | 'library' | 'moderation'
export interface State {
authenticated: boolean
@ -167,6 +170,7 @@ const store: Module<State, RootState> = {
} catch (error) {
console.log('Error while logging out, probably logged in via oauth')
}
const modules = [
'auth',
'favorites',
@ -175,9 +179,20 @@ const store: Module<State, RootState> = {
'queue',
'radios'
]
modules.forEach(m => {
commit(`${m}/reset`, null, { root: true })
})
for (const module of modules) {
commit(`${module}/reset`, null, { root: true })
}
// Clear session storage
sessionStorage.clear()
// Clear track queue
await useQueue().clear()
// Clear all indexedDB data
await clearIDB()
logger.info('Log out, goodbye!')
},

View file

@ -126,16 +126,21 @@ const logger = useLogger()
// 1. use the url provided in settings.json, if any
// 2. use the url specified when building via VUE_APP_INSTANCE_URL
// 3. use the current url
const instanceUrl = import.meta.env.VUE_APP_INSTANCE_URL as string ?? location.origin
let DEFAULT_INSTANCE_URL = `${location.origin}/`
try {
DEFAULT_INSTANCE_URL = new URL(import.meta.env.VUE_APP_INSTANCE_URL as string).href
} catch (e) {
logger.warn('Invalid VUE_APP_INSTANCE_URL, falling back to current url', e)
}
const store: Module<State, RootState> = {
namespaced: true,
state: {
frontSettings: {
defaultServerUrl: instanceUrl,
defaultServerUrl: DEFAULT_INSTANCE_URL,
additionalStylesheets: []
},
instanceUrl,
instanceUrl: DEFAULT_INSTANCE_URL,
knownInstances: [],
nodeinfo: null,
settings: {
@ -190,40 +195,31 @@ const store: Module<State, RootState> = {
state.nodeinfo = value
},
instanceUrl: (state, value) => {
if (value && !value.endsWith('/')) {
value = value + '/'
}
try {
const { href } = new URL(value)
state.instanceUrl = href
axios.defaults.baseURL = `${href}api/v1/`
state.instanceUrl = value
// append the URL to the list (and remove existing one if needed)
if (value) {
const index = state.knownInstances.indexOf(value)
if (index > -1) {
state.knownInstances.splice(index, 1)
}
state.knownInstances.splice(0, 0, value)
}
if (!value) {
// append the URL to the list (and remove existing one if needed)
const index = state.knownInstances.indexOf(href)
if (index > -1) state.knownInstances.splice(index, 1)
state.knownInstances.unshift(href)
} catch (e) {
logger.error('Invalid instance URL', e)
axios.defaults.baseURL = undefined
return
}
const suffix = 'api/v1/'
axios.defaults.baseURL = state.instanceUrl + suffix
}
},
getters: {
absoluteUrl: (state) => (relativeUrl: string) => {
absoluteUrl: (_state, getters) => (relativeUrl: string) => {
if (relativeUrl.startsWith('http')) return relativeUrl
if (state.instanceUrl?.endsWith('/') && relativeUrl.startsWith('/')) {
relativeUrl = relativeUrl.slice(1)
}
return (state.instanceUrl ?? instanceUrl) + relativeUrl
return relativeUrl.startsWith('/')
? `${getters.url.href}${relativeUrl.slice(1)}`
: `${getters.url.href}${relativeUrl}`
},
domain: (state) => new URL(state.instanceUrl ?? instanceUrl).hostname,
defaultInstance: () => instanceUrl
url: (state) => new URL(state.instanceUrl ?? DEFAULT_INSTANCE_URL),
domain: (_state, getters) => getters.url.hostname,
defaultInstance: () => DEFAULT_INSTANCE_URL
},
actions: {
setUrl ({ commit }, url) {
@ -269,7 +265,7 @@ const store: Module<State, RootState> = {
for (const [key, value] of Object.entries(response.data as FrontendSettings)) {
if (key === 'defaultServerUrl' && !value) {
state.frontSettings.defaultServerUrl = instanceUrl
state.frontSettings.defaultServerUrl = DEFAULT_INSTANCE_URL
continue
}

View file

@ -1,3 +1,6 @@
body > .modals {
z-index: 99999 !important;
}
.modal > .header {
text-align: center;

View file

@ -31,7 +31,8 @@ syncRef(pageQuery, page, {
transform: {
ltr: (left) => +left,
rtl: (right) => right.toString()
}
},
direction: 'both'
})
const q = useRouteQuery('q', '')
@ -113,7 +114,7 @@ const currentType = computed(() => types.value.find(({ id }) => id === type.valu
const axiosParams = computed(() => {
const params = new URLSearchParams({
q: query.value,
page: page.value as unknown as string,
page: pageQuery.value,
page_size: paginateBy.value as unknown as string
})
@ -171,10 +172,12 @@ const search = async () => {
}
watch(type, () => {
if (page.value === 1) return search()
page.value = 1
search()
})
// NOTE: When we watch `page`, the `pageQuery` value is never updated for some reason
watch(pageQuery, search)
search()
const labels = computed(() => ({

View file

@ -103,7 +103,8 @@ watch([uuid, object], ([uuid, object], [lastUuid, lastObject]) => {
const route = useRoute()
watchEffect(() => {
if (!store.state.auth.authenticated && store.getters['instance/domain'] !== object.value?.actor.domain) {
if (!object.value) return
if (!store.state.auth.authenticated && store.getters['instance/domain'] !== object.value.actor.domain) {
router.push({ name: 'login', query: { next: route.fullPath } })
}
})