Skip to content
Snippets Groups Projects
Commit 73e8701c authored by Konrad Mohrfeldt's avatar Konrad Mohrfeldt :koala:
Browse files

refactor: rework media management

This refactoring includes some fixes, new features,
and updates to the API:

* the new /media and /media-sources APIs are used
* users can now cancel the addition of new media sources
* all errors should now be catched and displayed, no matter when
  in the handling process they happen
* users can show the import logs for uploads
* uploads happen in the background so users can move to
  other pages without aborting the upload

refs #347
refs #328
refs #323
refs #342
refs #273
parent d39d6930
No related branches found
No related tags found
No related merge requests found
Pipeline #8866 passed
Pipeline: aura-tests

#8867

    Showing
    with 595 additions and 158 deletions
    ......@@ -101,7 +101,7 @@ import { useRoute, useRouter } from 'vue-router'
    import { useI18n } from '@/i18n'
    import {
    useAuthStore,
    usePlaylistStore,
    useMediaStore,
    useRRuleStore,
    useShowStore,
    useTimeSlotStore,
    ......@@ -137,7 +137,7 @@ const authStore = useAuthStore()
    const rruleStore = useRRuleStore()
    const showStore = useShowStore()
    const timeslotStore = useTimeSlotStore()
    const playlistStore = usePlaylistStore()
    const mediaStore = useMediaStore()
    const radioSettings = useCurrentRadioSettings()
    const canAddSchedule = useHasUserPermission(['program.add_schedule'])
    ......@@ -178,14 +178,14 @@ const calendarEvents = computedAsync(
    program.value.map((e) => e.timeslotId).filter((id) => id !== null) as number[],
    { useCached: true },
    ),
    playlistStore.retrieveMultiple(
    program.value.map((e) => e.playlistId).filter((id) => id !== null) as number[],
    mediaStore.retrieveMultiple(
    program.value.map((e) => e.mediaId).filter((id) => id !== null) as number[],
    { useCached: true },
    ),
    ])
    for (const entry of program.value) {
    const cacheKey = [entry.id, entry.timeslotId, entry.playlistId].join(':')
    const cacheKey = [entry.id, entry.timeslotId, entry.mediaId].join(':')
    const cachedSlot = calendarEventsCache.get(cacheKey)
    if (cachedSlot) {
    ......@@ -193,7 +193,7 @@ const calendarEvents = computedAsync(
    continue
    }
    const isEmpty = entry.timeslotId && entry.playlistId === null
    const isEmpty = entry.timeslotId && entry.mediaId === null
    const emptyText = isEmpty ? `: ${t('calendar.empty')} ⚠` : ''
    const ts = entry.timeslotId
    ? await timeslotStore.retrieve(entry.timeslotId, { useCached: true })
    ......
    ......@@ -330,17 +330,14 @@
    v-if="radioSettings?.program?.fallback?.showId !== show.id"
    :title="t('show.section.media.title')"
    >
    <FormGroup
    v-slot="{ disabled }"
    :errors="playlistId.errors"
    edit-permissions="program.edit__show__default_playlist_id"
    >
    <APlaylistEditor
    :playlist="playlist"
    <FormGroup v-slot="{ disabled }" edit-permissions="program.edit__show__default_media_id">
    <AMediaEditor
    :show="show"
    :media="media"
    :get-media="getMedia"
    :disabled="disabled"
    :context-key="`show-${show.id}-default-media`"
    class="tw-max-w-3xl"
    @create="playlistId.value = $event.id"
    />
    </FormGroup>
    </AFieldset>
    ......@@ -378,7 +375,7 @@ import {
    useProfileStore,
    useLanguageStore,
    useMusicFocusStore,
    usePlaylistStore,
    useMediaStore,
    useShowStore,
    useTopicStore,
    useTypeStore,
    ......@@ -400,10 +397,11 @@ import ComboBoxSimple from '@/components/ComboBoxSimple.vue'
    import Tag from '@/components/generic/Tag.vue'
    import ImagePicker from '@/components/images/ImagePicker.vue'
    import AFieldset from '@/components/generic/AFieldset.vue'
    import APlaylistEditor from '@/components/playlist/APlaylistEditor.vue'
    import AMediaEditor from '@/components/media/AMediaEditor.vue'
    import AUserSelector from '@/components/identities/AUserSelector.vue'
    import AProfileSelector from '@/components/identities/AProfileSelector.vue'
    import { useCurrentRadioSettings, useImageRequirements } from '@/stores/radio-settings'
    import { useMediaFactory } from '@/stores/media-manager'
    const props = defineProps<{
    show: Show
    ......@@ -419,7 +417,7 @@ const musicFocusStore = useMusicFocusStore()
    const languageStore = useLanguageStore()
    const profileStore = useProfileStore()
    const fundingCategoryStore = useFundingCategoryStore()
    const playlistStore = usePlaylistStore()
    const mediaStore = useMediaStore()
    const radioSettings = useCurrentRadioSettings()
    const show = computed(() => props.show)
    ......@@ -444,11 +442,14 @@ const owners = useRelationList(showStore, () => props.show, 'ownerIds', userStor
    sortBy: ['lastName', 'firstName', 'username', 'email'],
    })
    const { obj: playlist } = useObjectFromStore(() => props.show.defaultPlaylistId, playlistStore)
    const playlistId = useAPIObjectFieldCopy(showStore, show, 'defaultPlaylistId', { debounce: 0 })
    const { obj: media } = useObjectFromStore(() => props.show.defaultMediaId, mediaStore)
    const logoRequirements = useImageRequirements('show.logo')
    const imageRequirements = useImageRequirements('show.image')
    const getMedia = useMediaFactory(show, media, (media) =>
    showStore.partialUpdate(props.show.id, { defaultMediaId: media.id }),
    )
    useBreadcrumbs(() => [
    { title: t('navigation.shows'), route: { name: 'shows' } },
    { title: props.show.name, route: { name: 'show', params: { showId: props.show.id.toString() } } },
    ......
    <template>
    <router-view v-if="episode" :show="show" :episode="episode" :playlist="playlist" />
    <router-view v-if="episode" :show="show" :episode="episode" :media="media" />
    </template>
    <script setup lang="ts">
    ......@@ -7,7 +7,7 @@ import { watchEffect } from 'vue'
    import { useRoute, useRouter } from 'vue-router'
    import { useObjectFromStore } from '@rokoli/bnb/drf'
    import { useEpisodeStore, usePlaylistStore } from '@/stores'
    import { useEpisodeStore, useMediaStore } from '@/stores'
    import { defineNavigationContext } from '@/stores/nav'
    import { Show } from '@/types'
    ......@@ -18,12 +18,12 @@ const props = defineProps<{
    const router = useRouter()
    const route = useRoute()
    const episodeStore = useEpisodeStore()
    const playlistStore = usePlaylistStore()
    const mediaStore = useMediaStore()
    const { obj: episode } = useObjectFromStore(
    () => parseInt(route.params.episodeId as string),
    episodeStore,
    )
    const { obj: playlist } = useObjectFromStore(() => episode.value?.mediaId ?? null, playlistStore)
    const { obj: media } = useObjectFromStore(() => episode.value?.mediaId ?? null, mediaStore)
    defineNavigationContext(() => ({ episode }))
    ......
    ......@@ -20,13 +20,14 @@
    <p v-else>{{ t('timeslot.labels.noneAssigned') }}</p>
    </AFieldset>
    <AFieldset :title="t('playlist.editor.title')" class="tw-col-span-full xl:tw-col-span-5">
    <AFieldset :title="t('media.editor.title')" class="tw-col-span-full xl:tw-col-span-5">
    <APermissionGuard v-slot="{ disabled }" edit-permissions="program.edit__episode__media">
    <APlaylistEditor
    :playlist="playlist"
    <AMediaEditor
    :show="show"
    :media="media"
    :get-media="getMedia"
    :disabled="disabled"
    @create="assignPlaylist"
    :context-key="`episode-${episode.id}-media`"
    />
    </APermissionGuard>
    </AFieldset>
    ......@@ -53,20 +54,21 @@ import { computed } from 'vue'
    import { useI18n } from '@/i18n'
    import { useEpisodeStore, useTimeSlotStore } from '@/stores'
    import { useBreadcrumbs } from '@/stores/nav'
    import { Episode, Playlist, Show } from '@/types'
    import { Episode, Media, Show } from '@/types'
    import PageHeader from '@/components/PageHeader.vue'
    import EpisodeDescriptionEditor from '@/components/episode/EpisodeDescriptionEditor.vue'
    import ATimeEditInfo from '@/components/generic/ATimeEditInfo.vue'
    import APermissionGuard from '@/components/generic/APermissionGuard.vue'
    import APlaylistEditor from '@/components/playlist/APlaylistEditor.vue'
    import AMediaEditor from '@/components/media/AMediaEditor.vue'
    import AFieldset from '@/components/generic/AFieldset.vue'
    import { ensureDate, useObjectListFromStore } from '@/util'
    import ASpinnerLabel from '@/components/generic/ASpinnerLabel.vue'
    import { useMediaFactory } from '@/stores/media-manager'
    const props = defineProps<{
    show: Show
    episode: Episode
    playlist: Playlist | null
    media: Media | null
    }>()
    const { locale, t } = useI18n()
    ......@@ -92,9 +94,11 @@ const timeslotData = computed(() => {
    })
    })
    async function assignPlaylist(playlist: Playlist) {
    await episodeStore.partialUpdate(props.episode.id, { mediaId: playlist.id })
    }
    const getMedia = useMediaFactory(
    () => props.show,
    () => props.media,
    (media) => episodeStore.partialUpdate(props.episode.id, { mediaId: media.id }),
    )
    useBreadcrumbs(() => [
    { title: t('navigation.shows'), route: { name: 'shows' } },
    ......
    ......@@ -83,8 +83,8 @@
    <APill
    v-if="
    playlistDurationInSeconds !== null &&
    timeslotDurationInSeconds !== playlistDurationInSeconds
    mediaDurationInSeconds !== null &&
    timeslotDurationInSeconds !== mediaDurationInSeconds
    "
    class="tw-text-amber-700 tw-text-sm"
    >
    ......@@ -110,7 +110,7 @@ import SafeHTML from '@/components/generic/SafeHTML'
    import ATimelineHeader from '@/components/generic/ATimelineHeader.vue'
    import ATimelineIcon from '@/components/generic/ATimelineIcon.vue'
    import { ProgramEntry, TimeSlot } from '@/types'
    import { calculatePlaylistDurationInSeconds, usePlaylistStore } from '@/stores/playlists'
    import { calculateMediaDurationInSeconds, useMediaStore } from '@/stores/media'
    import { useObjectFromStore } from '@rokoli/bnb/drf'
    import { useEpisodeStore, useShowStore, useTimeSlotStore } from '@/stores'
    import { computed } from 'vue'
    ......@@ -129,7 +129,7 @@ const emit = defineEmits<{ edit: [TimeSlot] }>()
    const { locale, t } = useI18n()
    const showStore = useShowStore()
    const timeslotStore = useTimeSlotStore()
    const playlistStore = usePlaylistStore()
    const mediaStore = useMediaStore()
    const episodeStore = useEpisodeStore()
    const start = computed(() => ensureDate(props.entry.start))
    ......@@ -138,9 +138,9 @@ const durationInSeconds = computed(() => calculateDurationSeconds(start.value, e
    const { obj: show } = useObjectFromStore(() => props.entry.showId, showStore)
    const { obj: playlist } = useObjectFromStore(() => props.entry.playlistId, playlistStore)
    const playlistDurationInSeconds = computed(() =>
    playlist.value ? calculatePlaylistDurationInSeconds(playlist.value) : null,
    const { obj: media } = useObjectFromStore(() => props.entry.mediaId, mediaStore)
    const mediaDurationInSeconds = computed(() =>
    media.value ? calculateMediaDurationInSeconds(media.value) : null,
    )
    const { obj: timeslot } = useObjectFromStore(() => props.entry.timeslotId, timeslotStore)
    ......
    ......@@ -15,8 +15,8 @@
    </span>
    </td>
    <td class="tw-text-xs">
    <AStatus v-if="playlist && playlist.entries.length > 0" is-success rounded>
    {{ t('mediaSource.count', { smart_count: playlist.entries.length }) }}
    <AStatus v-if="media && media.entries.length > 0" is-success rounded>
    {{ t('mediaSource.count', { smart_count: media.entries.length }) }}
    </AStatus>
    <AStatus v-else is-warning rounded>
    {{ t('labels.missing') }}
    ......@@ -31,7 +31,7 @@ import { computed, useAttrs } from 'vue'
    import { useI18n } from '@/i18n'
    import { Episode } from '@/types'
    import { usePlaylistStore, useTimeSlotStore } from '@/stores'
    import { useMediaStore, useTimeSlotStore } from '@/stores'
    import Loading from '@/components/generic/Loading.vue'
    import { ensureDate, useObjectListFromStore } from '@/util'
    import AStatus from '@/components/generic/AStatus.vue'
    ......@@ -47,10 +47,10 @@ const props = defineProps<{
    const attrs = useAttrs()
    const { t, locale } = useI18n()
    const playlistStore = usePlaylistStore()
    const mediaStore = useMediaStore()
    const timeslotStore = useTimeSlotStore()
    const { obj: playlist } = useObjectFromStore(() => props.episode.mediaId, playlistStore)
    const { obj: media } = useObjectFromStore(() => props.episode.mediaId, mediaStore)
    const { objects: timeslots, isLoading: isLoadingTimeslots } = useObjectListFromStore(
    () => props.episode.timeslotIds as number[],
    timeslotStore,
    ......
    <script lang="ts" setup>
    import type { Component } from 'vue'
    import { useAttrs } from 'vue'
    defineOptions({
    inheritAttrs: false,
    })
    defineProps<{
    wrapIn?: string | Component | undefined
    }>()
    const attrs = useAttrs()
    </script>
    <template>
    <component :is="wrapIn" v-if="wrapIn" v-bind="attrs">
    <slot />
    </component>
    <template v-else>
    <slot />
    </template>
    </template>
    <template>
    <AEditDialog
    ref="dialog"
    :title="t('playlist.editor.importFileDialog.title')"
    :save-label="t('playlist.editor.importFileDialog.saveLabel')"
    :title="t('media.editor.importFileDialog.title')"
    :save-label="t('media.editor.importFileDialog.saveLabel')"
    :can-save="canSave"
    :save="() => emit('save', url)"
    class="md:tw-w-[600px]"
    ......
    <template>
    <AEditDialog
    ref="dialog"
    :title="t('playlist.editor.addInputDialog.title')"
    :title="t('media.editor.addInputDialog.title')"
    :can-save="canSave"
    :save-label="t('playlist.editor.addInputDialog.saveLabel')"
    :save-label="t('media.editor.addInputDialog.saveLabel')"
    :save="() => emit('save', selectedInput as string)"
    class="tw-w-min"
    >
    ......
    <template>
    <AEditDialog
    ref="dialog"
    :title="t('playlist.editor.addM3uDialog.title')"
    :save-label="t('playlist.editor.addM3uDialog.saveLabel')"
    :title="t('media.editor.addM3uDialog.title')"
    :save-label="t('media.editor.addM3uDialog.saveLabel')"
    :can-save="canSave"
    :save="() => emit('save', url)"
    class="md:tw-w-[600px]"
    ......
    <template>
    <div v-if="playlistState.state !== 'ok' && playlistState.state !== 'missing'">
    <AAlert :title="t(`playlist.state.${playlistState.state}.title`)" is-warning>
    <div v-if="mediaState.state !== 'ok' && mediaState.state !== 'missing'">
    <AAlert :title="t(`media.state.${mediaState.state}.title`)" is-warning>
    <SafeHTML
    sanitize-preset="inline-noninteractive"
    :html="t(`playlist.state.${playlistState.state}.description`, { totalTime, offset })"
    :html="t(`media.state.${mediaState.state}.description`, { totalTime, offset })"
    />
    </AAlert>
    </div>
    ......@@ -13,20 +13,20 @@
    import { computed } from 'vue'
    import { useI18n } from '@/i18n'
    import { usePlaylistState } from '@/stores/playlists'
    import { Playlist } from '@/types'
    import { useMediaState } from '@/stores/media'
    import { Media } from '@/types'
    import { secondsToDurationString } from '@/util'
    import AAlert from '@/components/generic/AAlert.vue'
    import SafeHTML from '@/components/generic/SafeHTML'
    const props = defineProps<{
    requiredDurationSeconds: number
    playlist: Playlist
    media: Media
    }>()
    const { t } = useI18n()
    const totalTime = computed(() => secondsToDurationString(props.requiredDurationSeconds))
    const playlistState = usePlaylistState(() => props.playlist, props.requiredDurationSeconds)
    const offset = computed(() => secondsToDurationString(playlistState.value?.offset ?? 0))
    const mediaState = useMediaState(() => props.media, props.requiredDurationSeconds)
    const offset = computed(() => secondsToDurationString(mediaState.value?.offset ?? 0))
    </script>
    <template>
    <div class="tw-relative" v-bind="attrs">
    <SaveIndicator
    v-if="isUpdatingPlaylist"
    state="pending"
    class="tw-absolute tw-right-0 -tw-top-7 tw-z-10 -tw-translate-y-full"
    role="alert"
    aria-live="polite"
    :aria-label="t('playlist.editor.isSaving')"
    />
    <APlaylistDurationCheck
    v-if="playlist && playlist.entries.length > 0 && requiredDurationSeconds > 0"
    :playlist="playlist"
    <AMediaDurationCheck
    v-if="media && media.entries.length > 0 && requiredDurationSeconds > 0"
    :media="media"
    :required-duration-seconds="requiredDurationSeconds"
    class="tw-mb-6"
    />
    <APlaylistEntries
    v-if="entries.length > 0"
    v-model:entries="entries"
    <AMediaSources
    v-if="sources.length > 0"
    :sources="sources"
    class="tw-mb-6"
    :can-sort="!disabled"
    :can-edit="!disabled"
    />
    <p v-if="disabled && entries.length === 0" class="tw-m-0">
    {{ t('playlist.editor.noEntries') }}
    <p v-if="disabled && sources.length === 0" class="tw-m-0">
    {{ t('media.editor.noEntries') }}
    </p>
    <div
    v-if="nonAddedEntries.length > 0"
    class="tw-border-2 tw-border-rose-200 tw-border-solid tw-rounded tw-p-3 tw-mb-6"
    >
    <div v-if="entryUpdateErrorList.length > 0" class="tw-mb-2">
    {{ t('playlist.editor.entriesAdd.error') }}
    <AErrorList :errors="entryUpdateErrorList" />
    </div>
    <APlaylistEntries
    :entries="nonAddedEntries"
    :can-sort="false"
    :can-edit="false"
    class="tw-mb-3"
    />
    <div class="tw-flex tw-items-center tw-gap-3">
    <button
    type="button"
    class="btn btn-default btn-sm"
    :disabled="isUpdatingPlaylist"
    @click="retryAddEntriesToPlaylist"
    >
    <Loading v-if="isRetrying" class="tw-h-1 tw-mr-1" />
    {{ t('playlist.editor.entriesAdd.retry') }}
    </button>
    <button
    type="button"
    class="btn btn-danger btn-sm"
    :disabled="isUpdatingPlaylist"
    @click="nonAddedEntries = []"
    >
    {{ t('playlist.editor.entriesAdd.discard') }}
    </button>
    </div>
    </div>
    <section
    v-if="uploadedFiles.size > 0"
    class="tw-mb-6"
    :aria-label="t('playlist.editor.upload.pending')"
    >
    <ul class="tw-flex tw-flex-wrap tw-gap-2 tw-p-0 tw-m-0">
    <template v-for="[id, entry] in uploadedFiles" :key="id">
    <AUploadProgress
    as="li"
    :name="entry.name"
    :file-id="id"
    :retry="entry.retry"
    :error="entry.error"
    class="tw-max-w-xs"
    />
    </template>
    </ul>
    <section class="tw-mb-6 empty:tw-hidden">
    <AMediaSourceJobQueue :context-key="contextKey" :label="t('media.editor.upload.pending')" />
    </section>
    <fieldset
    ......@@ -97,9 +36,9 @@
    <div class="tw-place-self-center tw-mx-auto tw-flex tw-flex-col tw-gap-2 tw-items-center">
    <template v-if="isAllowedToAddFiles">
    <icon-system-uicons-file-upload class="tw-text-xl" />
    <p class="tw-mb-0">{{ t('playlist.editor.control.dropFiles') }}</p>
    <p class="tw-mb-0">{{ t('media.editor.control.dropFiles') }}</p>
    <p class="tw-text-gray-400 tw-text-sm tw-mb-1.5 tw-leading-none">
    {{ t('playlist.editor.control._or') }}
    {{ t('media.editor.control._or') }}
    </p>
    </template>
    <div class="tw-flex tw-flex-wrap tw-justify-center tw-items-center tw-gap-3">
    ......@@ -107,16 +46,16 @@
    v-if="isAllowedToAddFiles"
    type="button"
    class="btn btn-default"
    data-testid="playlist-editor:open-file-dialog"
    data-testid="media-editor:open-file-dialog"
    @click="openFileDialog()"
    >
    <icon-iconamoon-file-audio-thin class="tw-flex-none" />
    {{ t('playlist.editor.control.selectFiles') }}
    {{ t('media.editor.control.selectFiles') }}
    </button>
    <APermissionGuard show-permissions="program.add__import">
    <button type="button" class="btn btn-default" @click="importFileFromURL()">
    <icon-formkit-url class="tw-flex-none" />
    {{ t('playlist.editor.control.importFile') }}
    {{ t('media.editor.control.importFile') }}
    </button>
    <GetFileImportUrl v-slot="{ resolve }">
    <AFileUrlDialog @save="resolve($event)" @close="resolve(null)" />
    ......@@ -125,27 +64,27 @@
    </div>
    <div class="tw-flex tw-flex-wrap tw-justify-center tw-items-center tw-gap-3 tw-mt-1">
    <APermissionGuard show-permissions="program.add__stream">
    <button type="button" class="btn btn-default" @click="addStreamToPlaylist()">
    <button type="button" class="btn btn-default" @click="addStreamMediaSource()">
    <icon-solar-play-stream-bold class="tw-flex-none" />
    {{ t('playlist.editor.control.addStream') }}
    {{ t('media.editor.control.addStream') }}
    </button>
    <GetStreamUrl v-slot="{ resolve }">
    <AStreamURLDialog @save="resolve($event)" @close="resolve(null)" />
    </GetStreamUrl>
    </APermissionGuard>
    <APermissionGuard show-permissions="program.add__line">
    <button type="button" class="btn btn-default" @click="addInputToPlaylist">
    <button type="button" class="btn btn-default" @click="addInputMediaSource">
    <icon-game-icons-jack-plug class="tw-flex-none" />
    {{ t('playlist.editor.control.addInput') }}
    {{ t('media.editor.control.addInput') }}
    </button>
    <GetInputUrl v-slot="{ resolve }">
    <AInputUrlDialog @save="resolve($event)" @close="resolve(null)" />
    </GetInputUrl>
    </APermissionGuard>
    <APermissionGuard show-permissions="program.add__m3ufile">
    <button type="button" class="btn btn-default" @click="addM3utoPlaylist">
    <button type="button" class="btn btn-default" @click="addM3UMediaSource">
    <icon-ph-playlist-light class="tw-flex-none" />
    {{ t('playlist.editor.control.addM3u') }}
    {{ t('media.editor.control.addM3u') }}
    </button>
    <GetM3uUrl v-slot="{ resolve }">
    <AM3uUrlDialog @save="resolve($event)" @close="resolve(null)" />
    ......@@ -158,227 +97,99 @@
    </template>
    <script lang="ts" setup>
    import { APIResponseError, useErrorList } from '@rokoli/bnb/drf'
    import { createTemplatePromise, useDropZone, useFileDialog } from '@vueuse/core'
    import { partition } from 'lodash'
    import { ref, useAttrs, watch } from 'vue'
    import { computed, ref, useAttrs, watch } from 'vue'
    import { useCopy } from '@/form'
    import { useI18n } from '@/i18n'
    import { useFilesStore, usePlaylistStore } from '@/stores'
    import { File as TankFile, Playlist, PlaylistEntry, Show } from '@/types'
    import { ensureError, getFilenameFromURL, sanitizeHTML, useAsyncFunction } from '@/util'
    import AStreamURLDialog from '@/components/playlist/AStreamURLDialog.vue'
    import AFileUrlDialog from '@/components/playlist/AFileUrlDialog.vue'
    import AInputUrlDialog from '@/components/playlist/AInputUrlDialog.vue'
    import AM3uUrlDialog from '@/components/playlist/AM3uUrlDialog.vue'
    import Loading from '@/components/generic/Loading.vue'
    import AUploadProgress from '@/components/playlist/AUploadProgress.vue'
    import SaveIndicator from '@/components/generic/SaveIndicator.vue'
    import APlaylistDurationCheck from '@/components/playlist/APlaylistDurationCheck.vue'
    import APlaylistEntries from '@/components/playlist/APlaylistEntries.vue'
    import { Media, Show } from '@/types'
    import AStreamURLDialog from '@/components/media/AStreamURLDialog.vue'
    import AFileUrlDialog from '@/components/media/AFileUrlDialog.vue'
    import AInputUrlDialog from '@/components/media/AInputUrlDialog.vue'
    import AM3uUrlDialog from '@/components/media/AM3uUrlDialog.vue'
    import AMediaDurationCheck from '@/components/media/AMediaDurationCheck.vue'
    import AMediaSources from '@/components/media/AMediaSources.vue'
    import APermissionGuard from '@/components/generic/APermissionGuard.vue'
    import { useHasUserPermission } from '@/stores/auth'
    import { useToast } from 'vue-toast-notification'
    import AErrorList from '@/components/generic/AErrorList.vue'
    import { MediaResolver, useMediaSourceController } from '@/stores/media-manager'
    import AMediaSourceJobQueue from '@/components/media/AMediaSourceJobQueue.vue'
    import { usePlayoutStore } from '@/stores'
    const props = withDefaults(
    defineProps<{
    show: Show
    requiredDurationSeconds?: number
    playlist: Playlist | null
    getMedia: MediaResolver
    disabled?: boolean
    media?: Media | null
    contextKey?: string | null | undefined
    requiredDurationSeconds?: number
    }>(),
    {
    requiredDurationSeconds: -1,
    contextKey: undefined,
    media: null,
    },
    )
    const emit = defineEmits<{
    create: [Playlist]
    }>()
    const attrs = useAttrs()
    const { t } = useI18n()
    const fileStore = useFilesStore()
    const playlistStore = usePlaylistStore()
    const toast = useToast()
    const GetStreamUrl = createTemplatePromise<string | null>()
    const GetFileImportUrl = createTemplatePromise<string | null>()
    const GetInputUrl = createTemplatePromise<string | null>()
    const GetM3uUrl = createTemplatePromise<string | null>()
    const sources = computed(() => props.media?.entries ?? [])
    const entries = useCopy(() => props.playlist?.entries ?? [], {
    save: () => updatePlaylistEntries(),
    const playoutStore = usePlayoutStore()
    const mediaSourceManager = useMediaSourceController({
    show: () => props.show,
    contextKey: () => props.contextKey,
    getMedia: props.getMedia,
    })
    const entryUpdateError = ref<Error>()
    const entryUpdateErrorList = useErrorList(entryUpdateError)
    const nonAddedEntries = ref<Partial<PlaylistEntry>[]>([])
    const isRetrying = ref(false)
    const isAllowedToAddFiles = useHasUserPermission(['program.add__file'])
    const uploadedFiles = ref(
    new Map<
    TankFile['id'],
    { tankFile: TankFile; retry?: () => unknown; name: string; error?: Error }
    >(),
    )
    const dropzoneEl = ref<HTMLDivElement>()
    const { open: openFileDialog, files } = useFileDialog({ accept: 'audio/*', multiple: true })
    watch(files, (newFiles) => uploadFiles(Array.from(newFiles ?? [])))
    const { isOverDropZone } = useDropZone(dropzoneEl, {
    onDrop: (files) => {
    if (isAllowedToAddFiles.value) {
    uploadFiles(files ?? [])
    }
    },
    })
    async function retryUpload(tankFile: TankFile, file: File) {
    try {
    await fileStore.remove(tankFile.id)
    } catch (err) {
    const record = uploadedFiles.value.get(tankFile.id)
    if (record) record.error = ensureError(err)
    return
    function addFiles(files: File[] | FileList | null) {
    if (isAllowedToAddFiles.value) {
    mediaSourceManager.addFiles(files)
    }
    uploadedFiles.value.delete(tankFile.id)
    await uploadFiles([file])
    }
    function warnOnDiscardedFiles(discardedFiles: File[]) {
    if (discardedFiles.length === 0) return
    const hasOgg = discardedFiles.some((file) => file.name.endsWith('.ogg'))
    const isGecko = 'navigator' in globalThis && globalThis.navigator.userAgent.includes('Gecko/')
    const suffix = hasOgg && isGecko ? t('playlist.editor.uploadFiles.geckoBug') : ''
    const discardedFileList = `<ul>${discardedFiles.map((file) => `<li>${sanitizeHTML(file.name)}</li>`).join('\n')}</ul>`
    const message =
    t('playlist.editor.uploadFiles.discarded', { discardedFileList: discardedFileList }) + suffix
    toast.warning(message, { dismissible: true, duration: 0 })
    }
    async function uploadFiles(files: File[]) {
    const [audioFiles, discardedFiles] = partition(files, (f) => f.type.startsWith('audio/'))
    warnOnDiscardedFiles(discardedFiles)
    const fileAddResults = await Promise.allSettled(
    audioFiles.map((file) => {
    let _tankFile: TankFile | null = null
    return fileStore
    .uploadFile(file, props.show, {
    onDone: (tankFile) => uploadedFiles.value.delete(tankFile.id),
    onCreate: (tankFile) => {
    _tankFile = tankFile
    uploadedFiles.value.set(tankFile.id, {
    tankFile,
    retry: () => retryUpload(tankFile, file),
    name: file.name,
    })
    },
    })
    .catch((err) => {
    const _err = ensureError(err)
    if (_tankFile) {
    const record = uploadedFiles.value.get(_tankFile.id)
    if (record) record.error = _err
    }
    throw _err
    })
    }),
    )
    const addedFiles = fileAddResults
    .filter((r) => r.status === 'fulfilled')
    .map((r) => (r as PromiseFulfilledResult<TankFile>).value)
    if (addedFiles.length > 0) await addFileToPlaylist(...addedFiles)
    // files handled through file dialog
    const { open: openFileDialog, files } = useFileDialog({ accept: 'audio/*', multiple: true })
    watch(files, (files) => addFiles(files))
    const failedUploads = fileAddResults
    .filter((r) => r.status === 'rejected')
    .map((r) => ensureError((r as PromiseRejectedResult).reason))
    if (failedUploads.length > 0) {
    console.error('Some files failed to upload', failedUploads)
    }
    }
    // files handled through dropzone
    const dropzoneEl = ref<HTMLDivElement>()
    const { isOverDropZone, files: dropzoneFiles } = useDropZone(dropzoneEl)
    watch(dropzoneFiles, (files) => addFiles(files))
    // files imported by URL
    const GetFileImportUrl = createTemplatePromise<string | null>()
    async function importFileFromURL() {
    const fileUrl = await GetFileImportUrl.start()
    if (!fileUrl) return
    const tankFile = await fileStore.importFileURL(fileUrl, props.show, {
    onDone: (tankFile) => uploadedFiles.value.delete(tankFile.id),
    onCreate: (tankFile) => {
    uploadedFiles.value.set(tankFile.id, {
    tankFile,
    name: getFilenameFromURL(fileUrl),
    })
    },
    })
    await addFileToPlaylist(tankFile)
    mediaSourceManager.addImports(fileUrl)
    }
    async function createPlaylist() {
    const playlist = await playlistStore.create({ showId: props.show.id })
    emit('create', playlist)
    return playlist
    }
    async function addFileToPlaylist(...files: TankFile[]) {
    await updatePlaylistEntries(
    ...files.map((file) => ({ fileId: file.id, duration: file.duration })),
    )
    }
    async function addStreamToPlaylist() {
    // streams
    const GetStreamUrl = createTemplatePromise<string | null>()
    async function addStreamMediaSource() {
    const streamURL = await GetStreamUrl.start()
    if (!streamURL) return
    await updatePlaylistEntries({ uri: streamURL })
    mediaSourceManager.addUrls(streamURL)
    }
    async function addInputToPlaylist() {
    const inputUrl = await GetInputUrl.start()
    if (!inputUrl) return
    await updatePlaylistEntries({ uri: inputUrl })
    // inputs
    const GetInputUrl = createTemplatePromise<string | null>()
    async function addInputMediaSource() {
    const url = await GetInputUrl.start()
    if (!url) return
    const input = playoutStore.inputs.find((input) => input.uri === url)
    const name = input?.label ?? t('input.singular')
    mediaSourceManager.addUrls({ url, name })
    }
    async function addM3utoPlaylist() {
    // M3U
    const GetM3uUrl = createTemplatePromise<string | null>()
    async function addM3UMediaSource() {
    const m3uUrl = await GetM3uUrl.start()
    if (!m3uUrl) return
    await updatePlaylistEntries({ uri: m3uUrl })
    }
    type NewPlaylistEntry =
    | Pick<PlaylistEntry, 'fileId' | 'duration'>
    | Pick<PlaylistEntry, 'uri' | 'duration'>
    const { fn: updatePlaylistEntries, isProcessing: isUpdatingPlaylist } = useAsyncFunction(
    async function (...newEntries: Partial<NewPlaylistEntry>[]) {
    entryUpdateError.value = undefined
    try {
    const playlist = props.playlist ? props.playlist : await createPlaylist()
    await playlistStore.partialUpdate(playlist.id, {
    entries: [...entries.value, ...newEntries],
    })
    } catch (e) {
    if (e instanceof APIResponseError) {
    entryUpdateError.value = e
    }
    nonAddedEntries.value = [...nonAddedEntries.value, ...newEntries]
    throw e
    }
    },
    )
    async function retryAddEntriesToPlaylist() {
    if (nonAddedEntries.value.length === 0) return
    isRetrying.value = true
    const retryEntries = nonAddedEntries.value
    try {
    await updatePlaylistEntries(...retryEntries)
    nonAddedEntries.value = []
    } catch (e) {
    nonAddedEntries.value = retryEntries
    } finally {
    isRetrying.value = false
    }
    mediaSourceManager.addUrls(m3uUrl)
    }
    </script>
    ......@@ -2,10 +2,10 @@
    <component
    :is="as"
    class="tw-bg-gray-50 tw-border dark:tw-bg-neutral-800 dark:tw-border-neutral-700 tw-p-3 tw-rounded tw-flex tw-gap-2 tw-items-center"
    :class="{ 'tw-opacity-50 tw-pointer-events-none': isDeleted }"
    >
    <div>
    <icon-system-uicons-drag-vertical
    v-if="dragHandle"
    class="tw-flex-none tw-mr-2 tw-cursor-grab"
    data-drag-handle
    />
    ......@@ -19,7 +19,7 @@
    </template>
    <template v-else-if="type === 'file'">
    <icon-iconamoon-file-audio-thin class="tw-flex-none" />
    <span class="tw-truncate" data-testid="playlist-entry-editor:file-title">
    <span class="tw-truncate tw-min-w-[50px]" data-testid="media-source-editor:file-title">
    {{ file?.metadata?.title ?? t('file.unnamed') }}
    </span>
    <span
    ......@@ -28,23 +28,23 @@
    tabindex="0"
    >
    <span
    class="tw-rounded-full tw-px-3 tw-py-1 tw-border tw-border-solid tw-border-black/10 tw-inline-flex tw-items-center tw-gap-2 tw-cursor-help group-hocus:tw-bg-indigo-100 dark:group-hocus:tw-bg-indigo-800 group-hocus:tw-ring-2"
    class="tw-flex tw-rounded-full tw-px-3 tw-py-1 tw-border tw-border-solid tw-border-black/10 tw-items-center tw-gap-2 tw-cursor-help group-hocus:tw-bg-indigo-100 dark:group-hocus:tw-bg-indigo-800 group-hocus:tw-ring-2"
    >
    <icon-iconamoon-music-artist-thin />
    {{ t('file.metadata._title') }}
    <icon-iconamoon-music-artist-thin class="tw-flex-none" />
    <span class="tw-sr-only">{{ t('file.metadata._title') }}</span>
    </span>
    <SafeHTML
    :html="artistInfo"
    sanitize-preset="inline-noninteractive"
    class="tw-absolute tw-top-full tw-left-0 tw-mt-1 tw-p-3 tw-rounded tw-bg-white dark:tw-bg-neutral-800 tw-shadow-xl tw-z-10 tw-pointer-events-none tw-transition-all tw-opacity-0 -tw-translate-y-1 group-hocus:tw-opacity-100 group-hocus:tw-translate-y-0"
    class="tw-absolute tw-top-full tw-left-0 tw-mt-1 tw-p-3 tw-min-w-32 tw-rounded tw-bg-white dark:tw-bg-neutral-800 tw-shadow-xl tw-z-10 tw-pointer-events-none tw-transition-all tw-opacity-0 -tw-translate-y-1 group-hocus:tw-opacity-100 group-hocus:tw-translate-y-0"
    />
    </span>
    </template>
    <template v-else-if="['http', 'https'].includes(type)">
    <icon-solar-play-stream-bold class="tw-flex-none" />
    <span class="tw-truncate">
    {{ entry.uri }}
    {{ mediaSource.uri }}
    </span>
    </template>
    <template v-else-if="type === 'm3u'">
    ......@@ -56,8 +56,8 @@
    </span>
    <span class="tw-ml-auto">
    <template v-if="typeof entry.duration === 'number'">
    {{ secondsToDurationString(entry.duration) }}
    <template v-if="typeof mediaSource.duration === 'number'">
    {{ secondsToDurationString(mediaSource.duration) }}
    </template>
    <AStatus v-else class="tw-text-xs" rounded is-warning>
    {{ t('file.durationUnknown') }}
    ......@@ -66,7 +66,6 @@
    <div>
    <button
    v-if="canEdit"
    type="button"
    class="btn btn-default btn-sm tw-p-1 tw-rounded-full"
    @click="editDialog.open()"
    ......@@ -76,9 +75,8 @@
    </div>
    <AEditDialog
    v-if="canEdit"
    ref="editDialog"
    :title="t('playlist.editor.editPlaylistEntry')"
    :title="t('media.editor.editMediaSource')"
    :save="save"
    class="tw-w-[90vw] md:tw-w-min"
    >
    ......@@ -115,11 +113,11 @@
    type="button"
    class="btn btn-danger tw-ml-3 md:tw-ml-12"
    :disabled="isSaving"
    @click="emit('delete')"
    @click="remove"
    >
    <icon-system-uicons-trash />
    <span class="tw-hidden md:tw-block tw-whitespace-nowrap">
    {{ t('playlist.editor.deletePlaylistEntry') }}
    {{ t('media.editor.deleteMediaSource') }}
    </span>
    </button>
    </div>
    ......@@ -137,7 +135,11 @@
    </template>
    </AEditDialog>
    <AFileImportLog v-if="entry.fileId" ref="fileImportLogDialog" :file-id="entry.fileId" />
    <AFileImportLog
    v-if="mediaSource.fileId"
    ref="fileImportLogDialog"
    :file-id="mediaSource.fileId"
    />
    </component>
    </template>
    ......@@ -147,45 +149,45 @@ import { computed, ref } from 'vue'
    import { useCopy } from '@/form'
    import { useI18n } from '@/i18n'
    import { useFilesStore } from '@/stores'
    import { useFilesStore, useMediaSourceStore } from '@/stores'
    import { useInput } from '@/stores/playout'
    import { FileMetadata, PlaylistEntry } from '@/types'
    import { FileMetadata, MediaSource } from '@/types'
    import { parseTime, secondsToDurationString } from '@/util'
    import SafeHTML from '@/components/generic/SafeHTML'
    import AEditDialog from '@/components/generic/AEditDialog.vue'
    import FormTable from '@/components/generic/FormTable.vue'
    import FormGroup from '@/components/generic/FormGroup.vue'
    import AFileImportLog from '@/components/playlist/AFileImportLog.vue'
    import AFileImportLog from '@/components/media/AFileImportLog.vue'
    import AStatus from '@/components/generic/AStatus.vue'
    const entry = defineModel<Partial<PlaylistEntry>>('entry', { required: true })
    withDefaults(
    const props = withDefaults(
    defineProps<{
    dragHandle?: boolean
    canEdit?: boolean
    mediaSource: MediaSource
    as?: string
    }>(),
    {
    as: 'div',
    canEdit: true,
    dragHandle: false,
    },
    )
    const emit = defineEmits<{ delete: [] }>()
    const fileStore = useFilesStore()
    const mediaSourceStore = useMediaSourceStore()
    const { t } = useI18n()
    const editDialog = ref()
    const fileImportLogDialog = ref()
    const isDeleted = ref(false)
    const { obj: file } = useObjectFromStore(() => entry.value?.fileId ?? null, fileStore)
    const { obj: file } = useObjectFromStore(() => props.mediaSource.fileId ?? null, fileStore)
    const title = useCopy(() => file.value?.metadata?.title)
    const artist = useCopy(() => file.value?.metadata?.artist)
    const album = useCopy(() => file.value?.metadata?.album)
    const duration = useCopy(() => entry.value?.duration)
    const uri = computed(() => (entry.value.uri ? new URL(entry.value.uri as string) : null))
    const duration = useCopy(() => props.mediaSource.duration)
    const uri = computed(() =>
    props.mediaSource.uri ? new URL(props.mediaSource.uri as string) : null,
    )
    const type = computed(() => (uri.value ? uri.value.protocol.replace(':', '') : 'file'))
    const input = useInput(() => entry.value.uri ?? '')
    const input = useInput(() => props.mediaSource.uri ?? '')
    const artistInfo = computed(() => {
    const metadata = file.value?.metadata
    const artist = metadata?.artist ?? ''
    ......@@ -203,14 +205,14 @@ const artistInfo = computed(() => {
    function setDuration(event: Event) {
    const value = (event.target as HTMLInputElement).value
    // A duration of zero should be treated as undefined so that the
    // A duration of zero should be treated as null so that the
    // default playout behaviour for non-fixed-lengths entries can take over.
    duration.value = parseTime(value) || undefined
    duration.value = parseTime(value) || null
    }
    async function save() {
    if (duration.value !== entry.value.duration) {
    entry.value = { ...entry.value, duration: duration.value }
    if (duration.value !== props.mediaSource.duration) {
    await mediaSourceStore.partialUpdate(props.mediaSource.id, { duration: duration.value })
    }
    if (file.value) {
    ......@@ -226,4 +228,15 @@ async function save() {
    editDialog.value.close()
    }
    async function remove() {
    isDeleted.value = true
    editDialog.value.close()
    try {
    await mediaSourceStore.remove(props.mediaSource.id)
    } catch (e) {
    isDeleted.value = false
    throw e
    }
    }
    </script>
    <template>
    <component
    :is="as"
    ref="jobInfoEl"
    class="tw-rounded tw-block tw-m-0 tw-p-3 tw-border tw-bg-gray-50 dark:tw-bg-neutral-800 dark:tw-border-neutral-700"
    >
    <div class="tw-flex tw-items-center tw-gap-3">
    <span class="tw-truncate tw-leading-none/75 tw-flex-1">
    <span class="tw-block">{{ job.name }}</span>
    <span
    class="tw-block tw-text-sm tw-truncate tw-max-w-[95%] tw-text-gray-500 dark:tw-text-neutral-500"
    >
    <span>{{ label }}</span>
    <Dots v-if="!hasStopped" />
    </span>
    </span>
    <div class="tw-flex tw-gap-2 tw-mr-1 tw-flex-none">
    <template v-if="canBeCancelled">
    <ACircularProgress
    :progress="
    job.status.key === 'processing'
    ? job.status.progress ?? 'indeterminate'
    : 'indeterminate'
    "
    class="tw-size-8 tw-text-emerald-600 tw-grid-area-cover"
    :aria-label="t('file.uploadProgress')"
    />
    <button
    type="button"
    class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none"
    :title="t('cancel')"
    @click="cancel(job.id)"
    >
    <icon-system-uicons-close class="tw-w-6 tw-h-6" />
    <span class="tw-sr-only">{{ t('cancel') }}</span>
    </button>
    </template>
    <button
    v-if="canBeRestarted"
    type="button"
    class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none"
    :title="t('file.retryUpload')"
    @click="retry(job.id)"
    >
    <icon-pajamas-retry class="tw-size-4" />
    <span class="tw-sr-only">{{ t('file.retryUpload') }}</span>
    </button>
    <template v-if="hasStopped">
    <button
    v-if="fileImportLogDialog"
    type="button"
    class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none"
    :title="t('file.importLog.showImportLog')"
    @click="fileImportLogDialog.open()"
    >
    <icon-system-uicons-terminal class="tw-w-5 tw-h-5" />
    <span class="tw-sr-only">{{ t('file.importLog.showImportLog') }}</span>
    </button>
    <button
    type="button"
    class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none"
    :title="t('dismiss')"
    @click="dismiss(job.id)"
    >
    <icon-system-uicons-trash class="tw-w-5 tw-h-5" />
    <span class="tw-sr-only">{{ t('dismiss') }}</span>
    </button>
    </template>
    </div>
    </div>
    <div class="empty:tw-hidden">
    <AErrorList v-if="errors.length > 0" :errors="errors" class="tw-text-sm" />
    <SafeHTML
    v-else-if="description"
    as="p"
    :html="description"
    class="invalid-feedback tw-text-sm"
    sanitize-preset="inline-noninteractive"
    />
    <p v-if="mayHaveBeenRejectedBecauseOfFirefoxBug" class="invalid-feedback tw-text-sm">
    {{ t('media.editor.uploadedFiles.geckoBug') }}
    </p>
    </div>
    <AFileImportLog v-if="job.tankFile" ref="fileImportLogDialog" :file-id="job.tankFile.id" />
    </component>
    </template>
    <script setup lang="ts">
    import { match } from 'ts-pattern'
    import { useI18n } from '@/i18n'
    import ACircularProgress from '@/components/generic/ACircularProgress.vue'
    import Dots from '@/components/generic/Dots.vue'
    import {
    MediaSourceJob,
    useBasicMediaSourceController,
    useJobHasState,
    } from '@/stores/media-manager'
    import { computed, ref } from 'vue'
    import AFileImportLog from '@/components/media/AFileImportLog.vue'
    import SafeHTML from '@/components/generic/SafeHTML'
    import { useErrorList } from '@rokoli/bnb/drf'
    import AErrorList from '@/components/generic/AErrorList.vue'
    const props = withDefaults(
    defineProps<{
    job: MediaSourceJob
    as?: string
    }>(),
    {
    as: 'div',
    },
    )
    const { t, te } = useI18n()
    const { dismiss, cancel, retry } = useBasicMediaSourceController()
    const errors = useErrorList(() => props.job.error)
    const fileImportLogDialog = ref()
    const canBeCancelled = useJobHasState(() => props.job, ['queued', 'processing'])
    const hasStopped = useJobHasState(
    () => props.job,
    ['cancelled', 'failed', 'failedPermanently', 'rejectedLocally', 'rejectedRemotely'],
    )
    const canBeRestarted = useJobHasState(() => props.job, ['failed', 'cancelled'])
    const label = computed(() => {
    const { status } = props.job
    const state = (name: string) => () => t(`mediaSourceJob.state.label.${name}`)
    return match(status)
    .with({ key: 'queued' }, state('queued'))
    .with({ key: 'rejectedRemotely' }, state('discarded'))
    .with({ key: 'rejectedLocally', reason: 'notAnAudioFile' }, state('notAnAudioFile'))
    .with({ key: 'rejectedLocally' }, state('discarded'))
    .with({ key: 'failedPermanently' }, state('failed'))
    .with({ key: 'failed' }, state('failed'))
    .with({ key: 'processing', phase: 'media:save' }, state('savingMedia'))
    .with({ key: 'processing', phase: 'file:fetching' }, state('importingFile'))
    .with({ key: 'processing', phase: 'file:normalizing' }, state('normalizingFile'))
    .with({ key: 'processing' }, state('processing'))
    .with({ key: 'cancelled' }, state('cancelled'))
    .exhaustive()
    })
    const description = computed(() => {
    const key = `mediaSourceJob.state.description.${props.job.status.key}`
    return te(key) ? t(key) : null
    })
    // Firefox has an annoying bug that classifies some files as video/ogg despite them being audio.
    // We want users to know that they may be able to upload the file if they don’t use Firefox for it.
    // See: https://bugzilla.mozilla.org/show_bug.cgi?id=1240259
    // https://gitlab.servus.at/aura/aura/-/issues/389
    const mayHaveBeenRejectedBecauseOfFirefoxBug = computed(() => {
    const { name, status } = props.job
    // Job must be rejected because the provided file is seemingly not an audio file.
    if (status.key !== 'rejectedLocally' || status.reason !== 'notAnAudioFile') return false
    // File must be an ogg file
    if (!name.endsWith('.ogg')) return false
    // must be firefox
    if (!('navigator' in globalThis && globalThis.navigator.userAgent.includes('Gecko/')))
    return false
    return true
    })
    </script>
    <template>
    <AFragment v-if="queue.length > 0" :wrap-in="label ? FormGroup : undefined" :label="label">
    <ol class="tw-flex tw-flex-col tw-gap-2 tw-p-0 tw-m-0">
    <li v-for="job in queue" :key="job.id" class="tw-flex-1">
    <AMediaSourceJob class="tw-w-full" :job="job" />
    </li>
    </ol>
    </AFragment>
    </template>
    <script lang="ts" setup>
    import { useMediaSourceQueue } from '@/stores/media-manager'
    import AMediaSourceJob from '@/components/media/AMediaSourceJob.vue'
    import FormGroup from '@/components/generic/FormGroup.vue'
    import AFragment from '@/components/generic/AFragment.vue'
    const props = defineProps<{
    contextKey?: string | null | undefined
    label?: string | null
    }>()
    const queue = useMediaSourceQueue(() => props.contextKey)
    </script>
    <template>
    <component
    :is="entries.length === 1 ? 'div' : 'ol'"
    ref="playlistEntriesEl"
    class="tw-m-0 tw-p-0 tw-gap-1 playlist-entries"
    >
    <APlaylistEntryEditor
    v-for="(entry, index) in entries"
    :key="entry.fileId ?? entry.uri"
    v-model:entry="entries[index]"
    :as="entries.length === 1 ? 'div' : 'li'"
    :drag-handle="entries.length === 1 ? false : canSort"
    <ol ref="mediaSourcesEl" class="tw-m-0 tw-p-0 tw-gap-1 media-sources">
    <AMediaSourceEditor
    v-for="source in sources"
    :key="source.id"
    :media-source="source"
    as="li"
    :drag-handle="sources.length === 1 ? false : canSort"
    :can-edit="canEdit"
    @delete="entries.splice(index, 1)"
    />
    </component>
    </ol>
    </template>
    <script lang="ts" setup>
    import { useSortable } from '@vueuse/integrations/useSortable'
    import { ref } from 'vue'
    import { computed, ref } from 'vue'
    import { PlaylistEntry } from '@/types'
    import APlaylistEntryEditor from '@/components/playlist/APlaylistEntryEditor.vue'
    import { MediaSource } from '@/types'
    import AMediaSourceEditor from '@/components/media/AMediaSourceEditor.vue'
    import { useMediaSourceStore } from '@/stores'
    import { useCopy } from '@/form'
    import { useCached, watchDebounced } from '@vueuse/core'
    import { isEqual } from 'lodash'
    const entries = defineModel<Partial<PlaylistEntry>[]>('entries', { required: true })
    const props = withDefaults(
    defineProps<{
    sources: MediaSource[]
    canSort?: boolean
    canEdit?: boolean
    }>(),
    ......@@ -35,17 +34,26 @@ const props = withDefaults(
    },
    )
    const playlistEntriesEl = ref<HTMLOListElement>()
    useSortable(playlistEntriesEl, entries, {
    const mediaSourceStore = useMediaSourceStore()
    const orderedSources = useCopy(() => props.sources)
    const reorderData = useCached(
    computed(() => orderedSources.value.map((item, index) => ({ id: item.id, order: index }))),
    isEqual,
    )
    watchDebounced(reorderData, (data) => mediaSourceStore.reorder(data), { debounce: 200 })
    const mediaSourcesEl = ref<HTMLOListElement>()
    useSortable(mediaSourcesEl, orderedSources, {
    animation: 200,
    ghostClass: 'playlist-entry-drag-ghost',
    ghostClass: 'media-source-drag-ghost',
    handle: '[data-drag-handle]',
    disabled: !props.canSort,
    })
    </script>
    <style lang="postcss" scoped>
    .playlist-entries {
    .media-sources {
    display: grid;
    grid-template-columns: min-content minmax(100px, 1fr) max-content;
    ......@@ -58,7 +66,7 @@ useSortable(playlistEntriesEl, entries, {
    </style>
    <style lang="postcss">
    .playlist-entry-drag-ghost {
    .media-source-drag-ghost {
    @apply tw-rounded tw-opacity-40 tw-border-2 tw-border-solid tw-border-aura-primary;
    }
    </style>
    <template>
    <AEditDialog
    ref="dialog"
    :title="t('playlist.editor.addStreamDialog.title')"
    :save-label="t('playlist.editor.addStreamDialog.title')"
    :title="t('media.editor.addStreamDialog.title')"
    :save-label="t('media.editor.addStreamDialog.title')"
    :can-save="canSave"
    :save="() => emit('save', url)"
    class="md:tw-w-[600px]"
    ......
    <template>
    <component
    :is="as"
    class="tw-bg-emerald-100 tw-rounded-full tw-m-0 tw-pl-4 tw-pr-1 tw-py-1 tw-w-fit tw-flex tw-items-center tw-gap-3"
    :class="error ? 'tw-bg-rose-100' : 'tw-bg-emerald-100'"
    >
    <span class="tw-truncate tw-leading-none tw-text-black/75">
    <span class="tw-block tw-text-sm tw-font-medium">{{ name }}</span>
    <span class="tw-block tw-text-xs tw-truncate tw-max-w-[95%]" :title="error?.message">
    <template v-if="!error">
    {{ t(`playlist.editor.upload.step.${importProgress?.step ?? 'unknown'}`) }}<Dots />
    </template>
    <template v-else>{{ t('file.uploadError') }}: {{ error.message }}</template>
    </span>
    </span>
    <ACircularProgress
    v-if="!error"
    :progress="progress"
    class="tw-size-8 tw-flex-none tw-text-emerald-600"
    :aria-label="t('file.uploadProgress')"
    />
    <button
    v-else-if="error && retry"
    type="button"
    class="btn btn-default tw-p-0 tw-size-8 tw-rounded-full tw-justify-center tw-flex-none"
    :title="t('file.retryUpload')"
    @click="retry"
    >
    <icon-pajamas-retry class="tw-size-4" />
    <span class="tw-sr-only">{{ t('file.retryUpload') }}</span>
    </button>
    </component>
    </template>
    <script setup lang="ts">
    import { computed } from 'vue'
    import { useI18n } from '@/i18n'
    import { useFileUploadProgress } from '@/stores/files'
    import { File as TankFile } from '@/types'
    import ACircularProgress from '@/components/generic/ACircularProgress.vue'
    import Dots from '@/components/generic/Dots.vue'
    const props = withDefaults(
    defineProps<{
    as?: string
    fileId: TankFile['id']
    name: string
    retry?: (() => unknown) | undefined
    error?: Error | undefined
    }>(),
    {
    as: 'p',
    error: undefined,
    retry: undefined,
    },
    )
    const { t } = useI18n()
    const importProgress = useFileUploadProgress(() => props.fileId)
    const progress = computed(() => importProgress.value?.progress || 'indeterminate')
    </script>
    ......@@ -182,7 +182,7 @@ const { fn: create, isProcessing } = useAsyncFunction(async () => {
    rruleId: rruleId.value as RRule['id'],
    byWeekday: getWeekdayFromApiDate(firstDate.value),
    showId: showId.value as Show['id'],
    defaultPlaylistId: null,
    defaultMediaId: null,
    }
    if (lastDate.value) {
    ......
    0% Loading or .
    You are about to add 0 people to the discussion. Proceed with caution.
    Finish editing this message first!
    Please register or to comment