Merge branch '170-serie-album-UI' into 'develop'
See #170: UI for albums / series See merge request funkwhale/funkwhale!1079
This commit is contained in:
commit
1d37a2c819
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<div class="ui basic deny button">
|
||||
<translate translate-context="*/*/Button.Label/Verb">Cancel</translate>
|
||||
</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>
|
||||
<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>
|
||||
<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 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>
|
||||
<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() {
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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)
|
||||
|
|
|
@ -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;
|
||||
.image {
|
||||
width: 3.5em;
|
||||
.controls {
|
||||
margin-right: 1em;
|
||||
}
|
||||
.two-images {
|
||||
width: 3.5em;
|
||||
height: 3.5em;
|
||||
.image {
|
||||
width: 3em;
|
||||
height: 3em;
|
||||
margin-right: 1em;
|
||||
position: relative;
|
||||
img {
|
||||
width: 2.5em;
|
||||
position: absolute;
|
||||
&:last-child {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
&:first-child {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
line-height: 3em;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
.two-images {
|
||||
@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;
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 }
|
||||
|
|
Loading…
Reference in New Issue