[media.jellyfin] Playlist implementation [UI].

This commit is contained in:
Fabio Manganiello 2024-11-08 01:59:55 +01:00
parent bc42ba16d7
commit b646b5f3d7
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
7 changed files with 326 additions and 14 deletions

View file

@ -27,6 +27,16 @@
</div> </div>
</div> </div>
<div class="row duration" v-if="computedItem?.duration">
<div class="left side">Duration</div>
<div class="right side" v-text="formatDuration(computedItem.duration, true)" />
</div>
<div class="row duration" v-if="computedItem?.n_items != null">
<div class="left side">Items</div>
<div class="right side" v-text="computedItem.n_items" />
</div>
<div class="row direct-url" v-if="computedItem?.imdb_url"> <div class="row direct-url" v-if="computedItem?.imdb_url">
<div class="left side">ImDB URL</div> <div class="left side">ImDB URL</div>
<div class="right side"> <div class="right side">

View file

@ -14,10 +14,18 @@
<div class="left side" <div class="left side"
:class="{'col-11': !listView, 'col-10': listView }" :class="{'col-11': !listView, 'col-10': listView }"
@click.stop="$emit('select')"> @click.stop="$emit('select')">
<span class="track-number" v-if="listView && item.track_number"> <span class="track-number" v-if="playlistView">
{{ index + 1 }}
</span>
<span class="track-number" v-else-if="listView && item.track_number">
{{ item.track_number }} {{ item.track_number }}
</span> </span>
<span class="artist" v-if="playlistView && item.artist">
{{ item.artist.name ?? item.artist }} &nbsp;&mdash;&nbsp;
</span>
{{item.title || item.name}} {{item.title || item.name}}
</div> </div>
@ -111,6 +119,10 @@ export default {
default: false, default: false,
}, },
index: {
type: Number,
},
listView: { listView: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -187,7 +199,7 @@ export default {
}) })
} }
if (this.item.type === 'youtube') { if (['jellyfin', 'youtube'].includes(this.item.type)) {
actions.push({ actions.push({
iconClass: 'fa fa-list', iconClass: 'fa fa-list',
text: 'Add to Playlist', text: 'Add to Playlist',
@ -195,7 +207,7 @@ export default {
}) })
} }
if (this.item.type === 'youtube' && this.playlist?.length) { if (['jellyfin', 'youtube'].includes(this.item.type) && this.playlist) {
actions.push({ actions.push({
iconClass: 'fa fa-trash', iconClass: 'fa fa-trash',
text: 'Remove from Playlist', text: 'Remove from Playlist',
@ -211,6 +223,10 @@ export default {
return actions return actions
}, },
playlistView() {
return this.playlist && this.listView
},
}, },
methods: { methods: {
@ -308,6 +324,12 @@ export default {
&:hover { &:hover {
text-decoration: underline; text-decoration: underline;
} }
.artist {
font-weight: 300;
opacity: .75;
letter-spacing: 0.065em;
}
} }
.side { .side {

View file

@ -15,6 +15,7 @@
<Media v-bind="componentData.props" <Media v-bind="componentData.props"
v-on="componentData.on" v-on="componentData.on"
:collection="collection" :collection="collection"
@delete="deleteItem"
@select="select" @select="select"
@select-collection="selectCollection" @select-collection="selectCollection"
@view="$emit('view', $event)" @view="$emit('view', $event)"
@ -148,6 +149,27 @@ export default {
this.collection = collection this.collection = collection
this.select(collection) this.select(collection)
}, },
async deleteItem(item_id) {
this.loading_ = true
try {
await this.request('media.jellyfin.delete_item', {
item_id: item_id,
})
} finally {
this.loading_ = false
}
if (this.path.length > 1) {
if (this.path[this.path.length - 1].id === item_id) {
this.selectCollection(this.path[this.path.length - 2])
}
} else {
this.collection = null
this.select(this.rootItem)
}
},
}, },
watch: { watch: {

View file

@ -22,12 +22,61 @@
</div> </div>
</div> </div>
</div> </div>
<div class="add-playlist floating-btn-container" v-if="isPlaylistsCollection">
<FloatingButton icon-class="fa fa-plus"
title="Create Playlist"
:disabled="loading"
@click="showNewPlaylist = true" />
</div>
<div class="add-playlist-modal" v-if="showNewPlaylist">
<Modal title="Create Playlist"
:visible="true"
@close="showNewPlaylist = false">
<form class="modal-body" @submit.prevent="createPlaylist">
<div class="row">
<label for="newPlaylistName">Playlist Name</label>
<input name="name"
type="text"
id="newPlaylistName"
placeholder="Playlist Name"
required>
</div>
<div class="row">
<label for="newPlaylistPublic">Public</label>
<input name="public" type="checkbox" checked id="newPlaylistPublic">
</div>
<div class="row buttons">
<button type="button" @click="showNewPlaylist = false">Cancel</button>
<button type="submit">Create</button>
</div>
</form>
</Modal>
</div>
</div> </div>
</template> </template>
<script> <script>
import FloatingButton from "@/components/elements/FloatingButton";
import Modal from "@/components/Modal";
import Utils from '@/Utils'
export default { export default {
mixins: [Utils],
emits: ['refresh', 'select'],
components: {
FloatingButton,
Modal,
},
props: { props: {
collection: {
type: Object,
},
filter: { filter: {
type: String, type: String,
}, },
@ -50,7 +99,9 @@ export default {
data() { data() {
return { return {
fallbackImageCollections: {}, fallbackImageCollections: {},
loading: false,
maxResultIndex: this.batchItems, maxResultIndex: this.batchItems,
showNewPlaylist: false,
}; };
}, },
@ -82,9 +133,23 @@ export default {
return a.name.localeCompare(b.name) return a.name.localeCompare(b.name)
}).slice(0, this.maxResultIndex) }).slice(0, this.maxResultIndex)
}, },
isPlaylistsCollection() {
return this.collection?.collection_type === 'playlists'
},
}, },
methods: { methods: {
async createPlaylist(event) {
const form = new FormData(event.target)
await this.request('media.jellyfin.create_playlist', {
name: form.get('name'),
public: form.get('public') === 'on',
})
this.$emit('refresh')
},
onImageError(collection) { onImageError(collection) {
this.fallbackImageCollections[collection.id] = true this.fallbackImageCollections[collection.id] = true
}, },
@ -102,6 +167,13 @@ export default {
}, },
}, },
watch: {
showNewPlaylist(value) {
if (value)
this.$nextTick(() => this.$el.querySelector('input[name="name"]').focus())
},
},
mounted() { mounted() {
this.$el.parentElement?.addEventListener('scroll', this.onScroll) this.$el.parentElement?.addEventListener('scroll', this.onScroll)
}, },
@ -149,5 +221,45 @@ export default {
} }
} }
} }
:deep(.add-playlist-modal) {
.modal-body {
min-width: 30em;
max-width: calc(100% - 2em);
}
form {
display: flex;
flex-direction: column;
.row {
margin-bottom: 1em;
label {
@extend .col-m-4;
@extend .col-s-12;
}
input {
@extend .col-m-8;
@extend .col-s-12;
}
&.buttons {
display: flex;
justify-content: flex-end;
button {
margin-left: 1em;
&[type="submit"] {
width: 10em;
cursor: pointer;
}
}
}
}
}
}
} }
</style> </style>

View file

@ -7,8 +7,11 @@
:filter="filter" :filter="filter"
:loading="isLoading" :loading="isLoading"
:path="path" :path="path"
@add-to-playlist="$emit('add-to-playlist', $event)"
@delete="$emit('delete', $event)"
@play="$emit('play', $event)" @play="$emit('play', $event)"
@play-with-opts="$emit('play-with-opts', $event)" @play-with-opts="$emit('play-with-opts', $event)"
@remove-from-playlist="$emit('remove-from-playlist', $event)"
@select="selectedResult = $event; $emit('select', $event)" @select="selectedResult = $event; $emit('select', $event)"
@select-collection="selectCollection" @select-collection="selectCollection"
@view="$emit('view', $event)" /> @view="$emit('view', $event)" />
@ -16,7 +19,7 @@
<NoItems :with-shadow="false" <NoItems :with-shadow="false"
v-else-if="!items?.length"> v-else-if="!items?.length">
No videos found. No media found.
</NoItems> </NoItems>
<div class="wrapper items-wrapper" v-else> <div class="wrapper items-wrapper" v-else>
@ -25,6 +28,7 @@
:items="collections" :items="collections"
:loading="isLoading" :loading="isLoading"
:parent-id="collection?.id" :parent-id="collection?.id"
@refresh="refresh"
@select="selectCollection" @select="selectCollection"
v-if="collections.length > 0" /> v-if="collections.length > 0" />
@ -54,7 +58,18 @@ import Results from "@/components/panels/Media/Results";
export default { export default {
mixins: [Mixin], mixins: [Mixin],
emits: ['select', 'select-collection'], emits: [
'add-to-playlist',
'delete',
'download',
'play',
'play-with-opts',
'remove-from-playlist',
'select',
'select-collection',
'view',
],
components: { components: {
Collections, Collections,
Loading, Loading,
@ -65,11 +80,11 @@ export default {
computed: { computed: {
collections() { collections() {
return this.sortedItems?.filter((item) => item.item_type === 'collection') ?? [] return this.sortedItems?.filter((item) => ['collection', 'playlist'].includes(item.item_type)) ?? []
}, },
mediaItems() { mediaItems() {
const items = this.sortedItems?.filter((item) => item.item_type !== 'collection') ?? [] const items = this.sortedItems?.filter((item) => !['collection', 'playlist'].includes(item.item_type)) ?? []
if (this.collection && (!this.collection.collection_type || this.collection.collection_type === 'books')) { if (this.collection && (!this.collection.collection_type || this.collection.collection_type === 'books')) {
return items.sort((a, b) => { return items.sort((a, b) => {
@ -193,5 +208,18 @@ export default {
.music-wrapper { .music-wrapper {
height: 100%; height: 100%;
} }
:deep(.floating-btn-container) {
position: fixed;
bottom: 5.5em;
@include until($tablet) {
right: 1em;
}
@include from($tablet) {
right: 2.5em;
}
}
} }
</style> </style>

View file

@ -2,11 +2,7 @@
<div class="music index"> <div class="music index">
<Loading v-if="isLoading" /> <Loading v-if="isLoading" />
<NoItems :with-shadow="false" v-else-if="!items?.length"> <main :class="containerClass">
No music found.
</NoItems>
<main :class="{ album: view === 'album', artist: view === 'artist' }" v-else>
<div class="artist header" v-if="view === 'artist'"> <div class="artist header" v-if="view === 'artist'">
<div class="image" v-if="collection.image"> <div class="image" v-if="collection.image">
<img :src="collection.image" /> <img :src="collection.image" />
@ -17,7 +13,7 @@
</div> </div>
</div> </div>
<div class="album header" v-if="view === 'album'"> <div class="album header" v-else-if="view === 'album'">
<div class="image" v-if="collection.image"> <div class="image" v-if="collection.image">
<img :src="collection.image" /> <img :src="collection.image" />
</div> </div>
@ -44,6 +40,38 @@
</div> </div>
</div> </div>
<div class="playlist header" v-else-if="view === 'playlist'">
<div class="image" v-if="collection.image">
<img :src="collection.image" />
</div>
<div class="info">
<h1 v-text="collection.name" />
<div class="details">
<div class="row" v-if="collection.duration">
<span class="label">Duration:</span>
<span class="value" v-text="formatDuration(collection.duration, true)" />
</div>
</div>
</div>
<div class="actions">
<Dropdown title="Playlist Actions">
<DropdownItem text="Remove Playlist"
icon-class="fas fa-trash"
@input="$refs.deleteConfirmDialog.show()" />
<DropdownItem text="Info"
icon-class="fas fa-info"
@input="showInfoModal = true" />
</Dropdown>
</div>
</div>
<NoItems :with-shadow="false" v-if="!items?.length">
No media found.
</NoItems>
<Collections :collection="collection" <Collections :collection="collection"
:filter="filter" :filter="filter"
:items="collections" :items="collections"
@ -56,6 +84,7 @@
:sources="{'jellyfin': true}" :sources="{'jellyfin': true}"
:filter="filter" :filter="filter"
:list-view="true" :list-view="true"
:playlist="collection?.item_type === 'playlist' ? collection : null"
:selected-result="selectedResult" :selected-result="selectedResult"
:show-date="false" :show-date="false"
@add-to-playlist="$emit('add-to-playlist', $event)" @add-to-playlist="$emit('add-to-playlist', $event)"
@ -66,23 +95,54 @@
@select="selectedResult = $event" @select="selectedResult = $event"
@view="$emit('view', $event)" @view="$emit('view', $event)"
v-if="mediaItems?.length > 0" /> v-if="mediaItems?.length > 0" />
<div class="collection-modal" v-if="showInfoModal">
<Modal title="Collection Info" visible @close="showInfoModal = false">
<Info :item="collection" :pluginName="pluginName" />
</Modal>
</div>
<ConfirmDialog ref="deleteConfirmDialog" @input="$emit('delete', collection.id)">
Are you sure you want to delete this playlist?
</ConfirmDialog>
</main> </main>
</div> </div>
</template> </template>
<script> <script>
import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections"; import Collections from "@/components/panels/Media/Providers/Jellyfin/Collections";
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
import Info from "@/components/panels/Media/Info";
import Loading from "@/components/Loading"; import Loading from "@/components/Loading";
import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin"; import Mixin from "@/components/panels/Media/Providers/Jellyfin/Mixin";
import Modal from "@/components/Modal";
import NoItems from "@/components/elements/NoItems"; import NoItems from "@/components/elements/NoItems";
import Results from "@/components/panels/Media/Results"; import Results from "@/components/panels/Media/Results";
export default { export default {
mixins: [Mixin], mixins: [Mixin],
emits: ['select', 'select-collection'], emits: [
'add-to-playlist',
'delete',
'download',
'play',
'play-with-opts',
'remove-from-playlist',
'select',
'select-collection',
'view',
],
components: { components: {
Collections, Collections,
ConfirmDialog,
Dropdown,
DropdownItem,
Info,
Loading, Loading,
Modal,
NoItems, NoItems,
Results, Results,
}, },
@ -90,6 +150,7 @@ export default {
data() { data() {
return { return {
artist: null, artist: null,
showInfoModal: false,
} }
}, },
@ -100,6 +161,14 @@ export default {
).sort((a, b) => a.name.localeCompare(b.name)) ).sort((a, b) => a.name.localeCompare(b.name))
}, },
containerClass() {
return {
artist: this.view === 'artist',
album: this.view === 'album',
playlist: this.view === 'playlist',
}
},
displayedArtist() { displayedArtist() {
return this.artist || this.collection?.artist return this.artist || this.collection?.artist
}, },
@ -134,6 +203,8 @@ export default {
return 'artist' return 'artist'
case 'album': case 'album':
return 'album' return 'album'
case 'playlist':
return 'playlist'
default: default:
return 'index' return 'index'
} }
@ -240,6 +311,16 @@ export default {
) )
break break
case 'playlist':
this.items = await this.request(
'media.jellyfin.get_items',
{
parent_id: this.collection.id,
limit: 25000,
}
)
break
default: default:
this.artist = null this.artist = null
this.items = await this.request( this.items = await this.request(
@ -274,6 +355,8 @@ export default {
$artist-header-height: 5em; $artist-header-height: 5em;
$album-header-height: 10em; $album-header-height: 10em;
$playlist-header-height: 5em;
$actions-dropdown-width: 5em;
.index { .index {
position: relative; position: relative;
@ -313,6 +396,12 @@ $album-header-height: 10em;
} }
} }
&.playlist {
.media-results {
height: calc(100% - #{$playlist-header-height} - 0.5em);
}
}
.index { .index {
height: fit-content; height: fit-content;
} }
@ -363,6 +452,32 @@ $album-header-height: 10em;
} }
} }
&.playlist {
height: $playlist-header-height;
border-bottom: 1px solid $default-shadow-color;
.image {
img {
width: calc($playlist-header-height - 0.5em);
height: calc($playlist-header-height - 0.5em);
padding: 0.25em;
}
}
.info {
width: calc(100% - #{$actions-dropdown-width});
}
.actions {
width: $actions-dropdown-width;
display: flex;
justify-content: flex-end;
align-items: center;
margin-right: -0.75em;
position: relative;
}
}
.image { .image {
margin-right: 1em; margin-right: 1em;

View file

@ -5,6 +5,7 @@
<Item v-for="(item, i) in visibleResults" <Item v-for="(item, i) in visibleResults"
:key="i" :key="i"
:hidden="!!Object.keys(sources || {}).length && !sources[item.type]" :hidden="!!Object.keys(sources || {}).length && !sources[item.type]"
:index="i"
:item="item" :item="item"
:list-view="listView" :list-view="listView"
:playlist="playlist" :playlist="playlist"
@ -184,6 +185,8 @@ export default {
&.list { &.list {
:deep(.grid) { :deep(.grid) {
height: fit-content;
max-height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: 0; padding: 0;