diff --git a/api/funkwhale_api/music/serializers.py b/api/funkwhale_api/music/serializers.py index 9a26361b3347f5ee069875167364092b0453d30f..756d18bfc1771b19afd6be99d328b42f1aaf4625 100644 --- a/api/funkwhale_api/music/serializers.py +++ b/api/funkwhale_api/music/serializers.py @@ -210,6 +210,7 @@ def serialize_album_track(track): class AlbumSerializer(OptionalDescriptionMixin, serializers.Serializer): + # XXX: remove in 1.0, it's expensive and can work with a filter/api call tracks = serializers.SerializerMethodField() artist = serializers.SerializerMethodField() cover = cover_field diff --git a/front/src/components/audio/AlbumEntries.vue b/front/src/components/audio/AlbumEntries.vue new file mode 100644 index 0000000000000000000000000000000000000000..c9218b33a55713dcd1f1b23006e98731cb691e70 --- /dev/null +++ b/front/src/components/audio/AlbumEntries.vue @@ -0,0 +1,57 @@ +<template> + <div class="album-entries"> + <div :class="[{active: currentTrack && isPlaying && track.id === currentTrack.id}, 'album-entry']" v-for="track in tracks" :key="track.id"> + <div class="actions"> + <play-button class="basic circular icon" :button-classes="['circular inverted orange icon button']" :discrete="true" :icon-only="true" :track="track"></play-button> + </div> + <div class="position">{{ prettyPosition(track.position) }}</div> + <div class="content ellipsis"> + <router-link :to="{name: 'library.tracks.detail', params: {id: track.id}}" class="discrete link"> + <strong>{{ track.title }}</strong><br> + </router-link> + </div> + <div class="meta"> + <template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](track.id)"> + <track-favorite-icon class="tiny" :track="track"></track-favorite-icon> + </template> + <human-duration v-if="track.uploads[0] && track.uploads[0].duration" :duration="track.uploads[0].duration"></human-duration> + </div> + </div> + </div> +</template> + +<script> +import _ from '@/lodash' +import axios from 'axios' +import ChannelEntryCard from '@/components/audio/ChannelEntryCard' +import PlayButton from '@/components/audio/PlayButton' +import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' +import { mapGetters } from "vuex" + + +export default { + props: { + tracks: Array, + }, + components: { + PlayButton, + TrackFavoriteIcon + }, + computed: { + ...mapGetters({ + currentTrack: "queue/currentTrack", + }), + + isPlaying () { + return this.$store.state.player.playing + }, + }, + methods: { + prettyPosition (position, size) { + var s = String(position); + while (s.length < (size || 2)) {s = "0" + s;} + return s; + } + } +} +</script> diff --git a/front/src/components/audio/ArtistLabel.vue b/front/src/components/audio/ArtistLabel.vue new file mode 100644 index 0000000000000000000000000000000000000000..6f2b6e211250b0a4a4435453e76829976bb76f90 --- /dev/null +++ b/front/src/components/audio/ArtistLabel.vue @@ -0,0 +1,26 @@ +<template> + <router-link class="artist-label ui image label" :to="route"> + <img :class="[{circular: artist.content_category != 'podcast'}]" v-if="artist.cover && artist.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](artist.cover.small_square_crop)" /> + <i :class="[artist.content_category != 'podcast' ? 'circular' : 'bordered', 'inverted violet users icon']" v-else /> + {{ artist.name }} + </router-link> +</template> + +<script> + +import {momentFormat} from '@/filters' + +export default { + props: { + artist: Object, + }, + computed: { + route () { + if (this.artist.channel) { + return {name: 'channels.detail', params: {id: this.artist.channel.uuid}} + } + return {name: 'library.artists.detail', params: {id: this.artist.id}} + } + } +} +</script> diff --git a/front/src/components/audio/ChannelEntries.vue b/front/src/components/audio/ChannelEntries.vue index 99ff45942063991d4ce5bdb521c0b4ea9aa2636c..af3f2794310de761ae895019bc5af70164fa73b8 100644 --- a/front/src/components/audio/ChannelEntries.vue +++ b/front/src/components/audio/ChannelEntries.vue @@ -15,7 +15,7 @@ <template v-if="!isLoading && objects.length === 0"> <div class="ui placeholder segment"> <div class="ui icon header"> - <i class="compact disc icon"></i> + <i class="music icon"></i> No results matching your query </div> </div> @@ -31,7 +31,7 @@ import ChannelEntryCard from '@/components/audio/ChannelEntryCard' export default { props: { filters: {type: Object, required: true}, - limit: {type: Number, default: 5}, + limit: {type: Number, default: 10}, }, components: { ChannelEntryCard diff --git a/front/src/components/audio/ChannelEntryCard.vue b/front/src/components/audio/ChannelEntryCard.vue index 3ceb89bfb32fe392cb99a36508dd1980bfdd6805..ade7f892c0750781300880fef3a783ad7c3a28ef 100644 --- a/front/src/components/audio/ChannelEntryCard.vue +++ b/front/src/components/audio/ChannelEntryCard.vue @@ -1,33 +1,67 @@ <template> - <div class="channel-entry-card"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-if="cover && cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" class="channel-image image" v-else src="../../assets/audio/default-cover.png"> + <div :class="[{active: currentTrack && isPlaying && entry.id === currentTrack.id}, 'channel-entry-card']"> + <div class="controls"> + <play-button class="basic circular icon" :discrete="true" :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'inverted orange', 'icon', 'button']" :track="entry"></play-button> + </div> + <img + @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" + class="channel-image image" + v-if="cover && cover.original" + v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> + <span + @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" + class="channel-image image" + v-else-if="entry.artist.content_category === 'podcast'">#{{ entry.position }}</span> + <img + @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" + class="channel-image image" + v-else-if="entry.album && entry.album.cover && entry.album.cover.original" + v-lazy="$store.getters['instance/absoluteUrl'](entry.album.cover.square_crop)"> + <img + @click="$router.push({name: 'library.tracks.detail', params: {id: entry.id}})" + class="channel-image image" + v-else + src="../../assets/audio/default-cover.png"> <div class="ellipsis content"> <strong> <router-link class="discrete link" :title="entry.title" :to="{name: 'library.tracks.detail', params: {id: entry.id}}"> {{ entry.title }} </router-link> </strong> - <div class="description"> - <human-date :date="entry.creation_date"></human-date><template v-if="duration"> · - <human-duration :duration="duration"></human-duration></template> - </div> + <br> + <human-date class="really discrete" :date="entry.creation_date"></human-date> </div> - <div class="controls"> - <play-button :icon-only="true" :is-playable="true" :button-classes="['ui', 'circular', 'orange', 'icon', 'button']" :track="entry"></play-button> + <div class="meta"> + <template v-if="$store.state.auth.authenticated && $store.getters['favorites/isFavorite'](entry.id)"> + <track-favorite-icon class="tiny" :track="entry"></track-favorite-icon> + </template> + <human-duration v-if="duration" :duration="duration"></human-duration> + </div> </div> </template> <script> import PlayButton from '@/components/audio/PlayButton' +import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' +import { mapGetters } from "vuex" + export default { props: ['entry'], components: { PlayButton, + TrackFavoriteIcon, }, computed: { + + ...mapGetters({ + currentTrack: "queue/currentTrack", + }), + + isPlaying () { + return this.$store.state.player.playing + }, imageUrl () { let url = '../../assets/audio/default-cover.png' let cover = this.cover @@ -42,9 +76,6 @@ export default { if (this.entry.cover) { return this.entry.cover } - if (this.entry.album && this.entry.album.cover) { - return this.entry.album.cover - } }, duration () { let uploads = this.entry.uploads.filter((e) => { @@ -60,7 +91,4 @@ export default { <!-- Add "scoped" attribute to limit CSS to this component only --> <style scoped> -.default-cover { - background-image: url("../../assets/audio/default-cover.png") !important; -} </style> diff --git a/front/src/components/audio/ChannelSerieCard.vue b/front/src/components/audio/ChannelSerieCard.vue index 7d4e246e510c31850e335ae4699dc4fb63c9072e..aa27a8b0f8537b58f353f87eefbcbc638c1db68a 100644 --- a/front/src/components/audio/ChannelSerieCard.vue +++ b/front/src/components/audio/ChannelSerieCard.vue @@ -1,10 +1,10 @@ <template> <div class="channel-serie-card"> <div class="two-images"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> - <img @click="$router.push({name: 'library.tracks.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png"> + <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> + <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png"> + <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-if="cover.original" v-lazy="$store.getters['instance/absoluteUrl'](cover.square_crop)"> + <img @click="$router.push({name: 'library.albums.detail', params: {id: serie.id}})" class="channel-image" v-else src="../../assets/audio/default-cover.png"> </div> <div class="content"> <strong> diff --git a/front/src/components/audio/ChannelSeries.vue b/front/src/components/audio/ChannelSeries.vue index 0de82fbd3538b97f3f23e4589f8f6284059694d0..5f9fa99d961e0745fa1784c5f092df4153c940cf 100644 --- a/front/src/components/audio/ChannelSeries.vue +++ b/front/src/components/audio/ChannelSeries.vue @@ -5,7 +5,12 @@ <div v-if="isLoading" class="ui inverted active dimmer"> <div class="ui loader"></div> </div> - <channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" /> + <template v-if="isPodcast"> + <channel-serie-card v-for="serie in objects" :serie="serie" :key="serie.id" /> + </template> + <div v-else class="ui app-cards cards"> + <album-card v-for="album in objects" :album="album" :key="album.id" /> + </div> <template v-if="nextPage"> <div class="ui hidden divider"></div> <button v-if="nextPage" @click="fetchData(nextPage)" :class="['ui', 'basic', 'button']"> @@ -27,14 +32,18 @@ import _ from '@/lodash' import axios from 'axios' import ChannelSerieCard from '@/components/audio/ChannelSerieCard' +import AlbumCard from '@/components/audio/album/Card' + export default { props: { filters: {type: Object, required: true}, + isPodcast: {type: Boolean, default: true}, limit: {type: Number, default: 5}, }, components: { - ChannelSerieCard + ChannelSerieCard, + AlbumCard, }, data () { return { diff --git a/front/src/components/audio/track/Row.vue b/front/src/components/audio/track/Row.vue index 9afd67dd384625c213521262afd065b3d339b29f..d655a21262757983b4bb6e403377baf87a5cd6be 100644 --- a/front/src/components/audio/track/Row.vue +++ b/front/src/components/audio/track/Row.vue @@ -26,7 +26,7 @@ </router-link> </td> <td colspan="4" v-if="track.uploads && track.uploads.length > 0"> - {{ time.parse(track.uploads[0].duration) }} + <human-duration :duration="track.uploads[0].duration"></human-duration> </td> <td colspan="4" v-else> <translate translate-context="*/*/*">N/A</translate> @@ -49,7 +49,6 @@ <script> import { mapGetters } from "vuex" -import time from '@/utils/time' import TrackFavoriteIcon from '@/components/favorites/TrackFavoriteIcon' import TrackPlaylistIcon from '@/components/playlists/TrackPlaylistIcon' import PlayButton from '@/components/audio/PlayButton' @@ -67,11 +66,6 @@ export default { TrackPlaylistIcon, PlayButton }, - data () { - return { - time - } - }, computed: { ...mapGetters({ currentTrack: "queue/currentTrack", diff --git a/front/src/components/common/HumanDuration.vue b/front/src/components/common/HumanDuration.vue index 3fc1ffdab3a6b9feb2d4f31fb426fc8d3445efd6..07b4fdd03f3e5ae414256d96eed6e7d89f9b5227 100644 --- a/front/src/components/common/HumanDuration.vue +++ b/front/src/components/common/HumanDuration.vue @@ -1,20 +1,13 @@ <template> <time :datetime="`${duration}s`"> - <template v-if="durationObj.hours">{{ durationObj.hours|padDuration }}:</template>{{ durationObj.minutes|padDuration }}:{{ durationObj.seconds|padDuration }} + {{ duration | duration}} </time> </template> <script> -import {secondsToObject} from '@/filters' - export default { props: { duration: {required: true}, }, - computed: { - durationObj () { - return secondsToObject(this.duration) - } - } } </script> diff --git a/front/src/components/library/AlbumBase.vue b/front/src/components/library/AlbumBase.vue index 9e43be1c97d8cc8930781ccfd9e00e144a619ce4..614f9be44af9a1678b606910bfdd9353613c2693 100644 --- a/front/src/components/library/AlbumBase.vue +++ b/front/src/components/library/AlbumBase.vue @@ -4,131 +4,127 @@ <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> </div> <template v-if="object"> - <section :class="['ui', 'head', {'with-background': object.cover.original}, 'vertical', 'center', 'aligned', 'stripe', 'segment']" :style="headerStyle" v-title="object.title"> - <div class="segment-content"> - <h2 class="ui center aligned icon header"> - <i class="circular inverted sound yellow icon"></i> - <div class="content"> - {{ object.title }} - <div v-html="subtitle"></div> - </div> - </h2> - <tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list> - <div class="ui hidden divider"></div> - <div class="header-buttons"> - - <div class="ui buttons"> - <play-button class="orange" :tracks="object.tracks"> - <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> - </play-button> - </div> - - <modal v-if="publicLibraries.length > 0" :show.sync="showEmbedModal"> - <div class="header"> - <translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate> - </div> - <div class="content"> - <div class="description"> - <embed-wizard type="album" :id="object.id" /> - + <section class="ui vertical stripe segment channel-serie"> + <div class="ui stackable grid container"> + <div class="ui seven wide column"> + <div v-if="isSerie" class="padded basic segment"> + <div class="ui two column grid" v-if="isSerie"> + <div class="column"> + <div class="large two-images"> + <img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)"> + <img class="channel-image" v-else src="../../assets/audio/default-cover.png"> + <img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)"> + <img class="channel-image" v-else src="../../assets/audio/default-cover.png"> + </div> </div> - </div> - <div class="actions"> - <div class="ui basic deny button"> - <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + <div class="ui column right aligned"> + <tags-list v-if="object.tags && object.tags.length > 0" :tags="object.tags"></tags-list> + <div class="ui small hidden divider"></div> + <human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration> + <template v-if="totalTracks > 0"> + <div class="ui hidden very small divider"></div> + <translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph" + translate-plural="%{ count } episodes" + :translate-n="totalTracks" + :translate-params="{count: totalTracks}"> + %{ count } episode + </translate> + <translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate> + </template> + <div class="ui small hidden divider"></div> + <play-button class="orange" :tracks="object.tracks"></play-button> + <div class="ui hidden horizontal divider"></div> + <album-dropdown + :object="object" + :public-libraries="publicLibraries" + :is-loading="isLoading" + :is-album="isAlbum" + :is-serie="isSerie" + :is-channel="isChannel" + :artist="artist"></album-dropdown> </div> </div> - </modal> - <div class="ui buttons"> - <button class="ui button" @click="$refs.dropdown.click()"> - <translate translate-context="*/*/Button.Label/Noun">More…</translate> - </button> - <div class="ui floating dropdown icon button" ref="dropdown" v-dropdown> - <i class="dropdown icon"></i> - <div class="menu"> - <div - role="button" - v-if="publicLibraries.length > 0" - @click="showEmbedModal = !showEmbedModal" - class="basic item"> - <i class="code icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> - </div> - <a :href="wikipediaUrl" target="_blank" rel="noreferrer noopener" class="basic item"> - <i class="wikipedia w icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Search on Wikipedia</translate> - </a> - <a v-if="musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item"> - <i class="external icon"></i> - <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> - </a> - <a :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item"> - <i class="external icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate> - </a> - <router-link - v-if="object.is_local" - :to="{name: 'library.albums.edit', params: {id: object.id }}" - class="basic item"> - <i class="edit icon"></i> - <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> - </router-link> - <dangerous-button - :class="['ui', {loading: isLoading}, 'item']" - v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername" - @confirm="remove()"> - <i class="ui trash icon"></i> - <translate translate-context="*/*/*/Verb">Delete…</translate> - <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p> - <div slot="modal-content"> - <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p> - </div> - <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> - </dangerous-button> - <div class="divider"></div> - <div - role="button" - class="basic item" - v-for="obj in getReportableObjs({album: object})" - :key="obj.target.type + obj.target.id" - @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> - <i class="share icon" /> {{ obj.label }} - </div> - <div class="divider"></div> - <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}"> - <i class="wrench icon"></i> - <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> - </router-link> - <a - v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" - class="basic item" - :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" - target="_blank" rel="noopener noreferrer"> - <i class="wrench icon"></i> - <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> - </a> - </div> + <div class="ui small hidden divider"></div> + <header> + <h2 class="ui header" :title="object.title"> + {{ object.title }} + </h2> + <artist-label :artist="artist"></artist-label> + </header> + </div> + <div v-else class="ui center aligned text padded basic segment"> + <img class="channel-image" v-if="object.cover && object.cover.original" v-lazy="$store.getters['instance/absoluteUrl'](object.cover.square_crop)"> + <img class="channel-image" v-else src="../../assets/audio/default-cover.png"> + <div class="ui hidden divider"></div> + <header> + <h2 class="ui header" :title="object.title"> + {{ object.title }} + </h2> + <artist-label class="rounded" :artist="artist"></artist-label> + </header> + <div class="ui small hidden divider"></div> + <template v-if="totalTracks > 0"> + <div class="ui hidden very small divider"></div> + <translate key="1" v-if="isSerie" translate-context="Content/Channel/Paragraph" + translate-plural="%{ count } episodes" + :translate-n="totalTracks" + :translate-params="{count: totalTracks}"> + %{ count } episode + </translate> + <translate v-else translate-context="*/*/*" :translate-params="{count: totalTracks}" :translate-n="totalTracks" translate-plural="%{ count } tracks">%{ count } track</translate> · + </template> + <human-duration v-if="totalDuration > 0" :duration="totalDuration"></human-duration> + <div class="ui small hidden divider"></div> + <play-button class="orange" :tracks="object.tracks"></play-button> + <div class="ui horizontal hidden divider"></div> + <album-dropdown + :object="object" + :public-libraries="publicLibraries" + :is-loading="isLoading" + :is-album="isAlbum" + :is-serie="isSerie" + :is-channel="isChannel" + :artist="artist"></album-dropdown> + <div v-if="(object.tags && object.tags.length > 0) || object.description || $store.state.auth.authenticated && object.is_local"> + <div class="ui small hidden divider"></div> + <div class="ui divider"></div> + <div class="ui small hidden divider"></div> + <template v-if="object.tags && object.tags.length > 0" > + <tags-list :tags="object.tags"></tags-list> + <div class="ui small hidden divider"></div> + </template> + <rendered-description + v-if="object.description" + :content="object.description" + :can-update="false"></rendered-description> + <router-link v-else-if="$store.state.auth.authenticated && object.is_local" :to="{name: 'library.albums.edit', params: {id: object.id }}"> + <i class="pencil icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Add a description…</translate> + </router-link> </div> </div> </div> + <rendered-description + v-if="isSerie" + :content="object.description" + :can-update="false"></rendered-description> + <div class="nine wide column"> + <router-view v-if="object" :is-serie="isSerie" :artist="artist" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view> + </div> </div> </section> - <router-view v-if="object" :discs="discs" @libraries-loaded="libraries = $event" :object="object" object-type="album" :key="$route.fullPath"></router-view> </template> </main> </template> <script> import axios from "axios" -import logger from "@/logging" +import lodash from "@/lodash" import backend from "@/audio/backend" import PlayButton from "@/components/audio/PlayButton" -import EmbedWizard from "@/components/audio/EmbedWizard" -import Modal from '@/components/semantic/Modal' import TagsList from "@/components/tags/List" -import ReportMixin from '@/components/mixins/Report' - -const FETCH_URL = "albums/" +import ArtistLabel from '@/components/audio/ArtistLabel' +import AlbumDropdown from './AlbumDropdown' function groupByDisc(acc, track) { @@ -143,13 +139,12 @@ function groupByDisc(acc, track) { } export default { - mixins: [ReportMixin], props: ["id"], components: { PlayButton, - EmbedWizard, - Modal, TagsList, + ArtistLabel, + AlbumDropdown, }, data() { return { @@ -158,26 +153,24 @@ export default { artist: null, discs: [], libraries: [], - showEmbedModal: false } }, - created() { - this.fetchData() + async created() { + await this.fetchData() }, methods: { - fetchData() { - var self = this + async fetchData() { this.isLoading = true - let url = FETCH_URL + this.id + "/" - logger.default.debug('Fetching album "' + this.id + '"') - axios.get(url, {params: {refresh: 'true'}}).then(response => { - self.object = backend.Album.clean(response.data) - self.discs = self.object.tracks.reduce(groupByDisc, []) - axios.get(`artists/${response.data.artist.id}/`).then(response => { - self.artist = response.data - }) - self.isLoading = false - }) + let albumResponse = await axios.get(`albums/${this.id}/`, {params: {refresh: 'true'}}) + let artistResponse = await axios.get(`artists/${albumResponse.data.artist.id}/`) + this.artist = artistResponse.data + if (this.artist.channel) { + this.artist.channel.artist = this.artist + } + this.object = backend.Album.clean(albumResponse.data) + this.discs = this.object.tracks.reduce(groupByDisc, []) + this.isLoading = false + }, remove () { let self = this @@ -193,9 +186,30 @@ export default { } }, computed: { + totalTracks () { + return this.object.tracks.length + }, + isChannel () { + return this.object.artist.channel + }, + isSerie () { + return this.object.artist.content_category === 'podcast' + }, + isAlbum () { + return this.object.artist.content_category === 'music' + }, + totalDuration () { + let durations = [0] + this.object.tracks.forEach((t) => { + if (t.uploads[0] && t.uploads[0].duration) { + durations.push(t.uploads[0].duration) + } + }) + return lodash.sum(durations) + }, labels() { return { - title: this.$pgettext('*/*/*', 'Album') + title: this.$pgettext('*/*/*', 'Album'), } }, publicLibraries () { @@ -203,39 +217,6 @@ export default { return l.privacy_level === 'everyone' }) }, - wikipediaUrl() { - return ( - "https://en.wikipedia.org/w/index.php?search=" + - encodeURI(this.object.title + " " + this.object.artist.name) - ) - }, - musicbrainzUrl() { - if (this.object.mbid) { - return "https://musicbrainz.org/release/" + this.object.mbid - } - }, - discogsUrl() { - return ( - "https://discogs.com/search/?type=release&title=" + - encodeURI(this.object.title) + "&artist=" + - encodeURI(this.object.artist.name) - ) - }, - headerStyle() { - if (!this.object.cover.original) { - return "" - } - return ( - "background-image: url(" + - this.$store.getters["instance/absoluteUrl"](this.object.cover.original) + - ")" - ) - }, - subtitle () { - let route = this.$router.resolve({name: 'library.artists.detail', params: {id: this.object.artist.id }}) - let msg = this.$npgettext('Content/Album/Header.Title', 'Album containing %{ count } track, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', 'Album containing %{ count } tracks, by <a class="internal" href="%{ artistUrl }">%{ artist }</a>', this.object.tracks.length) - return this.$gettextInterpolate(msg, {count: this.object.tracks.length, artist: this.object.artist.name, artistUrl: route.href}) - } }, watch: { id() { diff --git a/front/src/components/library/AlbumDetail.vue b/front/src/components/library/AlbumDetail.vue index a3b924d940ae6eb0b030f5095f107cf62d6a5966..f4a8fa6aa9ec9564baa617ac876e47150a746930 100644 --- a/front/src/components/library/AlbumDetail.vue +++ b/front/src/components/library/AlbumDetail.vue @@ -1,35 +1,33 @@ <template> <div v-if="object"> - <template v-if="discs && discs.length > 1"> - <section v-for="(tracks, disc_number) in discs" class="ui vertical stripe segment"> + <h2 class="ui header"> + <translate key="1" v-if="isSerie" translate-context="Content/Channels/*">Episodes</translate> + <translate key="2" v-else translate-context="*/*/*">Tracks</translate> + </h2> + <channel-entries v-if="artist.channel && isSerie" :limit="50" :filters="{channel: artist.channel.uuid, ordering: '-creation_date'}"> + </channel-entries> + <template v-else-if="discs && discs.length > 1"> + <div v-for="(tracks, discNumber) in discs" :key="discNumber"> + <div class="ui hidden divider"></div> <translate - tag="h2" - class="left floated" - :translate-params="{number: disc_number + 1}" + tag="h3" + :translate-params="{number: discNumber + 1}" translate-context="Content/Album/" >Volume %{ number }</translate> - <play-button class="right floated orange" :tracks="tracks"> - <translate translate-context="Content/Queue/Button.Label/Short, Verb">Play all</translate> - </play-button> - <track-table :artist="object.artist" :display-position="true" :tracks="tracks"></track-table> - </section> + <album-entries :tracks="tracks"></album-entries> + </div> </template> <template v-else> - <section class="ui vertical stripe segment"> - <h2> - <translate translate-context="*/*/*">Tracks</translate> - </h2> - <track-table v-if="object" :artist="object.artist" :display-position="true" :tracks="object.tracks"></track-table> - </section> + <album-entries :tracks="object.tracks"></album-entries> </template> - <section class="ui vertical stripe segment"> + <template v-if="!artist.channel && !isSerie"> <h2> <translate translate-context="Content/*/Title/Noun">User libraries</translate> </h2> <library-widget @loaded="$emit('libraries-loaded', $event)" :url="'albums/' + object.id + '/libraries/'"> <translate slot="subtitle" translate-context="Content/Album/Paragraph">This album is present in the following libraries:</translate> </library-widget> - </section> + </template> </div> </template> @@ -41,11 +39,15 @@ import url from "@/utils/url" import logger from "@/logging" import LibraryWidget from "@/components/federation/LibraryWidget" import TrackTable from "@/components/audio/track/Table" +import ChannelEntries from '@/components/audio/ChannelEntries' +import AlbumEntries from '@/components/audio/AlbumEntries' export default { - props: ["object", "libraries", "discs"], + props: ["object", "libraries", "discs", "isSerie", "artist"], components: { LibraryWidget, + AlbumEntries, + ChannelEntries, TrackTable }, data() { diff --git a/front/src/components/library/AlbumDropdown.vue b/front/src/components/library/AlbumDropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..8a065833ebe0e71d0c6964663ca4936eb20e26dc --- /dev/null +++ b/front/src/components/library/AlbumDropdown.vue @@ -0,0 +1,134 @@ +<template> + <span> + + <modal v-if="isEmbedable" :show.sync="showEmbedModal"> + <div class="header"> + <translate translate-context="Popup/Album/Title/Verb">Embed this album on your website</translate> + </div> + <div class="content"> + <div class="description"> + <embed-wizard type="album" :id="object.id" /> + + </div> + </div> + <div class="actions"> + <div class="ui basic deny button"> + <translate translate-context="*/*/Button.Label/Verb">Cancel</translate> + </div> + </div> + </modal> + <div role="button" class="ui floating dropdown circular icon basic button" :title="labels.more" v-dropdown="{direction: 'downward'}"> + <i class="ellipsis vertical icon"></i> + <div class="menu"> + <div + role="button" + v-if="isEmbedable" + @click="showEmbedModal = !showEmbedModal" + class="basic item"> + <i class="code icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Embed</translate> + </div> + <a v-if="isAlbum && musicbrainzUrl" :href="musicbrainzUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="external icon"></i> + <translate translate-context="Content/*/*/Clickable, Verb">View on MusicBrainz</translate> + </a> + <a v-if="!isChannel && isAlbum" :href="discogsUrl" target="_blank" rel="noreferrer noopener" class="basic item"> + <i class="external icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Search on Discogs</translate> + </a> + <router-link + v-if="object.is_local" + :to="{name: 'library.albums.edit', params: {id: object.id }}" + class="basic item"> + <i class="edit icon"></i> + <translate translate-context="Content/*/Button.Label/Verb">Edit</translate> + </router-link> + <dangerous-button + :class="['ui', {loading: isLoading}, 'item']" + v-if="artist && $store.state.auth.authenticated && artist.channel && artist.attributed_to.full_username === $store.state.auth.fullUsername" + @confirm="remove()"> + <i class="ui trash icon"></i> + <translate translate-context="*/*/*/Verb">Delete…</translate> + <p slot="modal-header"><translate translate-context="Popup/Channel/Title">Delete this album?</translate></p> + <div slot="modal-content"> + <p><translate translate-context="Content/Moderation/Paragraph">The album will be deleted, as well as any related files and data. This action is irreversible.</translate></p> + </div> + <p slot="modal-confirm"><translate translate-context="*/*/*/Verb">Delete</translate></p> + </dangerous-button> + <div class="divider"></div> + <div + role="button" + class="basic item" + v-for="obj in getReportableObjs({album: object, channel: artist.channel})" + :key="obj.target.type + obj.target.id" + @click.stop.prevent="$store.dispatch('moderation/report', obj.target)"> + <i class="share icon" /> {{ obj.label }} + </div> + <div class="divider"></div> + <router-link class="basic item" v-if="$store.state.auth.availablePermissions['library']" :to="{name: 'manage.library.albums.detail', params: {id: object.id}}"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link">Open in moderation interface</translate> + </router-link> + <a + v-if="$store.state.auth.profile && $store.state.auth.profile.is_superuser" + class="basic item" + :href="$store.getters['instance/absoluteUrl'](`/api/admin/music/album/${object.id}`)" + target="_blank" rel="noopener noreferrer"> + <i class="wrench icon"></i> + <translate translate-context="Content/Moderation/Link/Verb">View in Django's admin</translate> + </a> + </div> + </div> + </span> +</template> +<script> +import EmbedWizard from "@/components/audio/EmbedWizard" +import Modal from '@/components/semantic/Modal' +import ReportMixin from '@/components/mixins/Report' + + +export default { + mixins: [ReportMixin], + props: { + isLoading: Boolean, + artist: Object, + object: Object, + publicLibraries: Array, + isAlbum: Boolean, + isChannel: Boolean, + isSerie: Boolean, + }, + components: { + EmbedWizard, + Modal, + }, + data () { + return { + showEmbedModal: false, + } + }, + computed: { + labels() { + return { + more: this.$pgettext('*/*/Button.Label/Noun', "More…"), + } + }, + isEmbedable () { + return (this.isChannel && this.artist.channel.actor) || this.publicLibraries.length > 0 + }, + + musicbrainzUrl() { + if (this.object.mbid) { + return "https://musicbrainz.org/release/" + this.object.mbid + } + }, + discogsUrl() { + return ( + "https://discogs.com/search/?type=release&title=" + + encodeURI(this.object.title) + "&artist=" + + encodeURI(this.object.artist.name) + ) + }, + } +} +</script> diff --git a/front/src/components/library/TrackDetail.vue b/front/src/components/library/TrackDetail.vue index d271ed88cfa3f365e3a8d1b63081e5939cd71eb6..ffc4be8183b9037153b9fc9a43c3a9a08d17a8e3 100644 --- a/front/src/components/library/TrackDetail.vue +++ b/front/src/components/library/TrackDetail.vue @@ -16,7 +16,7 @@ <translate translate-context="Content/*/*">Duration</translate> </td> <td class="right aligned"> - <template v-if="upload.duration">{{ time.parse(upload.duration) }}</template> + <template v-if="upload.duration">{{ upload.duration | duration }}</template> <translate v-else translate-context="*/*/*">N/A</translate> </td> </tr> @@ -60,7 +60,7 @@ <rendered-description :content="track.description" - can-update="false"></rendered-description> + :can-update="false"></rendered-description> <h2 class="ui header"> <translate translate-context="Content/*/*">Release Details</translate> </h2> @@ -154,7 +154,6 @@ </template> <script> -import time from "@/utils/time" import axios from "axios" import url from "@/utils/url" import logger from "@/logging" @@ -173,7 +172,6 @@ export default { }, data() { return { - time, id: this.track.id, licenseData: null } diff --git a/front/src/components/metadata/ArtistCard.vue b/front/src/components/metadata/ArtistCard.vue deleted file mode 100644 index 531c2645c147c2504ea2856feb57923572da4bfe..0000000000000000000000000000000000000000 --- a/front/src/components/metadata/ArtistCard.vue +++ /dev/null @@ -1,69 +0,0 @@ -<template> - <div class="ui card"> - <div class="content"> - <div v-if="isLoading" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <template v-if="data.id"> - <header class="header"> - <a :href="getMusicbrainzUrl('artist', data.id)" target="_blank" :title="labels.musicbrainz">{{ data.name }}</a> - </header> - <div class="description"> - <table class="ui very basic fixed single line compact table"> - <tbody> - <tr v-for="group in releasesGroups"> - <td> - {{ group['first-release-date'] }} - </td> - <td colspan="3"> - <a :href="getMusicbrainzUrl('release-group', group.id)" class="discrete link" target="_blank" :title="labels.musicbrainz"> - {{ group.title }} - </a> - </td> - <td> - </td> - </tr> - </tbody> - </table> - </div> - </template> - </div> - </div> -</template> - -<script> -import Vue from "vue" -import CardMixin from "./CardMixin" -import time from "@/utils/time" - -export default Vue.extend({ - mixins: [CardMixin], - data() { - return { - time - } - }, - computed: { - labels() { - return { - musicbrainz: this.$pgettext('Content/*/*/Clickable, Verb', "View on MusicBrainz") - } - }, - type() { - return "artist" - }, - releasesGroups() { - return this.data["release-group-list"].filter(r => { - return r.type === "Album" - }) - } - } -}) -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> -.ui.card { - width: 100% !important; -} -</style> diff --git a/front/src/components/metadata/CardMixin.vue b/front/src/components/metadata/CardMixin.vue deleted file mode 100644 index a7cd476f6c2d534eb04c6bf2f1038711a56dc897..0000000000000000000000000000000000000000 --- a/front/src/components/metadata/CardMixin.vue +++ /dev/null @@ -1,47 +0,0 @@ -<template> - -</template> - -<script> -import axios from 'axios' -import logger from '@/logging' - -export default { - props: { - mbId: {type: String, required: true} - }, - created: function () { - this.fetchData() - }, - data: function () { - return { - isLoading: false, - data: {} - } - }, - methods: { - fetchData () { - let self = this - this.isLoading = true - let url = 'providers/musicbrainz/' + this.type + 's/' + this.mbId + '/' - axios.get(url).then((response) => { - logger.default.info('successfully fetched', self.type, self.mbId) - self.data = response.data[self.type] - this.$emit('metadata-changed', self.data) - self.isLoading = false - }, (response) => { - logger.default.error('error while fetching', self.type, self.mbId) - self.isLoading = false - }) - }, - getMusicbrainzUrl (type, id) { - return 'https://musicbrainz.org/' + type + '/' + id - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> - -</style> diff --git a/front/src/components/metadata/ReleaseCard.vue b/front/src/components/metadata/ReleaseCard.vue deleted file mode 100644 index 08a0fe4a596f57e962b3be6525d0c2c19ecd4e4e..0000000000000000000000000000000000000000 --- a/front/src/components/metadata/ReleaseCard.vue +++ /dev/null @@ -1,71 +0,0 @@ -<template> - <div class="ui card"> - <div class="content"> - <div v-if="isLoading" class="ui vertical segment"> - <div :class="['ui', 'centered', 'active', 'inline', 'loader']"></div> - </div> - <template v-if="data.id"> - <div class="header"> - <a :href="getMusicbrainzUrl('release', data.id)" target="_blank" :title="labels.musicbrainz">{{ data.title }}</a> - </div> - <div class="meta"> - <a :href="getMusicbrainzUrl('artist', data['artist-credit'][0]['artist']['id'])" target="_blank" :title="labels.musicbrainz">{{ data['artist-credit-phrase'] }}</a> - </div> - <div class="description"> - <table class="ui very basic fixed single line compact table"> - <tbody> - <tr v-for="track in tracks"> - <td> - {{ track.position }} - </td> - <td colspan="3"> - <a :href="getMusicbrainzUrl('recording', track.id)" class="discrete link" target="_blank" :title="labels.musicbrainz"> - {{ track.recording.title }} - </a> - </td> - <td> - {{ time.parse(parseInt(track.length) / 1000) }} - </td> - </tr> - </tbody> - </table> - </div> - </template> - </div> - </div> -</template> - -<script> -import Vue from 'vue' -import CardMixin from './CardMixin' -import time from '@/utils/time' - -export default Vue.extend({ - mixins: [CardMixin], - data () { - return { - time - } - }, - computed: { - labels () { - return { - musicbrainz: this.$pgettext('Content/*/*/Clickable, Verb', 'View on MusicBrainz') - } - }, - type () { - return 'release' - }, - tracks () { - return this.data['medium-list'][0]['track-list'] - } - } -}) -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped lang="scss"> -.ui.card { - width: 100% !important; -} -</style> diff --git a/front/src/components/metadata/Search.vue b/front/src/components/metadata/Search.vue deleted file mode 100644 index f7feb511f095741b77d02e2e45f4fe012173be53..0000000000000000000000000000000000000000 --- a/front/src/components/metadata/Search.vue +++ /dev/null @@ -1,158 +0,0 @@ -<template> - <div> - <div class="ui form"> - <div class="inline fields"> - <div v-for="type in types" class="field"> - <div class="ui radio checkbox"> - <input type="radio" :value="type.value" v-model="currentType"> - <label >{{ type.label }}</label> - </div> - </div> - </div> - </div> - <div class="ui fluid search"> - <div class="ui icon input"> - <input class="prompt" :placeholder="labels.placeholder" type="text"> - <i class="search icon"></i> - </div> - <div class="results"></div> - </div> - </div> -</template> - -<script> -import jQuery from 'jquery' - -export default { - props: { - mbType: {type: String, required: false}, - mbId: {type: String, required: false} - }, - data: function () { - return { - currentType: this.mbType || 'artist', - currentId: this.mbId || '' - } - }, - - mounted: function () { - jQuery(this.$el).find('.ui.checkbox').checkbox() - this.setUpSearch() - }, - methods: { - - setUpSearch () { - var self = this - jQuery(this.$el).search({ - minCharacters: 3, - onSelect (result, response) { - self.currentId = result.id - }, - apiSettings: { - beforeXHR: function (xhrObject, s) { - xhrObject.setRequestHeader('Authorization', self.$store.getters['auth/header']) - return xhrObject - }, - onResponse: function (initialResponse) { - let category = self.currentTypeObject.value - let results = initialResponse[category + '-list'].map(r => { - let description = [] - if (category === 'artist') { - if (r.type) { - description.push(r.type) - } - if (r.area) { - description.push(r.area.name) - } else if (r['begin-area']) { - description.push(r['begin-area'].name) - } - return { - title: r.name, - id: r.id, - description: description.join(' - ') - } - } - if (category === 'release') { - if (r['medium-track-count']) { - description.push( - r['medium-track-count'] + ' tracks' - ) - } - if (r['artist-credit-phrase']) { - description.push(r['artist-credit-phrase']) - } - if (r['date']) { - description.push(r['date']) - } - return { - title: r.title, - id: r.id, - description: description.join(' - ') - } - } - if (category === 'recording') { - if (r['artist-credit-phrase']) { - description.push(r['artist-credit-phrase']) - } - return { - title: r.title, - id: r.id, - description: description.join(' - ') - } - } - }) - return {results: results} - }, - url: this.searchUrl - } - }) - } - }, - computed: { - labels () { - return { - placeholder: this.$pgettext('Content/Library/Input.Placeholder/Verb', 'Enter your search query…') - } - }, - currentTypeObject: function () { - let self = this - return this.types.filter(t => { - return t.value === self.currentType - })[0] - }, - searchUrl: function () { - return this.$store.getters['instance/absoluteUrl']('api/v1/providers/musicbrainz/search/' + this.currentTypeObject.value + 's/?query={query}') - }, - types: function () { - return [ - { - value: 'artist', - label: this.$pgettext('*/*/*/Noun', 'Artist') - }, - { - value: 'release', - label: this.$pgettext('*/*/*', 'Album') - }, - { - value: 'recording', - label: this.$pgettext('*/*/*/Noun', 'Track') - } - ] - } - }, - watch: { - currentType (newValue) { - this.setUpSearch() - this.$emit('type-changed', newValue) - }, - currentId (newValue) { - this.$emit('id-changed', newValue) - } - } -} -</script> - -<!-- Add "scoped" attribute to limit CSS to this component only --> -<style scoped> - -</style> diff --git a/front/src/filters.js b/front/src/filters.js index b88df2f0ea2b5f0ec84729eaf2f3b09a93a54537..030e50e830856c5325381f06a4aafcc911561c0d 100644 --- a/front/src/filters.js +++ b/front/src/filters.js @@ -1,5 +1,7 @@ import Vue from 'vue' +import time from '@/utils/time' + import moment from 'moment' export function truncate (str, max, ellipsis, middle) { @@ -89,6 +91,12 @@ export function padDuration (duration) { Vue.filter('padDuration', padDuration) +export function duration (seconds) { + return time.parse(seconds) +} + +Vue.filter('duration', duration) + export function momentFormat (date, format) { format = format || 'lll' return moment(date).format(format) diff --git a/front/src/style/_main.scss b/front/src/style/_main.scss index 3af8480d371f4207749bb0ba1e08f53ac7409f11..ce47d9736126cc53a70598cc404ce63da1fc29b6 100644 --- a/front/src/style/_main.scss +++ b/front/src/style/_main.scss @@ -420,6 +420,10 @@ input + .help { .ui.very.small.divider { margin: 0.25rem 0; } +.ui.horizontal.divider { + display: inline-block; + margin: 0 0.5em; +} .queue.segment.player-focused #queue-grid #player { @include media("<desktop") { @@ -562,39 +566,106 @@ input + .help { } // channels stuff - +@mixin two-images { + margin-right: 1em; + position: relative; + width: 3.5em; + height: 3.5em; + &.large { + width: 15em; + height: 15em; + img { + width: 11em; + } + } + img { + width: 2.5em; + position: absolute; + &:last-child { + bottom: 0; + left: 0; + } + &:first-child { + top: 0; + right: 0; + } + } +} +.two-images { + @include two-images; +} .channel-entry-card, .channel-serie-card { display: flex; width: 100%; align-items: center; - margin: 0 auto 1em; justify-content: space-between; + .controls { + margin-right: 1em; + } .image { - width: 3.5em; + width: 3em; + height: 3em; margin-right: 1em; + line-height: 3em; + text-align: center; + font-weight: bold; } .two-images { - width: 3.5em; - height: 3.5em; - margin-right: 1em; - position: relative; - img { - width: 2.5em; - position: absolute; - &:last-child { - bottom: 0; - left: 0; - } - &:first-child { - top: 0; - right: 0; - } - } + @include two-images; + } + .content { + flex-grow: 1; + } +} +.album-entries { + > div { + display: flex; + align-items: center; + justify-content: space-between; } .content { flex-grow: 1; } } +.ui.artist-label { + .icon { + width: 2em; + } + &.rounded { + border-radius: 5em; + padding: 0.2em 0.75em 0.2em 0.2em; + line-height: 2em; + img { + border-radius: 50%; + vertical-align: middle; + } + } +} +.album-entry, .channel-entry-card { + border-radius: 5px; + padding: 0.5em; + .meta { + text-align: right; + min-width: 7em; + } + > div { + padding: 0.25em; + &:not(:last-child) { + margin-right: 0.25em; + } + } + &.active { + background: rgba(155, 155, 155, 0.2); + } + &:hover { + background: rgba(155, 155, 155, 0.1); + } + .favorite-icon.tiny.button { + border: none !important; + padding: 0 !important; + margin: 0 0.5em; + } +} .channel-image { border: 1px solid rgba(0, 0, 0, 0.15); background-color: white; diff --git a/front/src/style/themes/_light.scss b/front/src/style/themes/_light.scss index 2ab581eb82dda9384a6911bbefabf58cc3f794fb..acfeee911975e29358a2f4c8a091353c8e3b2fb4 100644 --- a/front/src/style/themes/_light.scss +++ b/front/src/style/themes/_light.scss @@ -18,6 +18,9 @@ .discrete { color: rgba(0, 0, 0, 0.87); } + .really.discrete { + color: rgba(0, 0, 0, 0.57); + } .playlist.card { .attached.button { background-color: rgb(243, 244, 245); diff --git a/front/src/utils/time.js b/front/src/utils/time.js index ca3edbdea32a6721b05411117f6c38fd42c801ae..6c5770c12fdb7f331c11e3939e9a0f8ef52dff50 100644 --- a/front/src/utils/time.js +++ b/front/src/utils/time.js @@ -9,9 +9,16 @@ function pad (val) { export default { parse: function (sec) { let min = 0 + let hours = Math.floor(sec/3600) + if (hours >= 1) { + sec = sec % 3600 + } min = Math.floor(sec / 60) sec = sec - min * 60 - return pad(min) + ':' + pad(sec) + if (hours >= 1) { + return hours + ':' + pad(min) + ':' + pad(sec) + } + return min + ':' + pad(sec) }, durationFormatted (v) { let duration = parseInt(v) diff --git a/front/src/views/admin/library/UploadDetail.vue b/front/src/views/admin/library/UploadDetail.vue index 3cd32ed41f444bb1abfafebe9ca8def477d29e18..eaef1cc4af7845f0ef8386456a39392e0e9e33fe 100644 --- a/front/src/views/admin/library/UploadDetail.vue +++ b/front/src/views/admin/library/UploadDetail.vue @@ -240,7 +240,7 @@ </td> <td> <template v-if="object.duration"> - {{ time.parse(object.duration) }} + {{ object.duration | duration }} </template> <translate v-else translate-context="*/*/*">N/A</translate> </td> diff --git a/front/src/views/channels/DetailBase.vue b/front/src/views/channels/DetailBase.vue index 2445f1091cbfabed9edb03b0e5c6be451baffd8d..de07b4e220fd1859fd8fd487875612ed0d5f1e55 100644 --- a/front/src/views/channels/DetailBase.vue +++ b/front/src/views/channels/DetailBase.vue @@ -206,7 +206,8 @@ <translate translate-context="Content/Channels/Link">Overview</translate> </router-link> <router-link class="item" :exact="true" :to="{name: 'channels.detail.episodes', params: {id: id}}"> - <translate translate-context="Content/Channels/*">Episodes</translate> + <translate key="1" v-if="isPodcast" translate-context="Content/Channels/*">Episodes</translate> + <translate key="2" v-else translate-context="*/*/*">Tracks</translate> </router-link> </div> <div class="ui hidden divider"></div> @@ -313,6 +314,9 @@ export default { isOwner () { return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername }, + isPodcast () { + return this.object.artist.content_category === 'podcast' + }, labels () { return { title: this.$pgettext('*/*/*', 'Channel') diff --git a/front/src/views/channels/DetailOverview.vue b/front/src/views/channels/DetailOverview.vue index 8a739028afd5d1670a5183f39ec577ad0554356a..7d12127530e5a4425e794612031a9a11d922b253 100644 --- a/front/src/views/channels/DetailOverview.vue +++ b/front/src/views/channels/DetailOverview.vue @@ -51,13 +51,16 @@ </div> <channel-entries :key="String(episodesKey) + 'entries'" :filters="{channel: object.uuid, ordering: '-creation_date'}"> <h2 class="ui header"> - <translate translate-context="Content/Channel/Paragraph">Latest episodes</translate> + <translate key="1" v-if="isPodcast" translate-context="Content/Channel/Paragraph">Latest episodes</translate> + <translate key="2" v-else translate-context="Content/Channel/Paragraph">Latest tracks</translate> </h2> </channel-entries> <div class="ui hidden divider"></div> - <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters"> + <channel-series :key="String(seriesKey) + 'series'" :filters="seriesFilters" :is-podcast="isPodcast"> <h2 class="ui with-actions header"> - <translate translate-context="Content/Channel/Paragraph">Series</translate> + + <translate key="1" v-if="isPodcast" translate-context="Content/Channel/Paragraph">Series</translate> + <translate key="2" v-else translate-context="*/*/*">Albums</translate> <div class="actions" v-if="isOwner"> <a @click.stop.prevent="$refs.albumModal.show = true"> <i class="plus icon"></i> @@ -114,6 +117,9 @@ export default { }); }, computed: { + isPodcast () { + return this.object.artist.content_category === 'podcast' + }, isOwner () { return this.$store.state.auth.authenticated && this.object.attributed_to.full_username === this.$store.state.auth.fullUsername }, diff --git a/front/src/views/content/libraries/FilesTable.vue b/front/src/views/content/libraries/FilesTable.vue index 79c498a3e0b3fd8a1450b4133d39e3712bab1757..d60b21ede32e02cc29d8e1442ca35bf6c944f08e 100644 --- a/front/src/views/content/libraries/FilesTable.vue +++ b/front/src/views/content/libraries/FilesTable.vue @@ -165,7 +165,7 @@ <i class="question circle outline icon"></i> </button> </td> - <td v-if="scope.obj.duration">{{ time.parse(scope.obj.duration) }}</td> + <td v-if="scope.obj.duration">{{ scope.obj.duration | duration }}</td> <td v-else> <translate translate-context="*/*/*">N/A</translate> </td> diff --git a/front/tests/unit/specs/store/player.spec.js b/front/tests/unit/specs/store/player.spec.js index c3f1c4583f39b28b7df07370dede94128415f3b3..ac995ab1a805fed9d0120ba5f929fbcdf8f4488d 100644 --- a/front/tests/unit/specs/store/player.spec.js +++ b/front/tests/unit/specs/store/player.spec.js @@ -90,11 +90,11 @@ describe('store/player', () => { describe('getters', () => { it('durationFormatted', () => { const state = { duration: 12.51 } - expect(store.getters['durationFormatted'](state)).to.equal('00:13') + expect(store.getters['durationFormatted'](state)).to.equal('0:13') }) it('currentTimeFormatted', () => { const state = { currentTime: 12.51 } - expect(store.getters['currentTimeFormatted'](state)).to.equal('00:13') + expect(store.getters['currentTimeFormatted'](state)).to.equal('0:13') }) it('progress', () => { const state = { currentTime: 4, duration: 10 }