[Media UI] Support for generic media download.

This commit is contained in:
Fabio Manganiello 2024-07-15 04:04:17 +02:00
parent 79ba8deb71
commit f64d47565d
Signed by untrusted user: blacklight
GPG key ID: D90FBA7F76362774
18 changed files with 639 additions and 135 deletions

View file

@ -27,6 +27,7 @@
:selected-channel="selectedChannel"
@add-to-playlist="$emit('add-to-playlist', $event)"
@back="back"
@download="$emit('download', $event)"
@path-change="$emit('path-change', $event)"
@play="$emit('play', $event)"
/>
@ -48,6 +49,7 @@ export default {
'add-to-playlist',
'back',
'create-playlist',
'download',
'path-change',
'play',
'remove-from-playlist',

View file

@ -0,0 +1,321 @@
<template>
<Loading v-if="loading" />
<div class="media-downloads fade-in" v-else>
<div class="no-content" v-if="!Object.keys(downloads).length">No media downloads in progress</div>
<div class="no-content" v-else-if="!Object.keys(filteredDownloads).length">No media downloads match the filter</div>
<div class="items" v-else>
<div class="row item"
:class="{selected: selectedItem === i}"
:key="i"
v-for="(media, i) in filteredDownloads"
@click="selectedItem = i"
>
<div class="col-8 left side">
<i class="icon fa" :class="{
'fa-check': media.state.toLowerCase() === 'completed',
'fa-play': media.state.toLowerCase() === 'downloading',
'fa-pause': media.state.toLowerCase() === 'paused',
'fa-times': media.state.toLowerCase() === 'cancelled',
'fa-stop': media.state.toLowerCase() === 'idle',
'fa-hourglass-half': media.state.toLowerCase() === 'started',
}" />
<div class="title" v-text="media.path || media.url" />
</div>
<div class="col-2 right side">
<span v-text="displayProgress[i]" />
</div>
<div class="col-2 right side">
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" @click="selectedItem = i">
<DropdownItem icon-class="fa fa-play" text="Play"
@click="$emit('play', {url: `file:///${media.path}`})"
v-if="media.state.toLowerCase() === 'completed'" />
<DropdownItem icon-class="fa fa-pause" text="Pause download" @click="pause(media)"
v-if="media.state.toLowerCase() === 'downloading' || media.state.toLowerCase() === 'started'" />
<DropdownItem icon-class="fa fa-rotate-left" text="Resume download" @click="resume(media)"
v-if="media.state.toLowerCase() === 'paused'" />
<DropdownItem icon-class="fa fa-eraser" text="Clear from queue" @click="clear(media)"
v-if="media.state.toLowerCase() === 'completed'" />
<DropdownItem icon-class="fa fa-stop" text="Cancel" @click="cancel(media)"
v-if="media.state.toLowerCase() !== 'completed' && media.state.toLowerCase() !== 'cancelled'" />
<DropdownItem icon-class="fa fa-trash" text="Remove file" @click="onDeleteSelected(media)"
v-if="media.state.toLowerCase() === 'completed' || media.state.toLowerCase() === 'cancelled'" />
<DropdownItem icon-class="fa fa-info" text="Media info" @click="$refs.mediaInfo.isVisible = true" />
</Dropdown>
</div>
</div>
</div>
<Modal ref="mediaInfo" title="Media info" width="80%">
<div class="modal-body media-info" v-if="selectedItem != null && downloads[selectedItem]">
<div class="row" v-if="downloads[selectedItem].name">
<div class="attr">Path</div>
<div class="value" v-text="downloads[selectedItem].path" />
</div>
<div class="row" v-if="downloads[selectedItem].url">
<div class="attr">Remote URL</div>
<div class="value">
<a :href="downloads[selectedItem].url" target="_blank" v-text="downloads[selectedItem].url" />
</div>
</div>
<div class="row" v-if="downloads[selectedItem].path">
<div class="attr">Local URL</div>
<div class="value">
<a :href="localURL(downloads[selectedItem])"
target="_blank" v-text="downloads[selectedItem].path" />
</div>
</div>
<div class="row" v-if="downloads[selectedItem].state">
<div class="attr">State</div>
<div class="value" v-text="downloads[selectedItem].state" />
</div>
<div class="row" v-if="downloads[selectedItem].progress != null">
<div class="attr">Progress</div>
<div class="value" v-text="displayProgress[selectedItem]" />
</div>
<div class="row" v-if="downloads[selectedItem].size != null">
<div class="attr">Size</div>
<div class="value" v-text="convertSize(downloads[selectedItem].size)" />
</div>
<div class="row" v-if="downloads[selectedItem].started_at">
<div class="attr">Started</div>
<div class="value" v-text="formatDateTime(downloads[selectedItem].started_at)" />
</div>
<div class="row" v-if="downloads[selectedItem].ended_at">
<div class="attr">Ended</div>
<div class="value" v-text="formatDateTime(downloads[selectedItem].ended_at)" />
</div>
</div>
</Modal>
<ConfirmDialog
ref="deleteConfirmDialog"
title="Delete file"
@input="rm"
@close="mediaToDelete = null"
>
Are you sure you want to delete the downloaded file?
</ConfirmDialog>
</div>
</template>
<script>
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import Loading from "@/components/Loading";
import Utils from "@/Utils";
import MediaUtils from "@/components/Media/Utils.vue"
import Modal from "@/components/Modal";
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
export default {
mixins: [Utils, MediaUtils],
emits: [
'play',
'refresh',
],
components: {
ConfirmDialog,
Dropdown,
DropdownItem,
Loading,
Modal,
},
props: {
downloads: {
type: Object,
default: () => ({}),
},
pluginName: {
type: String,
required: true,
},
filter: {
type: String,
default: '',
},
},
data() {
return {
loading: false,
selectedItem: null,
mediaToDelete: null,
}
},
computed: {
relativeFiles() {
if (this.selectedItem == null || !this.downloads[this.selectedItem]?.files?.length)
return []
return this.downloads[this.selectedItem].files.map((file) => file.split('/').pop())
},
displayProgress() {
return Object.values(this.downloads).reduce((acc, value) => {
let progress = this.round(value.progress, 2)
let percent = progress != null ? `${progress}%` : 'N/A'
if (value.state.toLowerCase() === 'completed')
percent = '100%'
acc[value.path] = percent
return acc
}, {})
},
filteredDownloads() {
const filter = (this.filter || '').trim().toLowerCase()
let downloads = Object.values(this.downloads)
if (filter?.length) {
downloads = downloads.filter((download) => {
return download.path.toLowerCase().includes(filter) ||
download.url.toLowerCase().includes(filter)
})
}
return downloads.reduce((acc, download) => {
acc[download.path] = download
return acc
}, {})
},
},
methods: {
async run(action, media) {
this.loading = true
try {
await this.request(
`${this.pluginName}.${action}`,
{path: media.path}
)
} finally {
this.loading = false
}
},
async pause(media) {
await this.run('pause_download', media)
},
async resume(media) {
await this.run('resume_download', media)
},
async clear(media) {
await this.run('clear_downloads', media)
if (this.downloads[media.path])
delete this.downloads[media.path]
},
async cancel(media) {
await this.run('cancel_download', media)
},
async rm() {
const media = this.mediaToDelete
if (!media)
return
try {
await this.request('file.unlink', {file: media.path})
} finally {
await this.clear(media)
}
},
localURL(media) {
return `${window.location.origin}/file?path=${encodeURIComponent(media.path)}`
},
onDeleteSelected(media) {
this.mediaToDelete = media
this.$refs.deleteConfirmDialog.show()
},
}
}
</script>
<style lang="scss" scoped>
@import "src/style/items";
.media-downloads {
height: 100%;
background: $background-color;
.no-content {
height: 100%;
}
.items {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
overflow-y: auto;
}
}
:deep(.modal-body) {
.row {
display: flex;
border-bottom: $default-border;
padding: .5em .25em;
border-radius: .5em;
&:hover {
background-color: $hover-bg;
}
.attr {
@extend .col-3;
display: inline-flex;
}
.value {
@extend .col-9;
display: inline-flex;
justify-content: right;
&.nowrap {
overflow: hidden;
white-space: nowrap;
text-overflow: clip;
}
}
}
}
:deep(.modal-body) {
.dropdown-container {
.row {
box-shadow: none;
border: none;
}
button {
border: none;
background: none;
&:hover {
color: $default-hover-fg;
}
}
}
}
</style>

View file

@ -22,6 +22,14 @@
</form>
</div>
<div class="col-s-8 col-m-7 left side" v-else-if="selectedView === 'downloads'">
<form @submit.prevent="$emit('filter-downloads', downloadFilter)">
<label class="search-box">
<input type="search" placeholder="Filter" v-model="downloadFilter">
</label>
</form>
</div>
<div class="col-s-8 col-m-7 left side" v-else-if="selectedView === 'browser'">
<label class="search-box">
<input type="search" placeholder="Filter" :value="browserFilter" @change="$emit('filter', $event.target.value)"
@ -65,6 +73,7 @@ export default {
components: {Players},
emits: [
'filter',
'filter-downloads',
'play-url',
'player-status',
'search',
@ -119,6 +128,7 @@ export default {
filterVisible: false,
query: '',
torrentURL: '',
downloadFilter: '',
}
},

View file

@ -1,15 +1,27 @@
<template>
<keep-alive>
<div class="media-plugin fade-in">
<MediaView :plugin-name="pluginName" :status="selectedPlayer?.status || {}" :track="selectedPlayer?.status || {}"
:buttons="mediaButtons" @play="pause" @pause="pause" @stop="stop" @set-volume="setVolume"
@seek="seek" @search="search" @mute="toggleMute" @unmute="toggleMute">
<MediaView :plugin-name="pluginName"
:status="selectedPlayer?.status || {}"
:track="selectedPlayer?.status || {}"
:buttons="mediaButtons"
@play="pause"
@pause="pause"
@stop="stop"
@set-volume="setVolume"
@seek="seek"
@search="search"
@mute="toggleMute"
@unmute="toggleMute"
>
<main>
<div class="nav-container from tablet" :style="navContainerStyle">
<Nav :selected-view="selectedView"
:torrent-plugin="torrentPlugin"
:download-icon-class="downloadIconClass"
@input="onNavInput"
@toggle="forceShowNav = !forceShowNav" />
@toggle="forceShowNav = !forceShowNav"
/>
</div>
<div class="view-container">
@ -20,6 +32,7 @@
:selected-item="selectedItem"
:selected-subtitles="selectedSubtitles"
:browser-filter="browserFilter"
:downloads-filter="downloadsFilter"
:show-nav-button="!forceShowNav"
ref="header"
@search="search"
@ -29,8 +42,10 @@
@show-subtitles="showSubtitlesModal = !showSubtitlesModal"
@play-url="showPlayUrlModal"
@filter="browserFilter = $event"
@filter-downloads="downloadsFilter = $event"
@toggle-nav="forceShowNav = !forceShowNav"
@source-toggle="sources[$event] = !sources[$event]" />
@source-toggle="sources[$event] = !sources[$event]"
/>
<div class="body-container" :class="{'expanded-header': $refs.header?.filterVisible}">
<Results :results="results"
@ -44,21 +59,32 @@
@play="play"
@view="view"
@download="download"
v-if="selectedView === 'search'" />
v-if="selectedView === 'search'"
/>
<Transfers :plugin-name="torrentPlugin"
<TorrentTransfers :plugin-name="torrentPlugin"
:is-media="true"
@play="play"
v-else-if="selectedView === 'torrents'" />
v-else-if="selectedView === 'torrents'"
/>
<MediaDownloads :plugin-name="pluginName"
:downloads="downloads"
:filter="downloadsFilter"
@play="play"
v-else-if="selectedView === 'downloads'"
/>
<Browser :filter="browserFilter"
:selected-playlist="selectedPlaylist"
:selected-channel="selectedChannel"
@add-to-playlist="addToPlaylistItem = $event"
@back="selectedResult = null"
@download="download"
@path-change="browserFilter = ''"
@play="play($event)"
v-else-if="selectedView === 'browser'" />
v-else-if="selectedView === 'browser'"
/>
</div>
</div>
</main>
@ -100,13 +126,14 @@ import Utils from "@/Utils";
import Browser from "@/components/panels/Media/Browser";
import Header from "@/components/panels/Media/Header";
import MediaDownloads from "@/components/panels/Media/Downloads";
import MediaUtils from "@/components/Media/Utils";
import MediaView from "@/components/Media/View";
import Nav from "@/components/panels/Media/Nav";
import PlaylistAdder from "@/components/panels/Media/PlaylistAdder";
import Results from "@/components/panels/Media/Results";
import Subtitles from "@/components/panels/Media/Subtitles";
import Transfers from "@/components/panels/Torrent/Transfers";
import TorrentTransfers from "@/components/panels/Torrent/Transfers";
import UrlPlayer from "@/components/panels/Media/UrlPlayer";
export default {
@ -115,13 +142,14 @@ export default {
components: {
Browser,
Header,
MediaDownloads,
MediaView,
Modal,
Nav,
PlaylistAdder,
Results,
Subtitles,
Transfers,
TorrentTransfers,
UrlPlayer,
},
@ -157,6 +185,7 @@ export default {
awaitingPlayTorrent: null,
urlPlay: null,
browserFilter: null,
downloadsFilter: null,
addToPlaylistItem: null,
torrentPlugin: null,
torrentPlugins: [
@ -169,6 +198,8 @@ export default {
'youtube': true,
'torrent': true,
},
downloads: {},
}
},
@ -220,6 +251,28 @@ export default {
return this.results[this.selectedResult]
},
hasPendingDownloads() {
return Object.values(this.downloads).some((download) => {
return !['completed', 'cancelled'].includes(download.state.toLowerCase())
})
},
allDownloadsCompleted() {
return Object.values(this.downloads).length && Object.values(this.downloads).every((download) => {
return ['completed', 'cancelled'].includes(download.state.toLowerCase())
})
},
downloadIconClass() {
if (this.hasPendingDownloads)
return 'glow loop'
if (this.allDownloadsCompleted)
return 'completed'
return ''
},
},
methods: {
@ -291,8 +344,11 @@ export default {
},
async download(item) {
if (item?.type === 'torrent') {
await this.downloadTorrent(item)
switch (item.type) {
case 'torrent':
return await this.downloadTorrent(item)
case 'youtube':
return await this.downloadYoutube(item)
}
},
@ -385,7 +441,32 @@ export default {
return
}
return await this.request(`${torrentPlugin}.download`, {torrent: item?.url || item})
if (!item?.url) {
this.notify({
text: 'No torrent URL available',
error: true,
})
return
}
return await this.request(`${torrentPlugin}.download`, {torrent: item.url || item})
},
async downloadYoutube(item) {
if (!item?.url) {
this.notify({
text: 'No YouTube URL available',
error: true,
})
return
}
await this.request(
`${this.pluginName}.download`,
{url: item.url},
)
},
async selectSubtitles(item) {
@ -456,15 +537,100 @@ export default {
}
},
async refreshDownloads() {
this.downloads = (await this.request(`${this.pluginName}.get_downloads`)).reduce((acc, download) => {
acc[download.path] = download
return acc
}, {})
},
onNavInput(event) {
this.selectedView = event
if (event === 'search') {
this.selectedResult = null
}
},
onDownloadStarted(event) {
this.downloads[event.path] = event
this.notify({
title: 'Media download started',
html: `Saving <b>${event.resource}</b> to <b>${event.path}</b>`,
image: {
iconClass: 'fa fa-download',
}
})
},
mounted() {
onDownloadCompleted(event) {
this.downloads[event.path] = event
this.downloads[event.path].progress = 100
this.notify({
title: 'Media download completed',
html: `Saved <b>${event.resource}</b> to <b>${event.path}</b>`,
image: {
iconClass: 'fa fa-check',
}
})
},
onDownloadError(event) {
this.downloads[event.path] = event
this.notify({
title: 'Media download error',
html: `Error downloading ${event.resource}: <b>${event.error}</b>`,
error: true,
image: {
iconClass: 'fa fa-exclamation-triangle',
}
})
},
onDownloadCancelled(event) {
this.downloads[event.path] = event
this.notify({
title: 'Media download cancelled',
html: `Cancelled download of <b>${event.resource}</b>`,
image: {
iconClass: 'fa fa-times',
}
})
},
onDownloadPaused(event) {
this.downloads[event.path] = event
this.notify({
title: 'Media download paused',
html: `Paused download of <b>${event.resource}</b>`,
image: {
iconClass: 'fa fa-pause',
}
})
},
onDownloadResumed(event) {
this.downloads[event.path] = event
this.notify({
title: 'Media download resumed',
html: `Resumed download of <b>${event.resource}</b>`,
image: {
iconClass: 'fa fa-play',
}
})
},
onDownloadProgress(event) {
this.downloads[event.path] = event
},
onDownloadClear(event) {
if (event.path in this.downloads)
delete this.downloads[event.path]
},
},
async mounted() {
this.$watch(() => this.selectedPlayer, (player) => {
if (player)
this.refresh()
@ -480,27 +646,55 @@ export default {
})
this.torrentPlugin = this.getTorrentPlugin()
this.subscribe(this.onTorrentQueued,'notify-on-torrent-queued',
this.subscribe(this.onTorrentQueued,'on-torrent-queued',
'platypush.message.event.torrent.TorrentQueuedEvent')
this.subscribe(this.onTorrentMetadata,'on-torrent-metadata',
'platypush.message.event.torrent.TorrentDownloadedMetadataEvent')
this.subscribe(this.onTorrentDownloadStart,'notify-on-torrent-download-start',
this.subscribe(this.onTorrentDownloadStart,'on-torrent-download-start',
'platypush.message.event.torrent.TorrentDownloadStartEvent')
this.subscribe(this.onTorrentDownloadCompleted,'notify-on-torrent-download-completed',
this.subscribe(this.onTorrentDownloadCompleted,'on-torrent-download-completed',
'platypush.message.event.torrent.TorrentDownloadCompletedEvent')
this.subscribe(this.onDownloadStarted,'on-download-started',
'platypush.message.event.media.MediaDownloadStartedEvent')
this.subscribe(this.onDownloadCompleted,'on-download-completed',
'platypush.message.event.media.MediaDownloadCompletedEvent')
this.subscribe(this.onDownloadError,'on-download-error',
'platypush.message.event.media.MediaDownloadErrorEvent')
this.subscribe(this.onDownloadCancelled,'on-download-cancelled',
'platypush.message.event.media.MediaDownloadCancelledEvent')
this.subscribe(this.onDownloadPaused,'on-download-paused',
'platypush.message.event.media.MediaDownloadPausedEvent')
this.subscribe(this.onDownloadResumed,'on-download-resumed',
'platypush.message.event.media.MediaDownloadResumedEvent')
this.subscribe(this.onDownloadProgress,'on-download-progress',
'platypush.message.event.media.MediaDownloadProgressEvent')
this.subscribe(this.onDownloadClear,'on-download-clear',
'platypush.message.event.media.MediaDownloadClearEvent')
if ('media.plex' in this.$root.config)
this.sources.plex = true
if ('media.jellyfin' in this.$root.config)
this.sources.jellyfin = true
await this.refreshDownloads()
},
destroy() {
this.unsubscribe('notify-on-torrent-queued')
this.unsubscribe('on-torrent-queued')
this.unsubscribe('on-torrent-metadata')
this.unsubscribe('notify-on-torrent-download-start')
this.unsubscribe('notify-on-torrent-download-completed')
this.unsubscribe('on-torrent-download-start')
this.unsubscribe('on-torrent-download-completed')
this.unsubscribe('on-download-started')
this.unsubscribe('on-download-completed')
this.unsubscribe('on-download-error')
this.unsubscribe('on-download-cancelled')
this.unsubscribe('on-download-paused')
this.unsubscribe('on-download-resumed')
this.unsubscribe('on-download-progress')
this.unsubscribe('on-download-clear')
},
}
</script>

View file

@ -28,23 +28,6 @@
</div>
</div>
<div class="row direct-url" v-else-if="item?.type === 'youtube' && item?.url">
<div class="left side">Direct URL</div>
<div class="right side">
<a :href="youtubeUrl" title="Direct URL" target="_blank" v-if="youtubeUrl">
<i class="fas fa-external-link-alt" />
</a>
<button @click="copyToClipboard(youtubeUrl)" title="Copy URL to clipboard" v-if="youtubeUrl">
<i class="fas fa-clipboard" />
</button>
<Loading v-if="loadingUrl" />
<button @click="getYoutubeUrl" title="Refresh direct URL" v-else>
<i class="fas fa-sync-alt" />
</button>
</div>
</div>
<div class="row" v-if="item?.series">
<div class="left side">TV Series</div>
<div class="right side" v-text="item.series" />
@ -185,14 +168,13 @@
<script>
import Utils from "@/Utils";
import Loading from "@/components/Loading";
import MediaUtils from "@/components/Media/Utils";
import MediaImage from "./MediaImage";
import Icons from "./icons.json";
export default {
name: "Info",
components: {Loading, MediaImage},
components: {MediaImage},
mixins: [Utils, MediaUtils],
emits: ['play'],
props: {
@ -255,26 +237,6 @@ export default {
return null
},
},
methods: {
async getYoutubeUrl() {
if (!(this.item?.type === 'youtube' && this.item?.url)) {
return null
}
try {
this.loadingUrl = true
this.youtubeUrl = await this.request(
`${this.pluginName}.get_youtube_url`,
{url: this.item.url},
)
return this.youtubeUrl
} finally {
this.loadingUrl = false
}
},
},
}
</script>

View file

@ -3,56 +3,42 @@
class="item media-item"
:class="{selected: selected}"
@click.right.prevent="$refs.dropdown.toggle()"
v-if="!hidden"
>
v-if="!hidden">
<div class="thumbnail">
<MediaImage :item="item" @play="play" />
<MediaImage :item="item" @play="$emit('play')" />
</div>
<div class="body">
<div class="row title">
<div class="col-11 left side" v-text="item.title || item.name" @click="select" />
<div class="col-11 left side" v-text="item.title || item.name" @click="$emit('select')" />
<div class="col-1 right side">
<Dropdown title="Actions" icon-class="fa fa-ellipsis-h" ref="dropdown">
<DropdownItem icon-class="fa fa-play" text="Play" @click="play"
v-if="item.type !== 'torrent' && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-play" text="Play" @click="$emit('play')"
v-if="item.type !== 'torrent'" />
<DropdownItem icon-class="fa fa-download" text="Download" @click="$emit('download')"
v-if="item.type === 'torrent' && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
v-if="(item.type === 'torrent' || item.type === 'youtube') && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-window-maximize" text="View in browser" @click="$emit('view')"
v-if="item.type === 'file' && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-list" text="Add to playlist" @click="$emit('add-to-playlist')"
v-if="item.type === 'youtube' && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-trash" text="Remove from playlist" @click="$refs.confirmPlaylistRemove.open()"
v-if="playlist && item.item_type !== 'channel' && item.item_type !== 'playlist'" />
<DropdownItem icon-class="fa fa-info-circle" text="Info" @click="select" />
v-if="item.type === 'file'" />
<DropdownItem icon-class="fa fa-info-circle" text="Info" @click="$emit('select')" />
</Dropdown>
</div>
</div>
<div class="row subtitle" v-if="item.channel_url">
<div class="row subtitle" v-if="item.channel">
<a class="channel" :href="item.channel_url" target="_blank">
<img :src="item.channel_image" class="channel-image" v-if="item.channel_image" />
<span class="channel-name" v-text="item.channel" />
</a>
</div>
<div class="row subtitle" v-if="item.item_type && item.item_type !== 'video'">
<span class="type" v-text="item.item_type[0].toUpperCase() + item.item_type.slice(1)" />
</div>
<div class="row creation-date" v-if="item.created_at">
{{ formatDateTime(item.created_at, true) }}
</div>
</div>
<ConfirmDialog ref="confirmPlaylistRemove" @input="$emit('remove-from-playlist')">
Are you sure you want to remove this item from the playlist?
</ConfirmDialog>
</div>
</template>
<script>
import ConfirmDialog from "@/components/elements/ConfirmDialog";
import Dropdown from "@/components/elements/Dropdown";
import DropdownItem from "@/components/elements/DropdownItem";
import Icons from "./icons.json";
@ -60,23 +46,9 @@ import MediaImage from "./MediaImage";
import Utils from "@/Utils";
export default {
components: {Dropdown, DropdownItem, MediaImage},
mixins: [Utils],
components: {
ConfirmDialog,
Dropdown,
DropdownItem,
MediaImage,
},
emits: [
'add-to-playlist',
'download',
'play',
'remove-from-playlist',
'select',
'view',
],
emits: ['play', 'select', 'view', 'download'],
props: {
item: {
type: Object,
@ -92,10 +64,6 @@ export default {
type: Boolean,
default: false,
},
playlist: {
default: null,
},
},
data() {
@ -103,21 +71,6 @@ export default {
typeIcons: Icons,
}
},
methods: {
play() {
if (this.item.item_type === 'playlist' || this.item.item_type === 'channel') {
this.select()
return
}
this.$emit('play');
},
select() {
this.$emit('select');
},
},
}
</script>
@ -128,16 +81,11 @@ export default {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
cursor: initial !important;
margin-bottom: 5px;
border: 1px solid transparent;
border-bottom: 1px solid transparent !important;
@include from($tablet) {
max-height: max(25em, 25%);
}
&.selected {
box-shadow: $border-shadow-bottom;
background: $selected-bg;
@ -158,7 +106,6 @@ export default {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
.row {
width: 100%;

View file

@ -5,7 +5,7 @@
</button>
<li v-for="(view, name) in displayedViews" :key="name" :title="view.displayName"
:class="{selected: name === selectedView}" @click="$emit('input', name)">
:class="{selected: name === selectedView, ...customClasses[name]}" @click="$emit('input', name)">
<i :class="view.iconClass" />
</li>
</nav>
@ -28,6 +28,10 @@ export default {
type: String,
},
downloadIconClass: {
type: String,
},
views: {
type: Object,
default: () => {
@ -42,6 +46,11 @@ export default {
displayName: 'Browser',
},
downloads: {
iconClass: 'fa fa-download',
displayName: 'Downloads',
},
torrents: {
iconClass: 'fa fa-magnet',
displayName: 'Torrents',
@ -59,6 +68,15 @@ export default {
return views
},
customClasses() {
return {
downloads: this.downloadIconClass.split(' ').reduce((acc, cls) => {
acc[cls] = true
return acc
}, {}),
}
},
},
}
</script>
@ -107,12 +125,8 @@ nav {
list-style: none;
padding: .6em;
opacity: 0.7;
&.selected,
&:hover {
border-radius: 1.2em;
margin: 0 0.2em;
}
&:hover {
background: $nav-entry-collapsed-hover-bg;
@ -122,6 +136,9 @@ nav {
background: $nav-entry-collapsed-selected-bg;
}
&.completed {
color: $ok-fg;
}
}
}
</style>

View file

@ -7,6 +7,7 @@ export default {
'add-to-playlist',
'back',
'create-playlist',
'download',
'path-change',
'play',
'remove-from-playlist',

View file

@ -9,6 +9,7 @@
<div class="body" v-else>
<Feed :filter="filter"
@add-to-playlist="$emit('add-to-playlist', $event)"
@download="$emit('download', $event)"
@play="$emit('play', $event)"
v-if="selectedView === 'feed'"
/>
@ -16,6 +17,7 @@
<Playlists :filter="filter"
:selected-playlist="selectedPlaylist_"
@add-to-playlist="$emit('add-to-playlist', $event)"
@download="$emit('download', $event)"
@play="$emit('play', $event)"
@remove-from-playlist="removeFromPlaylist"
@select="onPlaylistSelected"

View file

@ -45,6 +45,7 @@
:filter="filter"
:selected-result="selectedResult"
ref="results"
@download="$emit('download', $event)"
@play="$emit('play', $event)"
@scroll-end="loadNextPage"
@select="selectedResult = $event"
@ -59,7 +60,7 @@ import Results from "@/components/panels/Media/Results";
import Utils from "@/Utils";
export default {
emits: ['play'],
emits: ['download', 'play'],
mixins: [Utils],
components: {
Loading,

View file

@ -10,6 +10,7 @@
:sources="{'youtube': true}"
:selected-result="selectedResult"
@add-to-playlist="$emit('add-to-playlist', $event)"
@download="$emit('download', $event)"
@select="selectedResult = $event"
@play="$emit('play', $event)"
v-else />
@ -26,6 +27,7 @@ export default {
mixins: [Utils],
emits: [
'add-to-playlist',
'download',
'play',
],

View file

@ -50,6 +50,7 @@
:playlist="id"
:selected-result="selectedResult"
@add-to-playlist="$emit('add-to-playlist', $event)"
@download="$emit('download', $event)"
@play="$emit('play', $event)"
@remove-from-playlist="$emit('remove-from-playlist', $event)"
@select="selectedResult = $event"
@ -68,6 +69,7 @@ export default {
mixins: [Utils],
emits: [
'add-to-playlist',
'download',
'play',
'remove-from-playlist',
],

View file

@ -31,6 +31,7 @@
:filter="filter"
:metadata="playlistsById[selectedPlaylist.id] || selectedPlaylist"
@add-to-playlist="$emit('add-to-playlist', $event)"
@download="$emit('download', $event)"
@remove-from-playlist="$emit('remove-from-playlist', {item: $event, playlist_id: selectedPlaylist.id})"
@play="$emit('play', $event)"
/>
@ -110,6 +111,7 @@ export default {
emits: [
'add-to-playlist',
'create-playlist',
'download',
'play',
'remove-from-playlist',
'remove-playlist',

View file

@ -16,6 +16,7 @@
@add-to-queue="$emit('load-tracks', {tracks: $event, play: false})"
@add-to-queue-and-play="$emit('load-tracks', {tracks: $event, play: true})"
@back="$emit('playlist-edit', null)"
@download="$emit('download', $event)"
@info="$emit('info', $event)"
@move="$emit('track-move', {...$event, playlist: editedPlaylist})"
@play="$emit('load-tracks', {tracks: [$event], play: true})"
@ -104,6 +105,7 @@ export default {
emits: [
'add-to-playlist',
'download',
'info',
'load',
'load-tracks',

View file

@ -26,3 +26,30 @@
display: none;
}
}
.glow {
animation-duration: 2s;
-webkit-animation-duration: 2s;
animation-fill-mode: both;
animation-name: glow;
-webkit-animation-name: glow;
}
.loop {
animation-iteration-count: infinite;
-webkit-animation-iteration-count: infinite;
}
@keyframes glow {
0% {opacity: 1; box-shadow: 0 0 5px #fff;}
10% {opacity: 0.9; box-shadow: 0 0 10px $active-glow-fg-1;}
20% {opacity: 0.8; box-shadow: 0 0 20px $active-glow-fg-1;}
30% {opacity: 0.7; box-shadow: 0 0 30px $active-glow-fg-1;}
40% {opacity: 0.6; box-shadow: 0 0 40px $active-glow-fg-1;}
50% {opacity: 0.5; box-shadow: 0 0 50px $active-glow-fg-1;}
60% {opacity: 0.6; box-shadow: 0 0 40px $active-glow-fg-1;}
70% {opacity: 0.7; box-shadow: 0 0 30px $active-glow-fg-1;}
80% {opacity: 0.8; box-shadow: 0 0 20px $active-glow-fg-1;}
90% {opacity: 0.9; box-shadow: 0 0 10px $active-glow-fg-1;}
100% {opacity: 1; box-shadow: 0 0 5px #fff;}
}

View file

@ -87,6 +87,8 @@ $default-link-fg: #5f7869 !default;
/// Active
$active-glow-bg-1: #d4ffe3 !default;
$active-glow-bg-2: #9cdfb0 !default;
$active-glow-fg-1: #32b646 !default;
$active-glow-fg-2: #5f7869 !default;
/// Hover
$default-hover-fg: #35b870 !default;

View file

@ -3,13 +3,17 @@ export default {
name: "DateTime",
methods: {
formatDate(date, year=false) {
if (typeof date === 'string')
if (typeof date === 'number')
date = new Date(date * 1000)
else if (typeof date === 'string')
date = new Date(Date.parse(date))
return date.toDateString().substring(0, year ? 15 : 10)
},
formatTime(date, seconds=true) {
if (typeof date === 'number')
date = new Date(date * 1000)
if (typeof date === 'string')
date = new Date(Date.parse(date))
@ -17,6 +21,8 @@ export default {
},
formatDateTime(date, year=false, seconds=true, skipTimeIfMidnight=false) {
if (typeof date === 'number')
date = new Date(date * 1000)
if (typeof date === 'string')
date = new Date(Date.parse(date))

View file

@ -114,6 +114,10 @@ export default {
return true
},
round(value, decimals) {
return Number(Math.round(value+'e'+decimals)+'e-'+decimals);
},
},
}
</script>