forked from platypush/platypush
[WIP] [#414] [UI] Initial implementation of the Jellyfin UI.
This commit is contained in:
parent
841516d9de
commit
bf82ad9bf0
11 changed files with 577 additions and 4 deletions
|
@ -175,7 +175,7 @@ export default {
|
|||
},
|
||||
|
||||
toggle(event) {
|
||||
event.stopPropagation()
|
||||
event?.stopPropagation()
|
||||
this.$emit('click', event)
|
||||
this.visible ? this.close() : this.open()
|
||||
},
|
||||
|
|
|
@ -3,13 +3,16 @@
|
|||
<div class="media-browser">
|
||||
<div class="media-index grid" v-if="!mediaProvider">
|
||||
<div class="item"
|
||||
v-for="(provider, name) in mediaProviders"
|
||||
v-for="(provider, name) in visibleMediaProviders"
|
||||
:key="name"
|
||||
@click="mediaProvider = provider">
|
||||
<div class="icon">
|
||||
<i v-bind="providersMetadata[name].icon"
|
||||
:style="{ color: providersMetadata[name].icon?.color || 'inherit' }"
|
||||
v-if="providersMetadata[name].icon" />
|
||||
v-if="providersMetadata[name].icon?.class" />
|
||||
|
||||
<img :src="providersMetadata[name].icon.url"
|
||||
v-else-if="providersMetadata[name].icon?.url" />
|
||||
</div>
|
||||
<div class="name">
|
||||
{{ providersMetadata[name].name }}
|
||||
|
@ -104,6 +107,15 @@ export default {
|
|||
return acc
|
||||
}, {})
|
||||
},
|
||||
|
||||
visibleMediaProviders() {
|
||||
return Object.entries(this.mediaProviders)
|
||||
.filter(([provider, component]) => component && (!this.filter || provider.toLowerCase().includes(this.filter.toLowerCase())))
|
||||
.reduce((acc, [provider, component]) => {
|
||||
acc[provider] = component
|
||||
return acc
|
||||
}, {})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
@ -124,13 +136,16 @@ export default {
|
|||
},
|
||||
|
||||
async refreshMediaProviders() {
|
||||
const config = await this.request('config.get')
|
||||
const config = this.$root.config
|
||||
this.mediaProviders = {}
|
||||
// The local File provider is always enabled
|
||||
this.registerMediaProvider('File')
|
||||
|
||||
if (config.youtube)
|
||||
this.registerMediaProvider('YouTube')
|
||||
|
||||
if (config['media.jellyfin'])
|
||||
this.registerMediaProvider('Jellyfin')
|
||||
},
|
||||
|
||||
onPlaylistChange() {
|
||||
|
|
|
@ -43,6 +43,20 @@
|
|||
<div class="row creation-date" v-if="item.created_at">
|
||||
{{ formatDateTime(item.created_at, true) }}
|
||||
</div>
|
||||
|
||||
<div class="row creation-date" v-text="item.year" v-else-if="item.year" />
|
||||
|
||||
<div class="row ratings" v-if="item.critic_rating != null || item.community_rating != null">
|
||||
<span class="rating" title="Critic rating" v-if="item.critic_rating != null">
|
||||
<i class="fa fa-star" />
|
||||
<span v-text="Math.round(item.critic_rating)" />%
|
||||
</span>
|
||||
|
||||
<span class="rating" title="Community rating" v-if="item.community_rating != null">
|
||||
<i class="fa fa-users" />
|
||||
<span v-text="Math.round(item.community_rating)" />%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
@ -229,5 +243,23 @@ export default {
|
|||
color: $default-fg-2;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ratings {
|
||||
width: 100%;
|
||||
font-size: .75em;
|
||||
opacity: .75;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
.rating {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-right: 1em;
|
||||
|
||||
i {
|
||||
margin-right: .25em;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
<i :class="overlayIconClass" />
|
||||
</div>
|
||||
|
||||
<div class="backdrop" v-if="item?.image"
|
||||
:style="{ backgroundImage: `url(${item.image})` }" />
|
||||
|
||||
<span class="icon type-icon" v-if="typeIcons[item?.type]">
|
||||
<a :href="item.url" target="_blank" v-if="item.url">
|
||||
<i :class="typeIcons[item.type]" :title="item.type">
|
||||
|
@ -119,6 +122,7 @@ export default {
|
|||
background: rgba(0, 0, 0, 0.5);
|
||||
border-radius: 0.25em;
|
||||
color: $default-media-img-fg;
|
||||
z-index: 2;
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
|
@ -159,6 +163,7 @@ export default {
|
|||
color: white;
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.25em;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.type-icon {
|
||||
|
@ -194,6 +199,7 @@ export default {
|
|||
|
||||
.image {
|
||||
max-width: 100%;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
div.image {
|
||||
|
@ -229,6 +235,7 @@ div.image {
|
|||
border-radius: 2em;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
z-index: 3;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
|
@ -239,4 +246,14 @@ div.image {
|
|||
color: $default-media-img-fg;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-size: cover;
|
||||
filter: blur(5px) brightness(0.5);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,162 @@
|
|||
<template>
|
||||
<div class="media-jellyfin-container browser">
|
||||
<MediaNav :path="path" @back="$emit('back')" />
|
||||
|
||||
<div class="media-jellyfin-browser">
|
||||
<Loading v-if="isLoading" />
|
||||
|
||||
<Index v-bind="componentData.props"
|
||||
v-on="componentData.on"
|
||||
@select="selectCollection"
|
||||
v-else-if="currentView === 'index'" />
|
||||
|
||||
<Movies v-bind="componentData.props"
|
||||
v-on="componentData.on"
|
||||
:collection="collection"
|
||||
@select="select"
|
||||
v-else-if="currentView === 'movies'" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Index from "./Jellyfin/Index";
|
||||
import Loading from "@/components/Loading";
|
||||
import MediaNav from "./Nav";
|
||||
import MediaProvider from "./Mixin";
|
||||
import Movies from "./Jellyfin/Collections/Movies/Index";
|
||||
|
||||
export default {
|
||||
mixins: [MediaProvider],
|
||||
components: {
|
||||
Index,
|
||||
Loading,
|
||||
MediaNav,
|
||||
Movies,
|
||||
},
|
||||
|
||||
emits: [
|
||||
'add-to-playlist',
|
||||
'back',
|
||||
'download',
|
||||
'download-audio',
|
||||
'play',
|
||||
'play-with-opts',
|
||||
],
|
||||
|
||||
data() {
|
||||
return {
|
||||
collection: null,
|
||||
loading_: false,
|
||||
path: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
componentData() {
|
||||
return {
|
||||
props: {
|
||||
collection: this.collection,
|
||||
filter: this.filter,
|
||||
loading: this.isLoading,
|
||||
},
|
||||
|
||||
on: {
|
||||
'add-to-playlist': (item) => this.$emit('add-to-playlist', item),
|
||||
'download': (item) => this.$emit('download', item),
|
||||
'download-audio': (item) => this.$emit('download-audio', item),
|
||||
'play': (item) => this.$emit('play', item),
|
||||
'play-with-opts': (item) => this.$emit('play-with-opts', item),
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
currentView() {
|
||||
if (!this.collection) {
|
||||
return 'index'
|
||||
}
|
||||
|
||||
switch (this.collection.type) {
|
||||
case 'movies':
|
||||
return 'movies'
|
||||
default:
|
||||
return 'index'
|
||||
}
|
||||
},
|
||||
|
||||
isLoading() {
|
||||
return this.loading_ || this.loading
|
||||
},
|
||||
|
||||
rootItem() {
|
||||
const item = {
|
||||
id: '',
|
||||
title: 'Jellyfin',
|
||||
type: 'index',
|
||||
icon: {
|
||||
class: 'fas fa-server',
|
||||
},
|
||||
}
|
||||
|
||||
item.click = () => {
|
||||
this.collection = null
|
||||
this.select(item)
|
||||
}
|
||||
|
||||
return item
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
select(item) {
|
||||
if (item) {
|
||||
if (this.path.length > 0 && this.path[this.path.length - 1].id === item.id) {
|
||||
return
|
||||
}
|
||||
|
||||
if (item.type === 'index') {
|
||||
this.path = [this.rootItem]
|
||||
} else {
|
||||
this.path.push({
|
||||
title: item.name,
|
||||
...item,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
this.path = []
|
||||
}
|
||||
},
|
||||
|
||||
selectCollection(collection) {
|
||||
this.collection = collection
|
||||
this.select(collection)
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
collection() {
|
||||
this.setUrlArgs({ collection: this.collection?.id })
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.path = [this.rootItem]
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
this.setUrlArgs({ collection: null })
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "../style.scss";
|
||||
|
||||
.media-jellyfin-container {
|
||||
height: 100%;
|
||||
|
||||
.media-jellyfin-browser {
|
||||
height: calc(100% - $media-nav-height - 2px);
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,116 @@
|
|||
<template>
|
||||
<div class="movies index">
|
||||
<Loading v-if="isLoading" />
|
||||
|
||||
<NoItems :with-shadow="false"
|
||||
v-else-if="sortedMovies.length === 0">
|
||||
No movies found.
|
||||
</NoItems>
|
||||
|
||||
<Results :results="sortedMovies"
|
||||
:sources="{'jellyfin': true}"
|
||||
:filter="filter"
|
||||
:selected-result="selectedResult"
|
||||
@add-to-playlist="$emit('add-to-playlist', $event)"
|
||||
@download="$emit('download', $event)"
|
||||
@play="$emit('play', $event)"
|
||||
@play-with-opts="$emit('play-with-opts', $event)"
|
||||
@remove-from-playlist="$emit('remove-from-playlist', $event)"
|
||||
@select="selectedResult = $event"
|
||||
v-else />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from "@/components/Loading";
|
||||
import MediaProvider from "@/components/panels/Media/Providers/Mixin";
|
||||
import NoItems from "@/components/elements/NoItems";
|
||||
import Results from "@/components/panels/Media/Results";
|
||||
|
||||
export default {
|
||||
mixins: [MediaProvider],
|
||||
components: {
|
||||
Loading,
|
||||
NoItems,
|
||||
Results,
|
||||
},
|
||||
|
||||
emits: [
|
||||
'add-to-playlist',
|
||||
'back',
|
||||
'download',
|
||||
'play',
|
||||
'play-with-opts',
|
||||
],
|
||||
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
movies: {},
|
||||
loading_: false,
|
||||
selectedResult: null,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
isLoading() {
|
||||
return this.loading_ || this.loading
|
||||
},
|
||||
|
||||
sortedMovies() {
|
||||
return [...this.movies].sort((a, b) => {
|
||||
return a.title.localeCompare(b.title)
|
||||
}).map((movie) => {
|
||||
return {
|
||||
item_type: movie.type,
|
||||
...movie,
|
||||
type: 'jellyfin',
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
async refresh() {
|
||||
const collection = this.collection?.name
|
||||
if (!collection?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loading_ = true
|
||||
try {
|
||||
this.movies = await this.request(
|
||||
'media.jellyfin.search',
|
||||
{ collection, limit: 1000 },
|
||||
)
|
||||
|
||||
} finally {
|
||||
this.loading_ = false
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
collection: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.refresh()
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await this.refresh()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/components/panels/Media/Providers/Jellyfin/common.scss";
|
||||
</style>
|
|
@ -0,0 +1,131 @@
|
|||
<template>
|
||||
<div class="collections index">
|
||||
<Loading v-if="isLoading" />
|
||||
|
||||
<div class="collections items" v-else>
|
||||
<div class="collection item"
|
||||
v-for="collection in filteredCollections"
|
||||
:key="collection.id"
|
||||
@click="$emit('select', collection)">
|
||||
<div class="image">
|
||||
<img :src="collection.image"
|
||||
:alt="collection.name"
|
||||
@error="onImageError(collection)"
|
||||
v-if="!fallbackImageCollections[collection.id]">
|
||||
<i :class="collectionsIcons[collection.type] ?? 'fas fa-folder'" v-else />
|
||||
</div>
|
||||
|
||||
<div class="name" v-if="fallbackImageCollections[collection.id]">
|
||||
<h2>{{ collection.name }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Loading from "@/components/Loading";
|
||||
import MediaProvider from "../Mixin";
|
||||
|
||||
export default {
|
||||
mixins: [MediaProvider],
|
||||
components: {
|
||||
Loading,
|
||||
},
|
||||
|
||||
emits: [
|
||||
'add-to-playlist',
|
||||
'back',
|
||||
'download',
|
||||
'download-audio',
|
||||
'play',
|
||||
'play-with-opts',
|
||||
'select',
|
||||
],
|
||||
|
||||
data() {
|
||||
return {
|
||||
collections: {},
|
||||
fallbackImageCollections: {},
|
||||
loading_: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
collectionsIcons() {
|
||||
return {
|
||||
books: "fas fa-book",
|
||||
homevideos: "fas fa-video",
|
||||
movies: "fas fa-film",
|
||||
music: "fas fa-music",
|
||||
playlists: "fas fa-list",
|
||||
photos: "fas fa-image",
|
||||
series: "fas fa-tv",
|
||||
};
|
||||
},
|
||||
|
||||
filteredCollections() {
|
||||
return Object.values(this.collections).filter(
|
||||
(collection) => !this.filter || collection.name.toLowerCase().includes(this.filter.toLowerCase())
|
||||
);
|
||||
},
|
||||
|
||||
isLoading() {
|
||||
return this.loading_ || this.loading
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
onImageError(collection) {
|
||||
this.fallbackImageCollections[collection.id] = true
|
||||
},
|
||||
|
||||
async refresh() {
|
||||
this.loading_ = true
|
||||
|
||||
try {
|
||||
this.collections = (
|
||||
await this.request('media.jellyfin.get_collections')
|
||||
).reduce((acc, collection) => {
|
||||
acc[collection.id] = collection
|
||||
return acc
|
||||
}, {})
|
||||
} finally {
|
||||
this.loading_ = false
|
||||
}
|
||||
},
|
||||
|
||||
initCollection() {
|
||||
const collectionId = this.getUrlArgs().collection
|
||||
if (collectionId == null) {
|
||||
return
|
||||
}
|
||||
|
||||
const collection = this.collections[collectionId]
|
||||
if (!collection) {
|
||||
return
|
||||
}
|
||||
|
||||
this.$emit('select', collection)
|
||||
},
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await this.refresh()
|
||||
this.initCollection()
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "./common.scss";
|
||||
|
||||
.index {
|
||||
.item {
|
||||
h2 {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,62 @@
|
|||
@import "@/components/panels/Media/style.scss";
|
||||
|
||||
.index {
|
||||
height: 100%;
|
||||
margin: 2px 0 -2px 0;
|
||||
|
||||
.items {
|
||||
height: 100%;
|
||||
display: grid;
|
||||
gap: 1em;
|
||||
grid-template-columns: repeat(auto-fill, minmax(20em, 1fr));
|
||||
overflow-y: auto;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.item {
|
||||
width: 100%;
|
||||
height: 15em;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
@extend .dim;
|
||||
}
|
||||
|
||||
.image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
i {
|
||||
font-size: 5em;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background-color: rgba(0, 0, 0, 0.75);
|
||||
color: white;
|
||||
font-size: 1.75em;
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,6 +7,13 @@
|
|||
}
|
||||
},
|
||||
|
||||
"Jellyfin": {
|
||||
"name": "Jellyfin",
|
||||
"icon": {
|
||||
"url": "https://static.platypush.tech/icons/media.jellyfin-64.png"
|
||||
}
|
||||
},
|
||||
|
||||
"YouTube": {
|
||||
"name": "YouTube",
|
||||
"icon": {
|
||||
|
|
|
@ -20,6 +20,11 @@
|
|||
i {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -46,6 +46,22 @@
|
|||
-webkit-animation-name: unfold;
|
||||
}
|
||||
|
||||
.dim {
|
||||
animation-duration: 0.5s;
|
||||
-webkit-animation-duration: 0.5s;
|
||||
animation-fill-mode: both;
|
||||
animation-name: dim;
|
||||
-webkit-animation-name: dim;
|
||||
}
|
||||
|
||||
.brighten {
|
||||
animation-duration: 0.5s;
|
||||
-webkit-animation-duration: 0.5s;
|
||||
animation-fill-mode: both;
|
||||
animation-name: brighten;
|
||||
-webkit-animation-name: brighten;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
0% {opacity: 0;}
|
||||
100% {opacity: 1;}
|
||||
|
@ -79,6 +95,16 @@
|
|||
100% {transform: scale(1, 1);}
|
||||
}
|
||||
|
||||
@keyframes dim {
|
||||
0% {filter: brightness(1);}
|
||||
100% {filter: brightness(0.5);}
|
||||
}
|
||||
|
||||
@keyframes brighten {
|
||||
0% {filter: brightness(0.5);}
|
||||
100% {filter: brightness(1);}
|
||||
}
|
||||
|
||||
.glow {
|
||||
animation-duration: 2s;
|
||||
-webkit-animation-duration: 2s;
|
||||
|
|
Loading…
Reference in a new issue