feat(music): create full screen music player

This commit is contained in:
Fernando Fernández 2021-01-03 02:46:16 +01:00
parent c5e9843c90
commit 8b6de3c7a0
7 changed files with 352 additions and 102 deletions

View File

@ -1,52 +1,114 @@
<template>
<v-container fluid>
<v-row>
<v-col cols="9" md="3" class="d-flex flex-row pa-0">
<v-avatar ref="albumCover" tile size="72" color="primary">
<v-img :src="getImageUrl(getCurrentItem.AlbumId)">
<template #placeholder>
<v-icon dark>mdi-album</v-icon>
</template>
</v-img>
</v-avatar>
<div class="d-flex flex-column justify-center ml-4">
<span class="font-weight-medium mt-md-n2">
<nuxt-link
tag="span"
class="text-truncate link"
:to="`/item/${getCurrentItem.AlbumId}`"
<transition name="fade-fast" mode="in-out">
<v-footer
v-if="isPlaying && getCurrentlyPlayingMediaType() === 'Audio'"
app
:color="footerColor"
>
<v-container v-if="isFullScreenPlayer" fluid>
<time-slider />
</v-container>
<v-container fluid>
<v-row>
<v-col cols="9" md="3" class="d-flex flex-row pa-0">
<v-avatar
v-if="!isFullScreenPlayer"
ref="albumCover"
tile
size="72"
color="primary"
>
{{ getCurrentItem.Name }}
</nuxt-link>
<v-btn class="d-none d-md-inline-flex" icon disabled>
<v-icon size="18">{{
getCurrentItem.UserData.IsFavorite
? 'mdi-heart'
: 'mdi-heart-outline'
}}</v-icon>
</v-btn>
</span>
<nuxt-link
v-if="getCurrentItem.AlbumArtists[0].Id"
tag="span"
class="text--secondary text-caption text-truncate mt-md-n2 link"
:to="`/artist/${getCurrentItem.AlbumArtists[0].Id}`"
<v-img :src="getImageUrl(getCurrentItem().AlbumId)">
<template #placeholder>
<v-icon dark>mdi-album</v-icon>
</template>
</v-img>
</v-avatar>
<div class="d-flex flex-column justify-center ml-4">
<span class="font-weight-medium mt-md-n2">
<nuxt-link
tag="span"
class="text-truncate link"
:to="`/item/${getCurrentItem().AlbumId}`"
>
{{ getCurrentItem().Name }}
</nuxt-link>
<v-btn class="d-none d-md-inline-flex" icon disabled>
<v-icon size="18">{{
getCurrentItem().UserData.IsFavorite
? 'mdi-heart'
: 'mdi-heart-outline'
}}</v-icon>
</v-btn>
</span>
<nuxt-link
tag="span"
class="text--secondary text-caption text-truncate mt-md-n2 link"
:to="`/artist/${getCurrentItem().AlbumArtists[0].Id}`"
>
{{ getCurrentItem().AlbumArtist }}
</nuxt-link>
</div>
</v-col>
<v-col cols="6" class="pa-0 d-none d-md-inline">
<div class="d-flex flex-column justify-center">
<div class="d-flex align-center justify-center">
<v-btn disabled icon class="mx-1">
<v-icon>mdi-shuffle</v-icon>
</v-btn>
<v-btn icon class="mx-1" @click="setPreviousTrack">
<v-icon>mdi-skip-previous</v-icon>
</v-btn>
<v-btn icon large class="mx-1" @click="togglePause">
<v-icon large>
{{ isPaused ? 'mdi-play' : 'mdi-pause' }}
</v-icon>
</v-btn>
<v-btn icon class="mx-1" @click="stopPlayback">
<v-icon>mdi-stop</v-icon>
</v-btn>
<v-btn icon class="mx-1" @click="setNextTrack">
<v-icon>mdi-skip-next</v-icon>
</v-btn>
<v-btn disabled icon class="mx-1">
<v-icon>mdi-repeat-off</v-icon>
</v-btn>
</div>
<time-slider v-if="!isFullScreenPlayer" />
</div>
</v-col>
<v-col cols="3" class="d-none d-md-flex align-center justify-end">
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn disabled icon class="mr-2" v-bind="attrs" v-on="on">
<v-icon>mdi-playlist-play</v-icon>
</v-btn>
</template>
<span>{{ $t('queue') }}</span>
</v-tooltip>
<volume-slider />
<transition name="fade-fast" mode="in-out">
<v-tooltip v-if="!isFullScreenPlayer" top>
<template #activator="{ on, attrs }">
<nuxt-link tag="span" :to="'/playback'">
<v-btn icon class="ml-2" v-bind="attrs" v-on="on">
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</nuxt-link>
</template>
<span>{{ $t('fullScreen') }}</span>
</v-tooltip>
</transition>
</v-col>
<v-col
cols="3"
class="d-flex d-md-none px-0 align-center justify-end"
>
{{ getCurrentItem.AlbumArtist }}
</nuxt-link>
</div>
</v-col>
<v-col cols="6" class="pa-0 d-none d-md-inline">
<div class="d-flex flex-column justify-center">
<div class="d-flex align-center justify-center">
<v-btn disabled icon class="mx-1">
<v-icon>mdi-shuffle</v-icon>
<v-btn icon>
<v-icon>mdi-heart</v-icon>
</v-btn>
<v-btn icon class="mx-1" @click="setPreviousTrack">
<v-icon>mdi-skip-previous</v-icon>
</v-btn>
<v-btn icon large class="mx-1" @click="togglePause">
<v-icon large>
<v-btn icon @click="togglePause">
<v-icon>
{{ isPaused ? 'mdi-play' : 'mdi-pause' }}
</v-icon>
</v-btn>
@ -59,45 +121,23 @@
<v-btn disabled icon class="mx-1">
<v-icon>mdi-repeat-off</v-icon>
</v-btn>
</div>
<time-slider />
</v-col>
</v-row>
<div
v-if="isFullScreenPlayer && nextTrackName"
class="d-flex justify-center align-center"
>
<h4 class="text-h6 font-weight-thin font-italic">
{{ $t('upNext') }}: {{ nextTrackName }}
</h4>
</div>
</v-col>
<v-col cols="3" class="d-none d-md-flex align-center justify-end">
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn disabled icon class="mr-2" v-bind="attrs" v-on="on">
<v-icon>mdi-playlist-play</v-icon>
</v-btn>
</template>
<span>{{ $t('queue') }}</span>
</v-tooltip>
<volume-slider />
<v-tooltip top>
<template #activator="{ on, attrs }">
<v-btn disabled icon class="ml-2" v-bind="attrs" v-on="on">
<v-icon>mdi-fullscreen</v-icon>
</v-btn>
</template>
<span>{{ $t('fullScreen') }}</span>
</v-tooltip>
</v-col>
<v-col cols="3" class="d-flex d-md-none px-0 align-center justify-end">
<v-btn icon>
<v-icon>mdi-heart</v-icon>
</v-btn>
<v-btn icon @click="togglePause">
<v-icon>
{{ isPaused ? 'mdi-play' : 'mdi-pause' }}
</v-icon>
</v-btn>
</v-col>
</v-row>
</v-container>
</v-container>
</v-footer>
</transition>
</template>
<script lang="ts">
import { ImageType } from '@jellyfin/client-axios';
import { ImageType, RepeatMode } from '@jellyfin/client-axios';
import Vue from 'vue';
import { mapActions, mapGetters } from 'vuex';
import timeUtils from '~/mixins/timeUtils';
@ -107,9 +147,39 @@ import { PlaybackStatus } from '~/store/playbackManager';
export default Vue.extend({
mixins: [timeUtils, imageHelper],
computed: {
...mapGetters('playbackManager', ['getCurrentItem']),
isPaused(): boolean {
return this.$store.state.playbackManager.status === PlaybackStatus.paused;
},
isPlaying(): boolean {
return (
this.$store.state.playbackManager.status !== PlaybackStatus.stopped
);
},
isFullScreenPlayer(): boolean {
return !this.$store.state.playbackManager.isMinimized;
},
footerColor(): string | undefined {
if (this.isFullScreenPlayer) {
return 'rgba(0,0,0,0.15)';
} else {
return undefined;
}
},
nextTrackName(): string | undefined {
const state = this.$store.state.playbackManager;
const queue = this.$store.state.playbackManager.queue;
if (
state.currentItemIndex !== null &&
state.currentItemIndex + 1 < state.queue.length
) {
return queue[state.currentItemIndex + 1].Name;
} else if (
this.$store.state.playbackManager.repeatMode === RepeatMode.RepeatAll
) {
return queue[0].Name;
} else {
return undefined;
}
}
},
methods: {
@ -121,6 +191,10 @@ export default Vue.extend({
'unpause',
'pause'
]),
...mapGetters('playbackManager', [
'getCurrentItem',
'getCurrentlyPlayingMediaType'
]),
getImageUrl(itemId: string): string | undefined {
const element = this.$refs.albumCover as HTMLElement;
return this.getImageUrlForElement(ImageType.Primary, {

View File

@ -3,11 +3,10 @@
<v-slider
hide-details
thumb-label
min="0"
max="100"
:value="currentvolume"
validate-on-blur
prepend-icon="mdi-volume-high"
:prepend-icon="icon"
@input="onVolumeChange"
>
</v-slider>
@ -22,6 +21,17 @@ export default Vue.extend({
computed: {
currentvolume(): number {
return this.$store.state.playbackManager.currentVolume;
},
icon(): string {
if (this.currentvolume >= 80) {
return 'mdi-volume-high';
} else if (this.currentvolume < 80 && this.currentvolume >= 25) {
return 'mdi-volume-medium';
} else if (this.currentvolume < 25 && this.currentvolume >= 1) {
return 'mdi-volume-low';
} else {
return 'mdi-volume-mute';
}
}
},
methods: {

View File

@ -2,6 +2,7 @@
<v-app ref="app">
<backdrop />
<v-navigation-drawer
v-if="$store.state.page.showNavDrawer"
v-model="drawer"
:temporary="$vuetify.breakpoint.mobile"
:permanent="!$vuetify.breakpoint.mobile"
@ -54,7 +55,7 @@
:class="{ opaque: opaqueAppBar || $vuetify.breakpoint.xsOnly }"
>
<v-app-bar-nav-icon
v-if="$vuetify.breakpoint.mobile"
v-if="$vuetify.breakpoint.mobile && $store.state.page.showNavDrawer"
@click.stop="drawer = !drawer"
/>
<v-btn
@ -94,14 +95,7 @@
<v-main>
<nuxt />
</v-main>
<transition name="fade" mode="in-out">
<v-footer
v-if="isPlaying && getCurrentlyPlayingMediaType() === 'Audio'"
app
>
<audio-controls />
</v-footer>
</transition>
<audio-controls />
<!-- Utilities and global systems -->
<snackbar />
<player-manager />
@ -112,9 +106,8 @@
import { BaseItemDto } from '@jellyfin/client-axios';
import { stringify } from 'qs';
import Vue from 'vue';
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapActions, mapState } from 'vuex';
import { AppState } from '~/store';
import { PlaybackStatus } from '~/store/playbackManager';
import { getLibraryIcon } from '~/utils/items';
interface WebSocketMessage {
@ -149,11 +142,6 @@ export default Vue.extend({
};
})
}),
isPlaying(): boolean {
return (
this.$store.state.playbackManager.status !== PlaybackStatus.stopped
);
},
items(): LayoutButton[] {
return [
{
@ -193,7 +181,6 @@ export default Vue.extend({
methods: {
...mapActions('userViews', ['refreshUserViews']),
...mapActions('displayPreferences', ['callAllCallbacks']),
...mapGetters('playbackManager', ['getCurrentlyPlayingMediaType']),
handleKeepAlive(): void {
this.$store.subscribe((mutation, state) => {
if (

View File

@ -132,7 +132,7 @@ export default Vue.extend({
if (
this.collectionInfo &&
['CollectionFolder', 'Folder', 'UserView'].includes(
['CollectionFolder', 'Folder', 'UserView', 'playlists'].includes(
this.collectionInfo.Type || ''
)
) {

161
pages/playback/index.vue Normal file
View File

@ -0,0 +1,161 @@
<template>
<v-container fluid>
<swiper
ref="playbackSwiper"
class="swiper"
:options="swiperOptions"
@slideChange="onSlideChange"
>
<swiper-slide v-for="item in currentQueue" :key="item.Id">
<div class="d-flex flex-column justify-center">
<div class="d-flex align-center justify-center">
<v-avatar ref="albumCover" tile size="65vh" color="primary">
<v-img :src="getImageUrl(item)">
<template #placeholder>
<v-icon dark>mdi-album</v-icon>
</template>
</v-img>
</v-avatar>
</div>
</div>
</swiper-slide>
</swiper>
</v-container>
</template>
<script lang="ts">
import Vue from 'vue';
import { BaseItemDto, ImageType } from '@jellyfin/client-axios';
import { mapGetters, mapActions } from 'vuex';
import Swiper, { SwiperOptions } from 'swiper';
import { PlaybackStatus, RepeatMode } from '~/store/playbackManager';
import imageHelper from '~/mixins/imageHelper';
export default Vue.extend({
mixins: [imageHelper],
data() {
return {
swiperOptions: {
slidesPerView: 4,
centeredSlides: true,
initialSlide: 0,
loop: true,
parallax: true,
autoplay: false,
effect: 'coverflow',
coverflowEffect: {
depth: 500,
slideShadows: false,
stretch: -200,
rotate: 0
},
keyboard: true,
a11y: true
} as SwiperOptions,
swiper: undefined as Swiper | undefined
};
},
computed: {
currentItemIndex: {
get(): number {
return this.$store.state.playbackManager.currentItemIndex;
}
},
currentQueue: {
get(): BaseItemDto[] {
return this.$store.state.playbackManager.queue;
}
},
backdropHash: {
get(): string {
return (
this.getBlurhashHash(this.getCurrentItem(), ImageType.Primary) || ''
);
}
},
isPaused: {
get(): boolean {
return (
this.$store.state.playbackManager.status === PlaybackStatus.paused
);
}
},
isPlaying: {
get(): boolean {
return (
this.$store.state.playbackManager.status !== PlaybackStatus.stopped
);
}
}
},
watch: {
currentItemIndex(newIndex: number): void {
if (this.swiperOptions.loop) {
this.swiper?.slideToLoop(newIndex);
} else {
this.swiper?.slideTo(newIndex);
}
this.setBackdrop({ hash: this.backdropHash });
},
isPlaying(newValue: boolean): void {
if (!newValue) {
this.$router.back();
}
}
},
beforeMount() {
if (this.$store.state.playbackManager.repeatMode !== RepeatMode.all) {
this.swiperOptions.loop = false;
}
this.showNavDrawer({ showNavDrawer: false });
this.setAppBarOpacity({ opaqueAppBar: false });
this.setBackdropOpacity({ value: 0.5 });
if (!this.isPlaying) {
this.$router.back();
}
this.setMinimized({ minimized: false });
},
mounted() {
this.swiper = (this.$refs.playbackSwiper as Vue).$swiper as Swiper;
this.setBackdrop({ hash: this.backdropHash });
},
beforeDestroy() {
this.clearBackdrop();
this.resetBackdropOpacity();
this.setMinimized({ minimized: true });
},
destroyed() {
this.showNavDrawer({ showNavDrawer: true });
this.setAppBarOpacity({ opaqueAppBar: true });
},
methods: {
...mapGetters('playbackManager', ['getCurrentItem']),
...mapActions('playbackManager', ['setCurrentIndex', 'setMinimized']),
...mapActions('page', ['showNavDrawer', 'setAppBarOpacity']),
...mapActions('backdrop', [
'setBackdrop',
'setBackdropOpacity',
'resetBackdropOpacity',
'clearBackdrop'
]),
onSlideChange(): void {
const index = this.swiper?.realIndex || 0;
this.setCurrentIndex({ index });
},
getImageUrl(item: BaseItemDto): string | undefined {
const tag = this.getImageTag(item, ImageType.Primary);
if (tag) {
return this.getImageUrlForElement(ImageType.Primary, { item });
}
const albumTag = this.getImageTag(item.AlbumId, ImageType.Primary);
if (albumTag) {
return this.getImageUrlForElement(ImageType.Primary, {
item: item.AlbumId
});
}
return undefined;
}
}
});
</script>

View File

@ -3,11 +3,13 @@ import { ActionTree, MutationTree } from 'vuex';
export interface PageState {
title: string;
opaqueAppBar: boolean;
showNavDrawer: boolean;
}
export const state = (): PageState => ({
title: 'Jellyfin',
opaqueAppBar: true
opaqueAppBar: true,
showNavDrawer: true
});
interface TitleMutationPayload {
@ -18,6 +20,10 @@ interface AppBarMutationPayload {
opaqueAppBar: boolean;
}
interface NavDrawerMutationPayload {
showNavDrawer: boolean;
}
export const mutations: MutationTree<PageState> = {
SET_PAGE_TITLE(state: PageState, { title }: TitleMutationPayload) {
state.title = title;
@ -27,6 +33,12 @@ export const mutations: MutationTree<PageState> = {
{ opaqueAppBar }: AppBarMutationPayload
) {
state.opaqueAppBar = opaqueAppBar;
},
SET_NAVDRAWER_VISIBILITY(
state: PageState,
{ showNavDrawer }: NavDrawerMutationPayload
) {
state.showNavDrawer = showNavDrawer;
}
};
@ -36,5 +48,8 @@ export const actions: ActionTree<PageState, PageState> = {
},
setAppBarOpacity({ commit }, { opaqueAppBar }: AppBarMutationPayload) {
commit('SET_APPBAR_OPACITY', { opaqueAppBar });
},
showNavDrawer({ commit }, { showNavDrawer }: NavDrawerMutationPayload) {
commit('SET_NAVDRAWER_VISIBILITY', { showNavDrawer });
}
};

View File

@ -269,6 +269,9 @@ export const actions: ActionTree<PlaybackManagerState, PlaybackManagerState> = {
setVolume({ commit }, { volume }: { volume: number }) {
commit('SET_VOLUME', { volume });
},
setCurrentIndex({ commit }, { index }: { index: number }) {
commit('SET_CURRENT_ITEM_INDEX', { currentItemIndex: index });
},
setCurrentTime({ commit }, { time }: { time: number | null }) {
commit('SET_CURRENT_TIME', { time });
},