[#414] [UI] Added support for generic Jellyfin media collections.

This commit is contained in:
Fabio Manganiello 2024-09-29 00:00:07 +02:00
parent 9e78a9a297
commit c88a6aa3e6
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
13 changed files with 460 additions and 298 deletions

View file

@ -97,6 +97,7 @@ export default {
border-top: $default-border-2; border-top: $default-border-2;
background: $default-bg-2; background: $default-bg-2;
box-shadow: $border-shadow-top; box-shadow: $border-shadow-top;
z-index: 100;
} }
} }
</style> </style>

View file

@ -54,7 +54,7 @@ import Utils from "@/Utils"
export default { export default {
name: "Install", name: "Install",
mixins: [Utils], mixins: [Utils],
emit: ['install-start', 'install-end'], emits: ['install-start', 'install-end'],
components: { components: {
CopyButton, CopyButton,
Loading, Loading,

View file

@ -122,7 +122,7 @@ export default {
background: rgba(0, 0, 0, 0.5); background: rgba(0, 0, 0, 0.5);
border-radius: 0.25em; border-radius: 0.25em;
color: $default-media-img-fg; color: $default-media-img-fg;
z-index: 2; z-index: 3;
a { a {
width: 100%; width: 100%;

View file

@ -5,33 +5,35 @@
<div class="media-jellyfin-browser"> <div class="media-jellyfin-browser">
<Loading v-if="isLoading" /> <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" <Movies v-bind="componentData.props"
v-on="componentData.on" v-on="componentData.on"
:collection="collection" :collection="collection"
@select="select" @select="select"
v-else-if="currentView === 'movies'" /> v-else-if="currentView === 'movies'" />
<Media v-bind="componentData.props"
v-on="componentData.on"
:collection="collection"
@select="select"
@select-collection="selectCollection"
v-else />
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import Index from "./Jellyfin/Index";
import Loading from "@/components/Loading"; import Loading from "@/components/Loading";
import MediaNav from "./Nav"; import MediaNav from "./Nav";
import MediaProvider from "./Mixin"; import MediaProvider from "./Mixin";
import Movies from "./Jellyfin/collections/Movies/Index"; import Media from "./Jellyfin/views/Media/Index";
import Movies from "./Jellyfin/views/Movies/Index";
export default { export default {
mixins: [MediaProvider], mixins: [MediaProvider],
components: { components: {
Index,
Loading, Loading,
MediaNav, MediaNav,
Media,
Movies, Movies,
}, },
@ -59,6 +61,7 @@ export default {
collection: this.collection, collection: this.collection,
filter: this.filter, filter: this.filter,
loading: this.isLoading, loading: this.isLoading,
path: this.path,
}, },
on: { on: {
@ -76,9 +79,11 @@ export default {
return 'index' return 'index'
} }
switch (this.collection.type) { switch (this.collection.collection_type) {
case 'movies': case 'movies':
return 'movies' return 'movies'
case 'homevideos':
return 'videos'
default: default:
return 'index' return 'index'
} }
@ -116,12 +121,18 @@ export default {
if (item.type === 'index') { if (item.type === 'index') {
this.path = [this.rootItem] this.path = [this.rootItem]
} else {
const itemIndex = this.path.findIndex((i) => i.id === item.id)
if (itemIndex >= 0) {
this.path = this.path.slice(0, itemIndex + 1)
} else { } else {
this.path.push({ this.path.push({
title: item.name, title: item.name,
click: () => this.selectCollection(item),
...item, ...item,
}) })
} }
}
} else { } else {
this.path = [] this.path = []
} }

View file

@ -0,0 +1,96 @@
<template>
<div class="collections index" :class="{ 'is-root': !parentId }">
<div class="collections items">
<div class="collection item"
v-for="collection in filteredItems"
: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] || parentId">
<h2>{{ collection.name }}</h2>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
filter: {
type: String,
},
items: {
type: Array,
default: () => [],
},
parentId: {
type: String,
},
},
data() {
return {
fallbackImageCollections: {},
};
},
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",
};
},
filteredItems() {
return Object.values(this.items).filter(
(item) => !this.filter || item.name.toLowerCase().includes(this.filter.toLowerCase())
).sort((a, b) => a.name.localeCompare(b.name))
},
},
methods: {
onImageError(collection) {
this.fallbackImageCollections[collection.id] = true
},
},
}
</script>
<style lang="scss" scoped>
@import "./common.scss";
.index {
.item {
h2 {
font-size: 1.25em;
font-weight: bold;
overflow: auto;
text-overflow: ellipsis;
}
}
&.is-root {
.item {
h2 {
font-size: 2em;
}
}
}
}
</style>

View file

@ -1,131 +0,0 @@
<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,100 @@
<script>
import MediaProvider from "@/components/panels/Media/Providers/Mixin";
export default {
mixins: [MediaProvider],
emits: [
'add-to-playlist',
'back',
'download',
'play',
'play-with-opts',
'select',
],
props: {
collection: {
type: Object,
},
path: {
type: Array,
default: () => [],
},
},
data() {
return {
items: [],
loading_: false,
selectedResult: null,
sort: {
attr: 'title',
desc: false,
},
};
},
computed: {
isLoading() {
return this.loading_ || this.loading
},
sortedItems() {
if (!this.items) {
return []
}
return [...this.items].sort((a, b) => {
const attr = this.sort.attr
const desc = this.sort.desc
let aVal = a[attr]
let bVal = b[attr]
if (typeof aVal === 'number' || typeof bVal === 'number') {
aVal = aVal || 0
bVal = bVal || 0
return desc ? bVal - aVal : aVal - bVal
}
aVal = (aVal || '').toString().toLowerCase()
bVal = (bVal || '').toString().toLowerCase()
return desc ? bVal.localeCompare(aVal) : aVal.localeCompare(bVal)
}).map((item) => {
return {
item_type: item.type,
...item,
type: 'jellyfin',
}
})
},
},
methods: {
async refresh() {
const collection = this.collection?.name
if (!collection?.length) {
return
}
this.loading_ = true
try {
this.items = await this.request(
'media.jellyfin.search',
{ collection, limit: 1000 },
)
} finally {
this.loading_ = false
}
},
},
watch: {
collection() {
this.refresh()
},
},
}
</script>

View file

@ -1,145 +0,0 @@
<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 />
<SortButton :value="sort" @input="sort = $event" v-if="sortedMovies.length > 0" />
</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";
import SortButton from "@/components/panels/Media/Providers/Jellyfin/components/SortButton";
export default {
mixins: [MediaProvider],
components: {
Loading,
NoItems,
Results,
SortButton,
},
emits: [
'add-to-playlist',
'back',
'download',
'play',
'play-with-opts',
],
props: {
collection: {
type: Object,
required: true,
},
},
data() {
return {
movies: [],
loading_: false,
selectedResult: null,
sort: {
attr: 'title',
desc: false,
},
};
},
computed: {
isLoading() {
return this.loading_ || this.loading
},
sortedMovies() {
if (!this.movies) {
return []
}
return [...this.movies].sort((a, b) => {
const attr = this.sort.attr
const desc = this.sort.desc
let aVal = a[attr]
let bVal = b[attr]
if (typeof aVal === 'number' || typeof bVal === 'number') {
aVal = aVal || 0
bVal = bVal || 0
return desc ? bVal - aVal : aVal - bVal
}
aVal = (aVal || '').toString().toLowerCase()
bVal = (bVal || '').toString().toLowerCase()
return desc ? bVal.localeCompare(aVal) : aVal.localeCompare(bVal)
}).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";
.index {
position: relative;
}
</style>

View file

@ -53,10 +53,12 @@
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
text-align: center;
background-color: rgba(0, 0, 0, 0.75); background-color: rgba(0, 0, 0, 0.75);
color: white; color: white;
font-size: 1.75em; font-size: 1.75em;
font-weight: bold; font-weight: bold;
padding: 0.5em;
} }
} }
} }

View file

@ -2,7 +2,7 @@
<div class="sort-buttons"> <div class="sort-buttons">
<Dropdown :icon-class="btnIconClass" <Dropdown :icon-class="btnIconClass"
glow right glow right
title="Sort Direction"> :title="title">
<div class="sort-buttons-dropdown-body"> <div class="sort-buttons-dropdown-body">
<div class="title">Sort Direction</div> <div class="title">Sort Direction</div>
<DropdownItem text="Ascending" <DropdownItem text="Ascending"
@ -22,15 +22,18 @@
<DropdownItem text="Release Date" <DropdownItem text="Release Date"
icon-class="fa fa-calendar" icon-class="fa fa-calendar"
:item-class="{ active: value?.attr === 'year' }" :item-class="{ active: value?.attr === 'year' }"
@input="onAttrChange('year')" /> @input="onAttrChange('year')"
v-if="withReleaseDate" />
<DropdownItem text="Critics Rating" <DropdownItem text="Critics Rating"
icon-class="fa fa-star" icon-class="fa fa-star"
:item-class="{ active: value?.attr === 'critic_rating' }" :item-class="{ active: value?.attr === 'critic_rating' }"
@input="onAttrChange('critic_rating')" /> @input="onAttrChange('critic_rating')"
v-if="withCriticRating" />
<DropdownItem text="Community Rating" <DropdownItem text="Community Rating"
icon-class="fa fa-users" icon-class="fa fa-users"
:item-class="{ active: value?.attr === 'community_rating' }" :item-class="{ active: value?.attr === 'community_rating' }"
@input="onAttrChange('community_rating')" /> @input="onAttrChange('community_rating')"
v-if="withCommunityRating" />
</div> </div>
</Dropdown> </Dropdown>
</div> </div>
@ -54,12 +57,31 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
withReleaseDate: {
type: Boolean,
default: false,
},
withCriticRating: {
type: Boolean,
default: false,
},
withCommunityRating: {
type: Boolean,
default: false,
},
}, },
computed: { computed: {
btnIconClass() { btnIconClass() {
return this.value?.desc ? 'fa fa-arrow-down-wide-short' : 'fa fa-arrow-up-short-wide' return this.value?.desc ? 'fa fa-arrow-down-wide-short' : 'fa fa-arrow-up-short-wide'
}, },
title() {
return 'Sort By: ' + (this.value?.attr ?? '[none]') + ' ' + (this.value?.desc ? 'descending' : 'ascending')
},
}, },
methods: { methods: {

View file

@ -0,0 +1,140 @@
<template>
<div class="videos index">
<Loading v-if="isLoading" />
<NoItems :with-shadow="false"
v-else-if="!items?.length">
No videos found.
</NoItems>
<div class="items-wrapper" v-else>
<Collections :collection="collection"
:filter="filter"
:items="collections"
:loading="isLoading"
:parent-id="collection?.id"
@select="selectCollection"
v-if="collections.length > 0" />
<Results :results="mediaItems"
: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-if="mediaItems.length > 0" />
</div>
<SortButton :value="sort"
@input="sort = $event"
v-if="items.length > 0" />
</div>
</template>
<script>
import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections";
import Loading from "@/components/Loading";
import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin";
import NoItems from "@/components/elements/NoItems";
import Results from "@/components/panels/Media/Results";
import SortButton from "@/components/panels/Media/Providers/Jellyfin/components/SortButton";
export default {
mixins: [Mixin],
emits: ['select', 'select-collection'],
components: {
Collections,
Loading,
NoItems,
Results,
SortButton,
},
computed: {
collections() {
return this.sortedItems?.filter((item) => item.item_type === 'collection') ?? []
},
mediaItems() {
return this.sortedItems?.filter((item) => item.item_type !== 'collection') ?? []
},
},
methods: {
selectCollection(collection) {
this.$emit('select-collection', {
type: 'homevideos',
...collection,
})
},
async init() {
const args = this.getUrlArgs()
let collection = args?.collection
if (!collection)
return
this.loading_ = true
try {
collection = await this.request('media.jellyfin.info', {
item_id: collection,
})
if (collection)
this.selectCollection(collection)
} finally {
this.loading_ = false
}
},
async refresh() {
this.loading_ = true
try {
this.items = this.collection?.id ?
(
await this.request('media.jellyfin.get_items', {
parent_id: this.collection.id,
limit: 5000,
})
) : (await this.request('media.jellyfin.get_collections')).map((collection) => ({
...collection,
item_type: 'collection',
}))
} finally {
this.loading_ = false
}
},
},
async mounted() {
this.init()
await this.refresh()
},
}
</script>
<style lang="scss" scoped>
@import "@/components/panels/Media/Providers/Jellyfin/common.scss";
.index {
position: relative;
:deep(.items-wrapper) {
height: 100%;
position: relative;
overflow: auto;
.index {
height: fit-content;
}
.items {
overflow: hidden;
}
}
}
</style>

View file

@ -0,0 +1,65 @@
<template>
<div class="movies index">
<Loading v-if="isLoading" />
<NoItems :with-shadow="false"
v-else-if="movies.length === 0">
No movies found.
</NoItems>
<Results :results="movies"
: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 />
<SortButton :value="sort"
:with-release-date="true"
:with-critic-rating="true"
:with-community-rating="true"
@input="sort = $event"
v-if="movies.length > 0" />
</div>
</template>
<script>
import Loading from "@/components/Loading";
import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin";
import NoItems from "@/components/elements/NoItems";
import Results from "@/components/panels/Media/Results";
import SortButton from "@/components/panels/Media/Providers/Jellyfin/components/SortButton";
export default {
mixins: [Mixin],
components: {
Loading,
NoItems,
Results,
SortButton,
},
computed: {
movies() {
return this.sortedItems?.filter((item) => item.item_type === 'movie') ?? []
},
},
async mounted() {
await this.refresh()
},
}
</script>
<style lang="scss" scoped>
@import "@/components/panels/Media/Providers/Jellyfin/common.scss";
.index {
position: relative;
}
</style>

View file

@ -26,8 +26,7 @@
<script> <script>
export default { export default {
emit: ['back'], emits: ['back', 'select'],
props: { props: {
path: { path: {
type: Array, type: Array,
@ -37,8 +36,10 @@ export default {
methods: { methods: {
onClick(token) { onClick(token) {
if (token.click) if (token.click) {
token.click() token.click()
this.$emit('select', token)
}
}, },
}, },
} }