[web-src] add composer support

This commit is contained in:
whatdoineed2do/Ray 2021-10-23 10:48:11 +01:00
parent 8c97ad591f
commit 269842d30a
11 changed files with 667 additions and 2 deletions

View File

@ -0,0 +1,84 @@
<template>
<div>
<div v-if="is_grouped">
<div v-for="idx in composers.indexList" :key="idx" class="mb-6">
<span class="tag is-info is-light is-small has-text-weight-bold" :id="'index_' + idx">{{ idx }}</span>
<list-item-composer v-for="composer in composers.grouped[idx]"
:key="composer.id"
:composer="composer"
@click="open_composer(composer)">
<template slot="actions">
<a @click="open_dialog(composer)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-composer>
</div>
</div>
<div v-else>
<list-item-composer v-for="composer in composers_list"
:key="composer.id"
:composer="composer"
@click="open_composer(composer)">
<template slot="actions">
<a @click="open_dialog(composer)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-composer>
</div>
<modal-dialog-composer :show="show_details_modal" :composer="selected_composer" :media_kind="media_kind" @close="show_details_modal = false" />
</div>
</template>
<script>
import ListItemComposer from '@/components/ListItemComposer'
import ModalDialogComposer from '@/components/ModalDialogComposer'
import Composers from '@/lib/Composers'
export default {
name: 'ListComposers',
components: { ListItemComposer, ModalDialogComposer },
props: ['composers', 'media_kind'],
data () {
return {
show_details_modal: false,
selected_composer: {}
}
},
computed: {
media_kind_resolved: function () {
return this.media_kind ? this.media_kind : this.selected_composer.media_kind
},
composers_list: function () {
if (Array.isArray(this.composers)) {
return this.composers
}
return this.composers.sortedAndFiltered
},
is_grouped: function () {
return (this.composers instanceof Composers && this.composers.options.group)
}
},
methods: {
open_composer: function (composer) {
this.selected_composer = composer
this.$router.push({ name: 'ComposerTracks', params: { composer: composer.name } })
},
open_dialog: function (composer) {
this.selected_composer = composer
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,20 @@
<template functional>
<div class="media" :id="'index_' + props.composer.name.charAt(0).toUpperCase()">
<div class="media-content fd-has-action is-clipped" @click="listeners.click">
<h1 class="title is-6">{{ props.composer.name }}</h1>
</div>
<div class="media-right">
<slot name="actions"></slot>
</div>
</div>
</template>
<script>
export default {
name: 'ListItemComposer',
props: ['composer']
}
</script>
<style>
</style>

View File

@ -0,0 +1,77 @@
<template>
<div>
<transition name="fade">
<div class="modal is-active" v-if="show">
<div class="modal-background" @click="$emit('close')"></div>
<div class="modal-content fd-modal-card">
<div class="card">
<div class="card-content">
<p class="title is-4">
<a class="has-text-link" @click="open_albums">{{ composer.name }}</a>
</p>
<p>
<span class="heading">Albums</span>
<a class="has-text-link is-6" @click="open_albums">{{ composer.album_count }}</a>
</p>
<p>
<span class="heading">Tracks</span>
<a class="has-text-link is-6" @click="open_tracks">{{ composer.track_count }}</a>
</p>
</div>
<footer class="card-footer">
<a class="card-footer-item has-text-dark" @click="queue_add">
<span class="icon"><i class="mdi mdi-playlist-plus"></i></span> <span class="is-size-7">Add</span>
</a>
<a class="card-footer-item has-text-dark" @click="queue_add_next">
<span class="icon"><i class="mdi mdi-playlist-play"></i></span> <span class="is-size-7">Add Next</span>
</a>
<a class="card-footer-item has-text-dark" @click="play">
<span class="icon"><i class="mdi mdi-play"></i></span> <span class="is-size-7">Play</span>
</a>
</footer>
</div>
</div>
<button class="modal-close is-large" aria-label="close" @click="$emit('close')"></button>
</div>
</transition>
</div>
</template>
<script>
import webapi from '@/webapi'
export default {
name: 'ModalDialogComposer',
props: ['show', 'composer'],
methods: {
play: function () {
this.$emit('close')
webapi.player_play_expression('composer is "' + this.composer.name + '" and media_kind is music', false)
},
queue_add: function () {
this.$emit('close')
webapi.queue_expression_add('composer is "' + this.composer.name + '" and media_kind is music')
},
queue_add_next: function () {
this.$emit('close')
webapi.queue_expression_add_next('composer is "' + this.composer.name + '" and media_kind is music')
},
open_albums: function () {
this.$emit('close')
this.$router.push({ name: 'ComposerAlbums', params: { composer: this.composer.name } })
},
open_tracks: function () {
this.show_details_modal = false
this.$router.push({ name: 'ComposerTracks', params: { composer: this.composer.name } })
}
}
}
</script>
<style>
</style>

View File

@ -29,6 +29,12 @@
<span class="">Genres</span>
</a>
</router-link>
<router-link tag="li" to="/music/composers" active-class="is-active">
<a>
<span class="icon is-small"><i class="mdi mdi-book-open-page-variant"></i></span>
<span class="">Composers</span>
</a>
</router-link>
<router-link tag="li" to="/music/spotify" v-if="spotify_enabled" active-class="is-active">
<a>
<span class="icon is-small"><i class="mdi mdi-spotify"></i></span>

View File

@ -0,0 +1,62 @@
export default class Composers {
constructor (items, options = { hideSingles: false, hideSpotify: false, sort: 'Name', group: false }) {
this.items = items
this.options = options
this.grouped = {}
this.sortedAndFiltered = []
this.indexList = []
this.init()
}
init () {
this.createSortedAndFilteredList()
this.createGroupedList()
this.createIndexList()
}
getComposerIndex (composer) {
if (this.options.sort === 'Name') {
return composer.name_sort.charAt(0).toUpperCase()
}
return composer.time_added.substring(0, 4)
}
isComposerVisible (composer) {
if (this.options.hideSingles && composer.track_count <= (composer.album_count * 2)) {
return false
}
if (this.options.hideSpotify && composer.data_kind === 'spotify') {
return false
}
return true
}
createIndexList () {
this.indexList = [...new Set(this.sortedAndFiltered
.map(composer => this.getComposerIndex(composer)))]
}
createSortedAndFilteredList () {
var composersSorted = this.items
if (this.options.hideSingles || this.options.hideSpotify || this.options.hideOther) {
composersSorted = composersSorted.filter(composer => this.isComposerVisible(composer))
}
if (this.options.sort === 'Recently added') {
composersSorted = [...composersSorted].sort((a, b) => b.time_added.localeCompare(a.time_added))
}
this.sortedAndFiltered = composersSorted
}
createGroupedList () {
if (!this.options.group) {
this.grouped = {}
}
this.grouped = this.sortedAndFiltered.reduce((r, composer) => {
const idx = this.getComposerIndex(composer)
r[idx] = [...r[idx] || [], composer]
return r
}, {})
}
}

View File

@ -0,0 +1,101 @@
<template>
<div>
<tabs-music></tabs-music>
<content-with-heading>
<template slot="heading-left">
<p class="title is-4">{{ name }}</p>
</template>
<template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_composer_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile">{{ composer_albums.total }} albums | <a class="has-text-link" @click="open_tracks">tracks</a></p>
<list-item-albums v-for="album in composer_albums.items" :key="album.id" :album="album" @click="open_album(album)">
<template slot="actions">
<a @click="open_dialog(album)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-albums>
<modal-dialog-album :show="show_details_modal" :album="selected_album" @close="show_details_modal = false" />
<modal-dialog-composer :show="show_composer_details_modal" :composer="{ 'name': name }" @close="show_composer_details_modal = false" />
</template>
</content-with-heading>
</div>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemAlbums from '@/components/ListItemAlbum'
import ModalDialogAlbum from '@/components/ModalDialogAlbum'
import ModalDialogComposer from '@/components/ModalDialogComposer'
import webapi from '@/webapi'
const composerData = {
load: function (to) {
return webapi.library_composer(to.params.composer, 'albums')
},
set: function (vm, response) {
vm.name = vm.$route.params.composer
vm.composer_albums = response.data.albums
}
}
export default {
name: 'PageComposer',
mixins: [LoadDataBeforeEnterMixin(composerData)],
components: { ContentWithHeading, TabsMusic, ListItemAlbums, ModalDialogAlbum, ModalDialogComposer },
data () {
return {
name: '',
composer_albums: { items: [] },
show_details_modal: false,
selected_album: {},
show_composer_details_modal: false
}
},
computed: {
index_list () {
return [...new Set(this.composer_albums.items
.map(album => album.name_sort.charAt(0).toUpperCase()))]
}
},
methods: {
open_tracks: function () {
this.show_details_modal = false
this.$router.push({ name: 'ComposerTracks', params: { composer: this.name } })
},
play: function () {
webapi.player_play_expression('composer is "' + this.name + '" and media_kind is music', true)
},
open_album: function (album) {
this.$router.push({ path: '/music/albums/' + album.id })
},
open_dialog: function (album) {
this.selected_album = album
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,116 @@
<template>
<div>
<tabs-music></tabs-music>
<content-with-heading>
<template slot="heading-left">
<p class="title is-4">{{ composer }}</p>
<p class="heading">{{ tracks.total }} tracks</p>
</template>
<template slot="heading-right">
<div class="buttons is-centered">
<a class="button is-small is-light is-rounded" @click="show_composer_details_modal = true">
<span class="icon"><i class="mdi mdi-dots-horizontal mdi-18px"></i></span>
</a>
<a class="button is-small is-dark is-rounded" @click="play">
<span class="icon"><i class="mdi mdi-shuffle"></i></span> <span>Shuffle</span>
</a>
</div>
</template>
<template slot="content">
<p class="heading has-text-centered-mobile"><a class="has-text-link" @click="open_albums">albums</a> | {{ tracks.total }} tracks</p>
<list-item-track v-for="(track, index) in rated_tracks" :key="track.id" :track="track" @click="play_track(index)">
<template slot="actions">
<a @click="open_dialog(track)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-track>
<modal-dialog-track :show="show_details_modal" :track="selected_track" @close="show_details_modal = false" />
<modal-dialog-composer :show="show_composer_details_modal" :composer="{ 'name': composer }" @close="show_composer_details_modal = false" />
</template>
</content-with-heading>
</div>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemTrack from '@/components/ListItemTrack'
import ModalDialogTrack from '@/components/ModalDialogTrack'
import ModalDialogComposer from '@/components/ModalDialogComposer'
import webapi from '@/webapi'
const tracksData = {
load: function (to) {
return webapi.library_composer(to.params.composer, 'tracks')
},
set: function (vm, response) {
vm.composer = vm.$route.params.composer
vm.tracks = response.data.tracks
}
}
export default {
name: 'PageComposerTracks',
mixins: [LoadDataBeforeEnterMixin(tracksData)],
components: { ContentWithHeading, TabsMusic, ListItemTrack, ModalDialogTrack, ModalDialogComposer },
data () {
return {
tracks: { items: [] },
composer: '',
min_rating: 0,
show_details_modal: false,
selected_track: {},
show_composer_details_modal: false
}
},
computed: {
index_list () {
return [...new Set(this.tracks.items
.map(track => track.title_sort.charAt(0).toUpperCase()))]
},
rated_tracks () {
return this.tracks.items.filter(track => track.rating >= this.min_rating)
}
},
methods: {
open_albums: function () {
this.show_details_modal = false
this.$router.push({ name: 'ComposerAlbums', params: { composer: this.composer } })
},
play: function () {
webapi.player_play_expression('composer is "' + this.composer + '" and media_kind is music', true)
},
play_track: function (position) {
webapi.player_play_expression('composer is "' + this.composer + '" and media_kind is music', false, position)
},
show_rating: function (rating) {
if (rating === 0.5) {
rating = 0
}
this.min_rating = Math.ceil(rating) * 20
},
open_dialog: function (track) {
this.selected_track = track
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -0,0 +1,88 @@
<template>
<div>
<tabs-music></tabs-music>
<content-with-heading>
<template slot="heading-left">
<p class="title is-4">{{ heading }}</p>
<p class="heading">{{ composers.total }} composers</p>
</template>
<template slot="content">
<list-item-composer v-for="composer in composers.items" :key="composer.name" :composer="composer" @click="open_composer(composer)">
<template slot="actions">
<a @click="open_dialog(composer)">
<span class="icon has-text-dark"><i class="mdi mdi-dots-vertical mdi-18px"></i></span>
</a>
</template>
</list-item-composer>
<modal-dialog-composer :show="show_details_modal" :composer="selected_composer" @close="show_details_modal = false" />
</template>
</content-with-heading>
</div>
</template>
<script>
import { LoadDataBeforeEnterMixin } from './mixin'
import ContentWithHeading from '@/templates/ContentWithHeading'
import TabsMusic from '@/components/TabsMusic'
import ListItemComposer from '@/components/ListItemComposer'
import ModalDialogComposer from '@/components/ModalDialogComposer'
import webapi from '@/webapi'
const composersData = {
load: function (to) {
if (to.params.genre) {
return webapi.library_genre_composers(to.params.genre)
} else {
return webapi.library_composers()
}
},
set: function (vm, response) {
if (response.data.composers) {
vm.composers = response.data.composers
vm.heading = vm.$route.params.genre
} else {
vm.composers = response.data
vm.heading = 'Composers'
}
}
}
export default {
name: 'PageComposers',
mixins: [LoadDataBeforeEnterMixin(composersData)],
components: { ContentWithHeading, TabsMusic, ListItemComposer, ModalDialogComposer },
data () {
return {
composers: { items: [] },
heading: '',
show_details_modal: false,
selected_composer: {}
}
},
computed: {
index_list () {
return [...new Set(this.composers.items
.map(composer => composer.name.charAt(0).toUpperCase()))]
}
},
methods: {
open_composer: function (composer) {
this.$router.push({ name: 'ComposerAlbums', params: { composer: composer.name } })
},
open_dialog: function (composer) {
this.selected_composer = composer
this.show_details_modal = true
}
}
}
</script>
<style>
</style>

View File

@ -94,6 +94,28 @@
</template>
</content-text>
<!-- Composers -->
<content-with-heading v-if="show_composers && composers.total">
<template slot="heading-left">
<p class="title is-4">Composers</p>
</template>
<template slot="content">
<list-composers :composers="composers.items"></list-composers>
</template>
<template slot="footer">
<nav v-if="show_all_composers_button" class="level">
<p class="level-item">
<a class="button is-light is-small is-rounded" v-on:click="open_search_composers">Show all {{ composers.total }} composers</a>
</p>
</nav>
</template>
</content-with-heading>
<content-text v-if="show_composers && !composers.total">
<template slot="content">
<p><i>No composers found</i></p>
</template>
</content-text>
<!-- Playlists -->
<content-with-heading v-if="show_playlists && playlists.total">
<template slot="heading-left">
@ -169,13 +191,14 @@ import TabsSearch from '@/components/TabsSearch'
import ListTracks from '@/components/ListTracks'
import ListArtists from '@/components/ListArtists'
import ListAlbums from '@/components/ListAlbums'
import ListComposers from '@/components/ListComposers'
import ListPlaylists from '@/components/ListPlaylists'
import webapi from '@/webapi'
import * as types from '@/store/mutation_types'
export default {
name: 'PageSearch',
components: { ContentWithHeading, ContentText, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists },
components: { ContentWithHeading, ContentText, TabsSearch, ListTracks, ListArtists, ListAlbums, ListPlaylists, ListComposers },
data () {
return {
@ -184,6 +207,7 @@ export default {
tracks: { items: [], total: 0 },
artists: { items: [], total: 0 },
albums: { items: [], total: 0 },
composers: { items: [], total: 0 },
playlists: { items: [], total: 0 },
audiobooks: { items: [], total: 0 },
podcasts: { items: [], total: 0 }
@ -216,6 +240,13 @@ export default {
return this.albums.total > this.albums.items.length
},
show_composers () {
return this.$route.query.type && this.$route.query.type.includes('composer')
},
show_all_composers_button () {
return this.composers.total > this.composers.items.length
},
show_playlists () {
return this.$route.query.type && this.$route.query.type.includes('playlist')
},
@ -282,6 +313,7 @@ export default {
this.tracks = data.tracks ? data.tracks : { items: [], total: 0 }
this.artists = data.artists ? data.artists : { items: [], total: 0 }
this.albums = data.albums ? data.albums : { items: [], total: 0 }
this.composers = data.composers ? data.composers : { items: [], total: 0 }
this.playlists = data.playlists ? data.playlists : { items: [], total: 0 }
})
},
@ -346,7 +378,7 @@ export default {
this.$router.push({
path: '/search/library',
query: {
type: 'track,artist,album,playlist,audiobook,podcast',
type: 'track,artist,album,playlist,audiobook,podcast,composer',
query: this.search_query,
limit: 3,
offset: 0
@ -385,6 +417,16 @@ export default {
})
},
open_search_composers: function () {
this.$router.push({
path: '/search/library',
query: {
type: 'tracks',
query: this.$route.query.query
}
})
},
open_search_playlists: function () {
this.$router.push({
path: '/search/library',
@ -415,9 +457,42 @@ export default {
})
},
open_composer: function (composer) {
this.$router.push({ name: 'ComposerAlbums', params: { composer: composer.name } })
},
open_playlist: function (playlist) {
this.$router.push({ path: '/playlists/' + playlist.id + '/tracks' })
},
open_recent_search: function (query) {
this.search_query = query
this.new_search()
},
open_track_dialog: function (track) {
this.selected_track = track
this.show_track_details_modal = true
},
open_album_dialog: function (album) {
this.selected_album = album
this.show_album_details_modal = true
},
open_artist_dialog: function (artist) {
this.selected_artist = artist
this.show_artist_details_modal = true
},
open_composer_dialog: function (composer) {
this.selected_composer = composer
this.show_composer_details_modal = true
},
open_playlist_dialog: function (playlist) {
this.selected_playlist = playlist
this.show_playlist_details_modal = true
}
},

View File

@ -15,6 +15,9 @@ import PageGenres from '@/pages/PageGenres'
import PageGenre from '@/pages/PageGenre'
import PageGenreTracks from '@/pages/PageGenreTracks'
import PageArtistTracks from '@/pages/PageArtistTracks'
import PageComposers from '@/pages/PageComposers'
import PageComposer from '@/pages/PageComposer'
import PageComposerTracks from '@/pages/PageComposerTracks'
import PagePodcasts from '@/pages/PagePodcasts'
import PagePodcast from '@/pages/PagePodcast'
import PageAudiobooksAlbums from '@/pages/PageAudiobooksAlbums'
@ -128,6 +131,24 @@ export const router = new VueRouter({
component: PageGenreTracks,
meta: { show_progress: true, has_index: true }
},
{
path: '/music/composers',
name: 'Composers',
component: PageComposers,
meta: { show_progress: true, has_tabs: true, has_index: true }
},
{
path: '/music/composers/:composer/albums',
name: 'ComposerAlbums',
component: PageComposer,
meta: { show_progress: true, has_tabs: true, has_index: true }
},
{
path: '/music/composers/:composer/tracks',
name: 'ComposerTracks',
component: PageComposerTracks,
meta: { show_progress: true, has_tabs: true, has_index: true }
},
{
path: '/podcasts',
name: 'Podcasts',

View File

@ -267,6 +267,21 @@ export default {
})
},
library_composers () {
return axios.get('/api/library/composers')
},
library_composer (composer, type) {
var params = {
type: type,
media_kind: 'music',
expression: 'composer is "' + composer + '"'
}
return axios.get('/api/search', {
params: params
})
},
library_artist_tracks (artist) {
if (artist) {
const artistParams = {