[WIP] [#414] [UI] Initial implementation of the Jellyfin UI.

This commit is contained in:
Fabio Manganiello 2024-09-27 22:28:40 +02:00
parent 841516d9de
commit bf82ad9bf0
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
11 changed files with 577 additions and 4 deletions

View file

@ -175,7 +175,7 @@ export default {
},
toggle(event) {
event.stopPropagation()
event?.stopPropagation()
this.$emit('click', event)
this.visible ? this.close() : this.open()
},

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -7,6 +7,13 @@
}
},
"Jellyfin": {
"name": "Jellyfin",
"icon": {
"url": "https://static.platypush.tech/icons/media.jellyfin-64.png"
}
},
"YouTube": {
"name": "YouTube",
"icon": {

View file

@ -20,6 +20,11 @@
i {
font-size: 40px;
}
img {
width: 40px;
height: 40px;
}
}
}
}

View file

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