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:
Eliot Berriot 2020-04-07 17:19:17 +02:00
commit 1d37a2c819
26 changed files with 573 additions and 596 deletions

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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 {

View File

@ -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",

View File

@ -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>

View File

@ -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>&nbsp;
</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() {

View File

@ -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() {

View File

@ -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>&nbsp;
</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>

View File

@ -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
}

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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)

View File

@ -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;

View File

@ -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);

View File

@ -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)

View File

@ -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>

View File

@ -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')

View File

@ -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
},

View File

@ -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>

View File

@ -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 }