mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2025-08-10 04:54:57 +02:00
Merge branch 'master' into iosChapterTrack
This commit is contained in:
commit
73d70dd480
37 changed files with 1060 additions and 48 deletions
|
@ -9,6 +9,7 @@ android {
|
|||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':byteowls-capacitor-filesharer')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-clipboard')
|
||||
|
|
|
@ -1,4 +1,8 @@
|
|||
[
|
||||
{
|
||||
"pkg": "@byteowls/capacitor-filesharer",
|
||||
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
|
|
|
@ -116,6 +116,7 @@ class MainActivity : BridgeActivity() {
|
|||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
outState.clear()
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
|
|
|
@ -2,6 +2,9 @@
|
|||
include ':capacitor-android'
|
||||
project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor')
|
||||
|
||||
include ':byteowls-capacitor-filesharer'
|
||||
project(':byteowls-capacitor-filesharer').projectDir = new File('../node_modules/@byteowls/capacitor-filesharer/android')
|
||||
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
|
|
|
@ -15,7 +15,7 @@
|
|||
<div v-if="!isPodcast && !collapsedSeries" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<p class="whitespace-normal line-clamp-2" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
<span v-if="seriesSequence">#{{ seriesSequence }} </span>{{ displayTitle }}
|
||||
</p>
|
||||
<p class="truncate text-fg-muted" :style="{ fontSize: 0.7 * sizeMultiplier + 'rem' }">{{ displayAuthor }}</p>
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
<template>
|
||||
<modals-modal v-model="show" width="90%" height="100%">
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-10 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
<div v-show="selected !== 'all'" class="absolute top-12 left-4 z-40">
|
||||
<ui-btn class="text-lg border-yellow-400 border-opacity-40 h-10" :padding-y="0" @click="clearSelected">{{ $strings.ButtonClearFilter }}</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20" style="max-height: 75%" @click.stop>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-fg/20 mt-8" style="max-height: 75%" @click.stop>
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-fg select-none relative py-4 pr-9 cursor-pointer" :class="item.value === selected ? 'bg-bg bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
<div ref="wrapper" class="modal modal-bg w-full h-full max-h-screen fixed top-0 left-0 bg-primary bg-opacity-75 flex items-center justify-center z-50 opacity-0">
|
||||
<div class="absolute top-0 left-0 w-full h-40 bg-gradient-to-b from-black to-transparent opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute z-40 top-10 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<div class="absolute z-40 top-11 right-4 h-10 w-10 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="show = false">
|
||||
<span class="material-icons text-4xl">close</span>
|
||||
</div>
|
||||
<slot name="outer" />
|
||||
|
|
283
components/stats/YearInReview.vue
Normal file
283
components/stats/YearInReview.vue
Normal file
|
@ -0,0 +1,283 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
year: Number,
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
let finishedBookCoverImgs = {}
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
resolve()
|
||||
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
|
||||
finishedBookCoverImgs[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(50, 100, 340, 160)
|
||||
addText(this.yearStats.numBooksFinished, '64px', 'bold', 'white', '0px', 160, 165)
|
||||
addText('books finished', '28px', 'normal', tanColor, '0px', 160, 210)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 2, d: 2, e: 100, f: 160 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(410, 100, 340, 160)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
|
||||
addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
|
||||
addIcon('watch_later', 'white', '52px', 440, 180)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(50, 280, 340, 160)
|
||||
addText(this.yearStats.totalListeningSessions, '64px', 'bold', 'white', '0px', 160, 345)
|
||||
addText('sessions', '28px', 'normal', tanColor, '1px', 160, 390)
|
||||
addIcon('headphones', 'white', '52px', 95, 360)
|
||||
|
||||
// Box bottom right
|
||||
createRoundedRect(410, 280, 340, 160)
|
||||
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
|
||||
addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
|
||||
addIcon('local_library', 'white', '52px', 440, 360)
|
||||
|
||||
if (!this.variant) {
|
||||
// Text stats
|
||||
const topNarrator = this.yearStats.mostListenedNarrator
|
||||
if (topNarrator) {
|
||||
addText('TOP NARRATOR', '24px', 'normal', tanColor, '1px', 70, 520)
|
||||
addText(topNarrator.name, '36px', 'bolder', 'white', '0px', 70, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topNarrator.time, true, false), '24px', 'lighter', 'white', '1px', 70, 599)
|
||||
}
|
||||
|
||||
const topGenre = this.yearStats.topGenres[0]
|
||||
if (topGenre) {
|
||||
addText('TOP GENRE', '24px', 'normal', tanColor, '1px', 430, 520)
|
||||
addText(topGenre.genre, '36px', 'bolder', 'white', '0px', 430, 564, 330)
|
||||
addText(this.$elapsedPrettyExtended(topGenre.time, true, false), '24px', 'lighter', 'white', '1px', 430, 599)
|
||||
}
|
||||
|
||||
const topAuthor = this.yearStats.topAuthors[0]
|
||||
if (topAuthor) {
|
||||
addText('TOP AUTHOR', '24px', 'normal', tanColor, '1px', 70, 670)
|
||||
addText(topAuthor.name, '36px', 'bolder', 'white', '0px', 70, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749)
|
||||
}
|
||||
|
||||
if (this.yearStats.mostListenedMonth?.time) {
|
||||
const jsdate = new Date(this.year, this.yearStats.mostListenedMonth.month, 1)
|
||||
const monthName = this.$formatJsDate(jsdate, 'LLLL')
|
||||
addText('TOP MONTH', '24px', 'normal', tanColor, '1px', 430, 670)
|
||||
addText(monthName, '36px', 'bolder', 'white', '0px', 430, 714, 330)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.mostListenedMonth.time, true, false), '24px', 'lighter', 'white', '1px', 430, 749)
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Bottom images
|
||||
finishedBookCoverImgs = Object.values(finishedBookCoverImgs)
|
||||
if (finishedBookCoverImgs.length > 0) {
|
||||
ctx.textAlign = 'center'
|
||||
addText('Some books finished this year...', '28px', 'normal', tanColor, '0px', canvas.width / 2, 530)
|
||||
|
||||
for (let i = 0; i < Math.min(5, finishedBookCoverImgs.length); i++) {
|
||||
let imgToAdd = finishedBookCoverImgs[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 570, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 524)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 524)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 584 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_my_${this.year}.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/me/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
134
components/stats/YearInReviewBanner.vue
Normal file
134
components/stats/YearInReviewBanner.vue
Normal file
|
@ -0,0 +1,134 @@
|
|||
<template>
|
||||
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 p-2 sm:p-4 mb-4">
|
||||
<!-- hack to get icon fonts loaded on init -->
|
||||
<div class="h-0 w-0 overflow-hidden opacity-0">
|
||||
<span class="material-icons-outlined">close</span>
|
||||
<span class="abs-icons icon-audiobookshelf" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="hidden md:block text-xl font-semibold">{{ yearInReviewYear }} Year in Review</p>
|
||||
<div class="hidden md:block flex-grow" />
|
||||
<ui-btn class="w-full md:w-auto" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Hide Year in Review' : `See ${yearInReviewYear} Year in Review` }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<!-- your year in review -->
|
||||
<div v-if="showYearInReview">
|
||||
<div class="w-full h-px bg-slate-200/10 my-4" />
|
||||
|
||||
<div class="flex items-center justify-center mb-2 max-w-[800px] mx-auto">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewVariant || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReview"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Your Year in Review ({{ yearInReviewVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReview" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReview">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewVariant >= 2 || processingYearInReview" class="inline-flex items-center font-semibold" @click="yearInReviewVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
<stats-year-in-review ref="yearInReview" :variant="yearInReviewVariant" :year="yearInReviewYear" :processing.sync="processingYearInReview" />
|
||||
|
||||
<!-- your year in review short -->
|
||||
<div class="w-full max-w-[800px] mx-auto my-4">
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReviewShort" class="inline-flex sm:hidden items-center font-semibold mb-1" @click="shareYearInReviewShort"> Share </ui-btn>
|
||||
<stats-year-in-review-short ref="yearInReviewShort" :year="yearInReviewYear" :processing.sync="processingYearInReviewShort" />
|
||||
</div>
|
||||
|
||||
<!-- your server in review -->
|
||||
<div v-if="isAdminOrUp" class="w-full max-w-[800px] mx-auto mb-2 mt-4 border-t pt-4 border-white/10">
|
||||
<div class="flex items-center justify-center mb-2">
|
||||
<!-- previous button -->
|
||||
<ui-btn small :disabled="!yearInReviewServerVariant || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant--">
|
||||
<span class="material-icons text-lg sm:pr-1 py-px sm:py-0">chevron_left</span>
|
||||
<span class="hidden sm:inline-block pr-2">Previous</span>
|
||||
</ui-btn>
|
||||
<!-- share button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex sm:hidden items-center font-semibold ml-1 sm:ml-2" @click="shareYearInReviewServer"> Share </ui-btn>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p class="hidden sm:block text-lg font-semibold">Server Year in Review ({{ yearInReviewServerVariant + 1 }})</p>
|
||||
<p class="block sm:hidden text-lg font-semibold">{{ yearInReviewServerVariant + 1 }}</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
<!-- refresh button -->
|
||||
<ui-btn small :disabled="processingYearInReviewServer" class="inline-flex items-center font-semibold mr-1 sm:mr-2" @click="refreshYearInReviewServer">
|
||||
<span class="hidden sm:inline-block">Refresh</span>
|
||||
<span class="material-icons sm:!hidden text-lg py-px">refresh</span>
|
||||
</ui-btn>
|
||||
<!-- next button -->
|
||||
<ui-btn small :disabled="yearInReviewServerVariant >= 2 || processingYearInReviewServer" class="inline-flex items-center font-semibold" @click="yearInReviewServerVariant++">
|
||||
<span class="hidden sm:inline-block pl-2">Next</span>
|
||||
<span class="material-icons-outlined text-lg sm:pl-1 py-px sm:py-0">chevron_right</span>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewServer" :year="yearInReviewYear" :variant="yearInReviewServerVariant" :processing.sync="processingYearInReviewServer" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showYearInReview: false,
|
||||
yearInReviewYear: 0,
|
||||
yearInReviewVariant: 0,
|
||||
yearInReviewServerVariant: 0,
|
||||
processingYearInReview: false,
|
||||
processingYearInReviewShort: false,
|
||||
processingYearInReviewServer: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
shareYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.share()
|
||||
},
|
||||
shareYearInReview() {
|
||||
this.$refs.yearInReview.share()
|
||||
},
|
||||
shareYearInReviewShort() {
|
||||
this.$refs.yearInReviewShort.share()
|
||||
},
|
||||
refreshYearInReviewServer() {
|
||||
this.$refs.yearInReviewServer.refresh()
|
||||
},
|
||||
refreshYearInReview() {
|
||||
this.$refs.yearInReview.refresh()
|
||||
this.$refs.yearInReviewShort.refresh()
|
||||
},
|
||||
clickShowYearInReview() {
|
||||
this.showYearInReview = !this.showYearInReview
|
||||
}
|
||||
},
|
||||
beforeMount() {
|
||||
this.yearInReviewYear = new Date().getFullYear()
|
||||
// When not December show previous year
|
||||
if (new Date().getMonth() < 11) {
|
||||
this.yearInReviewYear--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
262
components/stats/YearInReviewServer.vue
Normal file
262
components/stats/YearInReviewServer.vue
Normal file
|
@ -0,0 +1,262 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
variant: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
variant() {
|
||||
this.init()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 800
|
||||
canvas.height = 800
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
let imgsToAdd = {}
|
||||
|
||||
if (this.yearStats.booksAddedWithCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-130, -120)
|
||||
for (let x = 0; x < 5; x++) {
|
||||
for (let y = 0; y < 5; y++) {
|
||||
const coverIndex = index % this.yearStats.booksAddedWithCovers.length
|
||||
let libraryItemId = this.yearStats.booksAddedWithCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
|
||||
if (!imgsToAdd[libraryItemId]) {
|
||||
imgsToAdd[libraryItemId] = {
|
||||
img,
|
||||
sx,
|
||||
sy,
|
||||
sw
|
||||
}
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(40, 100, 230, 100)
|
||||
ctx.textAlign = 'center'
|
||||
addText(this.yearStats.numBooksAdded, '48px', 'bold', 'white', '0px', 155, 140)
|
||||
addText('books added', '18px', 'normal', tanColor, '0px', 155, 170)
|
||||
|
||||
// Box top right
|
||||
createRoundedRect(285, 100, 230, 100)
|
||||
addText(this.yearStats.numAuthorsAdded, '48px', 'bold', 'white', '0px', 400, 140)
|
||||
addText('authors added', '18px', 'normal', tanColor, '0px', 400, 170)
|
||||
|
||||
// Box bottom left
|
||||
createRoundedRect(530, 100, 230, 100)
|
||||
addText(this.yearStats.numListeningSessions, '48px', 'bold', 'white', '0px', 645, 140)
|
||||
addText('sessions', '18px', 'normal', tanColor, '1px', 645, 170)
|
||||
|
||||
// Text stats
|
||||
if (this.yearStats.totalBooksAddedSize) {
|
||||
addText('Your book collection grew to...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 260)
|
||||
addText(this.$bytesPretty(this.yearStats.totalBooksSize), '36px', 'bolder', 'white', '0px', canvas.width / 2, 300)
|
||||
addText('+' + this.$bytesPretty(this.yearStats.totalBooksAddedSize), '20px', 'lighter', 'white', '0px', canvas.width / 2, 330)
|
||||
}
|
||||
|
||||
if (this.yearStats.totalBooksAddedDuration) {
|
||||
addText('With a total duration of...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 400)
|
||||
addText(this.$elapsedPrettyExtended(this.yearStats.totalBooksDuration, true, false), '36px', 'bolder', 'white', '0px', canvas.width / 2, 440)
|
||||
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
|
||||
}
|
||||
|
||||
if (!this.variant) {
|
||||
// Bottom images
|
||||
imgsToAdd = Object.values(imgsToAdd)
|
||||
if (imgsToAdd.length > 0) {
|
||||
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
|
||||
|
||||
for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
|
||||
let imgToAdd = imgsToAdd[i]
|
||||
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 1) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topNarrators.length) {
|
||||
addText('TOP NARRATORS', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topNarrators.length; i++) {
|
||||
addText(this.yearStats.topNarrators[i].name, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
} else if (this.variant === 2) {
|
||||
// Text stats
|
||||
ctx.textAlign = 'left'
|
||||
if (this.yearStats.topAuthors.length) {
|
||||
addText('TOP AUTHORS', '24px', 'normal', tanColor, '1px', 70, 549)
|
||||
for (let i = 0; i < this.yearStats.topAuthors.length; i++) {
|
||||
addText(this.yearStats.topAuthors[i].name, '36px', 'bolder', 'white', '0px', 70, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
|
||||
if (this.yearStats.topGenres.length) {
|
||||
addText('TOP GENRES', '24px', 'normal', tanColor, '1px', 430, 549)
|
||||
for (let i = 0; i < this.yearStats.topGenres.length; i++) {
|
||||
addText(this.yearStats.topGenres[i].genre, '36px', 'bolder', 'white', '0px', 430, 609 + i * 60, 330)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_server_${this.year}.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
192
components/stats/YearInReviewShort.vue
Normal file
192
components/stats/YearInReviewShort.vue
Normal file
|
@ -0,0 +1,192 @@
|
|||
<template>
|
||||
<div>
|
||||
<div v-if="processing" class="max-w-[600px] h-32 sm:h-[200px] flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
<img v-else-if="dataUrl" :src="dataUrl" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { FileSharer } from '@byteowls/capacitor-filesharer'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
year: Number
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
canvas: null,
|
||||
dataUrl: null,
|
||||
yearStats: null
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async initCanvas() {
|
||||
if (!this.yearStats) return
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 600
|
||||
canvas.height = 200
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
const createRoundedRect = (x, y, w, h) => {
|
||||
const grd1 = ctx.createLinearGradient(x, y, x + w, y + h)
|
||||
grd1.addColorStop(0, '#44444455')
|
||||
grd1.addColorStop(1, '#ffffff11')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.strokeStyle = '#C0C0C088'
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(x, y, w, h, [20])
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const addText = (text, fontSize, fontWeight, color, letterSpacing, x, y, maxWidth = 0) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontWeight} ${fontSize} Source Sans Pro`
|
||||
ctx.letterSpacing = letterSpacing
|
||||
|
||||
// If maxWidth is specified then continue to remove chars until under maxWidth and add ellipsis
|
||||
if (maxWidth) {
|
||||
let txtWidth = ctx.measureText(text).width
|
||||
while (txtWidth > maxWidth) {
|
||||
console.warn(`Text "${text}" is greater than max width ${maxWidth} (width:${txtWidth})`)
|
||||
if (text.endsWith('...')) text = text.slice(0, -4) // Repeated checks remove 1 char at a time
|
||||
else text = text.slice(0, -3) // First check remove last 3 chars
|
||||
text += '...'
|
||||
txtWidth = ctx.measureText(text).width
|
||||
console.log(`Checking text "${text}" (width:${txtWidth})`)
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(text, x, y)
|
||||
}
|
||||
|
||||
const addIcon = (icon, color, fontSize, x, y) => {
|
||||
ctx.fillStyle = color
|
||||
ctx.font = `${fontSize} Material Icons Outlined`
|
||||
ctx.fillText(icon, x, y)
|
||||
}
|
||||
|
||||
// Bg color
|
||||
ctx.fillStyle = '#232323'
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Cover image tiles
|
||||
const bookCovers = this.yearStats.finishedBooksWithCovers
|
||||
bookCovers.push(...this.yearStats.booksWithCovers)
|
||||
|
||||
if (bookCovers.length) {
|
||||
let index = 0
|
||||
ctx.globalAlpha = 0.25
|
||||
ctx.save()
|
||||
ctx.translate(canvas.width / 2, canvas.height / 2)
|
||||
ctx.rotate((-Math.PI / 180) * 25)
|
||||
ctx.translate(-canvas.width / 2, -canvas.height / 2)
|
||||
ctx.translate(-10, -90)
|
||||
for (let x = 0; x < 4; x++) {
|
||||
for (let y = 0; y < 3; y++) {
|
||||
const coverIndex = index % bookCovers.length
|
||||
let libraryItemId = bookCovers[coverIndex]
|
||||
index++
|
||||
|
||||
await new Promise((resolve) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.addEventListener('load', () => {
|
||||
let sw = img.width
|
||||
if (img.width > img.height) {
|
||||
sw = img.height
|
||||
}
|
||||
let sx = -(sw - img.width) / 2
|
||||
let sy = -(sw - img.height) / 2
|
||||
ctx.drawImage(img, sx, sy, sw, sw, 155 * x, 155 * y, 155, 155)
|
||||
resolve()
|
||||
})
|
||||
img.addEventListener('error', () => {
|
||||
resolve()
|
||||
})
|
||||
img.src = this.$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)
|
||||
})
|
||||
}
|
||||
}
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1
|
||||
ctx.textBaseline = 'middle'
|
||||
|
||||
// Create gradient
|
||||
const grd1 = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||
grd1.addColorStop(0, '#000000aa')
|
||||
grd1.addColorStop(1, '#cd9d49aa')
|
||||
ctx.fillStyle = grd1
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Top Abs icon
|
||||
let tanColor = '#ffdb70'
|
||||
ctx.fillStyle = tanColor
|
||||
ctx.font = '42px absicons'
|
||||
ctx.fillText('\ue900', 15, 36)
|
||||
|
||||
// Top text
|
||||
addText('audiobookshelf', '28px', 'normal', tanColor, '0px', 65, 28)
|
||||
addText(`${this.year} YEAR IN REVIEW`, '18px', 'bold', 'white', '1px', 65, 51)
|
||||
|
||||
// Top left box
|
||||
createRoundedRect(15, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksFinished, '48px', 'bold', 'white', '0px', 105, 120)
|
||||
addText('books finished', '20px', 'normal', tanColor, '0px', 105, 155)
|
||||
const readIconPath = new Path2D()
|
||||
readIconPath.addPath(new Path2D('M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z'), { a: 1.5, d: 1.5, e: 55, f: 115 })
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.fill(readIconPath)
|
||||
|
||||
createRoundedRect(305, 75, 280, 110)
|
||||
addText(this.yearStats.numBooksListened, '48px', 'bold', 'white', '0px', 400, 120)
|
||||
addText('books listened to', '20px', 'normal', tanColor, '0px', 400, 155)
|
||||
addIcon('local_library', 'white', '42px', 345, 130)
|
||||
|
||||
this.canvas = canvas
|
||||
this.dataUrl = canvas.toDataURL('png')
|
||||
},
|
||||
share() {
|
||||
const base64Data = this.dataUrl.split(';base64,').pop()
|
||||
FileSharer.share({
|
||||
filename: `audiobookshelf_my_${this.year}_short.png`,
|
||||
contentType: 'image/png',
|
||||
base64Data
|
||||
}).catch((error) => {
|
||||
if (error.message !== 'USER_CANCELLED') {
|
||||
console.error('Failed to share', error.message)
|
||||
this.$toast.error('Failed to share: ' + error.message)
|
||||
}
|
||||
})
|
||||
},
|
||||
refresh() {
|
||||
this.init()
|
||||
},
|
||||
init() {
|
||||
this.$emit('update:processing', true)
|
||||
this.$nativeHttp
|
||||
.get(`/api/me/stats/year/${this.year}`)
|
||||
.then((data) => {
|
||||
this.yearStats = data || []
|
||||
return this.initCanvas()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load year stats')
|
||||
})
|
||||
.finally(() => {
|
||||
this.$emit('update:processing', false)
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -30,6 +30,7 @@ export default {
|
|||
default: ''
|
||||
},
|
||||
paddingX: Number,
|
||||
paddingY: Number,
|
||||
small: Boolean,
|
||||
loading: Boolean,
|
||||
disabled: Boolean
|
||||
|
@ -48,14 +49,17 @@ export default {
|
|||
if (this.small) {
|
||||
list.push('text-sm')
|
||||
if (this.paddingX === undefined) list.push('px-4')
|
||||
list.push('py-1')
|
||||
if (this.paddingY === undefined) list.push('py-1')
|
||||
} else {
|
||||
if (this.paddingX === undefined) list.push('px-8')
|
||||
list.push('py-2')
|
||||
if (this.paddingY === undefined) list.push('py-2')
|
||||
}
|
||||
if (this.paddingX !== undefined) {
|
||||
list.push(`px-${this.paddingX}`)
|
||||
}
|
||||
if (this.paddingY !== undefined) {
|
||||
list.push(`py-${this.paddingY}`)
|
||||
}
|
||||
if (this.disabled) {
|
||||
list.push('cursor-not-allowed')
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ install! 'cocoapods', :disable_input_output_paths => true
|
|||
def capacitor_pods
|
||||
pod 'Capacitor', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'CapacitorCordova', :path => '../../node_modules/@capacitor/ios'
|
||||
pod 'ByteowlsCapacitorFilesharer', :path => '../../node_modules/@byteowls/capacitor-filesharer'
|
||||
pod 'CapacitorApp', :path => '../../node_modules/@capacitor/app'
|
||||
pod 'CapacitorBrowser', :path => '../../node_modules/@capacitor/browser'
|
||||
pod 'CapacitorClipboard', :path => '../../node_modules/@capacitor/clipboard'
|
||||
|
|
|
@ -1,5 +1,7 @@
|
|||
PODS:
|
||||
- Alamofire (5.6.4)
|
||||
- ByteowlsCapacitorFilesharer (5.0.0):
|
||||
- Capacitor
|
||||
- Capacitor (5.4.0):
|
||||
- CapacitorCordova
|
||||
- CapacitorApp (5.0.6):
|
||||
|
@ -29,6 +31,7 @@ PODS:
|
|||
|
||||
DEPENDENCIES:
|
||||
- Alamofire (~> 5.5)
|
||||
- "ByteowlsCapacitorFilesharer (from `../../node_modules/@byteowls/capacitor-filesharer`)"
|
||||
- "Capacitor (from `../../node_modules/@capacitor/ios`)"
|
||||
- "CapacitorApp (from `../../node_modules/@capacitor/app`)"
|
||||
- "CapacitorBrowser (from `../../node_modules/@capacitor/browser`)"
|
||||
|
@ -49,6 +52,8 @@ SPEC REPOS:
|
|||
- RealmSwift
|
||||
|
||||
EXTERNAL SOURCES:
|
||||
ByteowlsCapacitorFilesharer:
|
||||
:path: "../../node_modules/@byteowls/capacitor-filesharer"
|
||||
Capacitor:
|
||||
:path: "../../node_modules/@capacitor/ios"
|
||||
CapacitorApp:
|
||||
|
@ -74,6 +79,7 @@ EXTERNAL SOURCES:
|
|||
|
||||
SPEC CHECKSUMS:
|
||||
Alamofire: 4e95d97098eacb88856099c4fc79b526a299e48c
|
||||
ByteowlsCapacitorFilesharer: f6a773825632d65d5404a34764c4a3fd857bb176
|
||||
Capacitor: a5cd803e02b471591c81165f400ace01f40b11d3
|
||||
CapacitorApp: 024e1b1bea5f883d79f6330d309bc441c88ad04a
|
||||
CapacitorBrowser: 7a0fb6a1011abfaaf2dfedfd8248f942a8eda3d6
|
||||
|
@ -88,6 +94,6 @@ SPEC CHECKSUMS:
|
|||
Realm: 3fd136cb4c83a927482a7f1612496d37beed3cf5
|
||||
RealmSwift: 513d4dcbf5bfc4d573454088b592685fc48dd716
|
||||
|
||||
PODFILE CHECKSUM: 7a8fc177ef0646dd60a1ee8aa387964975fcc1e3
|
||||
PODFILE CHECKSUM: 02e6ffe2f51a453ce222ee9af0e55e9448d8514c
|
||||
|
||||
COCOAPODS: 1.12.1
|
||||
|
|
|
@ -589,12 +589,34 @@ class AudioPlayer: NSObject {
|
|||
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { [weak self] event in
|
||||
self?.play(allowSeekBack: true)
|
||||
guard let strongSelf = self else { return .commandFailed }
|
||||
if strongSelf.isPlaying() {
|
||||
strongSelf.pause()
|
||||
} else {
|
||||
strongSelf.play(allowSeekBack: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { [weak self] event in
|
||||
self?.pause()
|
||||
guard let strongSelf = self else { return .commandFailed }
|
||||
if strongSelf.isPlaying() {
|
||||
strongSelf.pause()
|
||||
} else {
|
||||
strongSelf.play(allowSeekBack: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
commandCenter.togglePlayPauseCommand.isEnabled = true
|
||||
commandCenter.togglePlayPauseCommand.addTarget { [weak self] event in
|
||||
guard let strongSelf = self else { return .commandFailed }
|
||||
if strongSelf.isPlaying() {
|
||||
strongSelf.pause()
|
||||
} else {
|
||||
strongSelf.play(allowSeekBack: true)
|
||||
}
|
||||
return .success
|
||||
}
|
||||
|
||||
|
|
30
package-lock.json
generated
30
package-lock.json
generated
|
@ -8,6 +8,7 @@
|
|||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.70-beta",
|
||||
"dependencies": {
|
||||
"@byteowls/capacitor-filesharer": "^5.0.0",
|
||||
"@capacitor/android": "^5.0.0",
|
||||
"@capacitor/app": "^5.0.6",
|
||||
"@capacitor/browser": "^5.1.0",
|
||||
|
@ -1939,6 +1940,17 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@byteowls/capacitor-filesharer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@byteowls/capacitor-filesharer/-/capacitor-filesharer-5.0.0.tgz",
|
||||
"integrity": "sha512-LtIMd8Ge94Kj9BQWF+A646drHAkRVC6ulTPZ1InkQtNH4VIy3WfYilN20VZM5KkOvrQ1lslXFIJwquwKptLgmw==",
|
||||
"dependencies": {
|
||||
"file-saver": "2.0.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@capacitor/core": ">=5"
|
||||
}
|
||||
},
|
||||
"node_modules/@capacitor/android": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
|
||||
|
@ -9195,6 +9207,11 @@
|
|||
"node": ">=8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"node_modules/file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
|
@ -21078,6 +21095,14 @@
|
|||
"to-fast-properties": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"@byteowls/capacitor-filesharer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@byteowls/capacitor-filesharer/-/capacitor-filesharer-5.0.0.tgz",
|
||||
"integrity": "sha512-LtIMd8Ge94Kj9BQWF+A646drHAkRVC6ulTPZ1InkQtNH4VIy3WfYilN20VZM5KkOvrQ1lslXFIJwquwKptLgmw==",
|
||||
"requires": {
|
||||
"file-saver": "2.0.5"
|
||||
}
|
||||
},
|
||||
"@capacitor/android": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@capacitor/android/-/android-5.4.0.tgz",
|
||||
|
@ -26353,6 +26378,11 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"file-saver": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
|
||||
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA=="
|
||||
},
|
||||
"file-uri-to-path": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
|
||||
|
|
|
@ -13,6 +13,7 @@
|
|||
"ionic:serve": "npm run start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@byteowls/capacitor-filesharer": "^5.0.0",
|
||||
"@capacitor/android": "^5.0.0",
|
||||
"@capacitor/app": "^5.0.6",
|
||||
"@capacitor/browser": "^5.1.0",
|
||||
|
|
|
@ -54,7 +54,7 @@
|
|||
<span v-if="!showPlay" class="px-2 text-base">{{ $strings.ButtonRead }} {{ ebookFormat }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn v-if="showDownload" :color="downloadItem ? 'warning' : 'primary'" class="flex items-center justify-center mx-1" :padding-x="2" @click="downloadClick">
|
||||
<span class="material-icons" :class="(downloadItem || startingDownload) ? 'animate-pulse' : ''">{{ (downloadItem || startingDownload) ? 'downloading' : 'download' }}</span>
|
||||
<span class="material-icons" :class="downloadItem || startingDownload ? 'animate-pulse' : ''">{{ downloadItem || startingDownload ? 'downloading' : 'download' }}</span>
|
||||
</ui-btn>
|
||||
<ui-btn color="primary" class="flex items-center justify-center mx-1" :padding-x="2" @click="moreButtonPress">
|
||||
<span class="material-icons">more_vert</span>
|
||||
|
@ -79,8 +79,8 @@
|
|||
<div v-if="podcastAuthor" class="text-sm">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="bookAuthors && bookAuthors.length" class="text-sm">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link>
|
||||
<span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">,</span>
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
@ -90,8 +90,8 @@
|
|||
<div v-if="series && series.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelSeries }}</div>
|
||||
<div v-if="series && series.length" class="truncate text-sm">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link>
|
||||
<span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">,</span>
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
@ -101,16 +101,16 @@
|
|||
<div v-if="narrators && narrators.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelNarrators }}</div>
|
||||
<div v-if="narrators && narrators.length" class="truncate text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link>
|
||||
<span :key="index" v-if="index < narrators.length - 1">,</span>
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="genres.length" class="text-fg-muted uppercase text-sm">{{ $strings.LabelGenres }}</div>
|
||||
<div v-if="genres.length" class="truncate text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link>
|
||||
<span :key="index" v-if="index < genres.length - 1">,</span>
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -1,5 +1,8 @@
|
|||
<template>
|
||||
<div class="w-full h-full px-0 py-4 overflow-y-auto">
|
||||
<!-- Year in review banner shown at the top in December and January -->
|
||||
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
|
||||
|
||||
<h1 class="text-xl px-4">
|
||||
{{ $strings.HeaderYourStats }}
|
||||
</h1>
|
||||
|
@ -50,6 +53,9 @@
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Year in review banner shown at the bottom Feb - Nov -->
|
||||
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
@ -58,7 +64,8 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
listeningStats: null,
|
||||
windowWidth: 0
|
||||
windowWidth: 0,
|
||||
showYearInReviewBanner: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
|
@ -103,7 +110,12 @@ export default {
|
|||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
console.log('Loaded user listening data', this.listeningStats)
|
||||
|
||||
let month = new Date().getMonth()
|
||||
// January and December show year in review banner
|
||||
if (month === 11 || month === 0) {
|
||||
this.showYearInReviewBanner = true
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
|
|
@ -94,26 +94,31 @@ Vue.prototype.$elapsedPretty = (seconds, useFullNames = false) => {
|
|||
return `${hours} ${useFullNames ? `hour${hours === 1 ? '' : 's'}` : 'hr'} ${minutes} ${useFullNames ? `minute${minutes === 1 ? '' : 's'}` : 'min'}`
|
||||
}
|
||||
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true) => {
|
||||
Vue.prototype.$elapsedPrettyExtended = (seconds, useDays = true, showSeconds = true) => {
|
||||
if (isNaN(seconds) || seconds === null) return ''
|
||||
seconds = Math.round(seconds)
|
||||
|
||||
var minutes = Math.floor(seconds / 60)
|
||||
let minutes = Math.floor(seconds / 60)
|
||||
seconds -= minutes * 60
|
||||
var hours = Math.floor(minutes / 60)
|
||||
let hours = Math.floor(minutes / 60)
|
||||
minutes -= hours * 60
|
||||
|
||||
var days = 0
|
||||
let days = 0
|
||||
if (useDays || Math.floor(hours / 24) >= 100) {
|
||||
days = Math.floor(hours / 24)
|
||||
hours -= days * 24
|
||||
}
|
||||
|
||||
var strs = []
|
||||
// If not showing seconds then round minutes up
|
||||
if (minutes && seconds && !showSeconds) {
|
||||
if (seconds >= 30) minutes++
|
||||
}
|
||||
|
||||
const strs = []
|
||||
if (days) strs.push(`${days}d`)
|
||||
if (hours) strs.push(`${hours}h`)
|
||||
if (minutes) strs.push(`${minutes}m`)
|
||||
if (seconds) strs.push(`${seconds}s`)
|
||||
if (seconds && showSeconds) strs.push(`${seconds}s`)
|
||||
return strs.join(' ')
|
||||
}
|
||||
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Popis",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Interpret",
|
||||
"LabelNarrators": "Interpreti",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Nedokončeno",
|
||||
"LabelNotStarted": "Nezahájeno",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Datum vydání",
|
||||
"LabelPublishYear": "Rok vydání",
|
||||
"LabelRead": "Číst",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Vlastní e-mail vlastníka",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Beskrivelse",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Fortæller",
|
||||
"LabelNarrators": "Fortællere",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Ikke færdig",
|
||||
"LabelNotStarted": "Ikke påbegyndt",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Udgivelsesdato",
|
||||
"LabelPublishYear": "Udgivelsesår",
|
||||
"LabelRead": "Læst",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Brugerdefineret ejerens e-mail",
|
||||
|
|
|
@ -104,15 +104,16 @@
|
|||
"LabelContinueBooks": "Bücher fortfahren",
|
||||
"LabelContinueEpisodes": "Episoden fortfahren",
|
||||
"LabelContinueListening": "Weiterhören",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Serie fortsetzen",
|
||||
"LabelCustomTime": "Benutzerdefinierte zeit",
|
||||
"LabelCustomTime": "Benutzerdefinierte Zeit",
|
||||
"LabelDescription": "Beschreibung",
|
||||
"LabelDisableAudioFadeOut": "Deaktiviere Audio abklingen",
|
||||
"LabelDisableAudioFadeOutHelp": "Die Audiolautstärke wird verringert, wenn weniger als 1 Minute für den Einschlaf-Timer verbleibt. Aktivieren Sie diese Einstellung, um nicht abzuklingen.",
|
||||
"LabelDisableAutoRewind": "Deaktiviere automatisches Rückspulen",
|
||||
"LabelDisableShakeToReset": "Deaktiviere Schütteln zum Zurücksetzen",
|
||||
"LabelDisableShakeToResetHelp": "Wenn Sie Ihr Gerät schütteln, während der Timer läuft ODER innerhalb von 2 Minuten nach Ablauf des Timers, wird der Sleep-Timer zurückgesetzt. Aktivieren Sie diese Einstellung, um das Schütteln zum Zurücksetzen zu deaktivieren.",
|
||||
"LabelDisableVibrateOnReset": "Deaktiviere vibrieren beim Zurücksetzen",
|
||||
"LabelDisableVibrateOnReset": "Deaktiviere Vibrieren beim Zurücksetzen",
|
||||
"LabelDisableVibrateOnResetHelp": "Wenn der Einschlaf-Timer zurückgesetzt wird, vibriert Ihr Gerät. Aktivieren Sie diese Einstellung, um nicht zu vibrieren, wenn der Einschlaf-Timer zurückgesetzt wird.",
|
||||
"LabelDiscover": "Entdecken",
|
||||
"LabelDownload": "Herunterladen",
|
||||
|
@ -121,7 +122,7 @@
|
|||
"LabelEbook": "E-Book",
|
||||
"LabelEbooks": "Ebooks",
|
||||
"LabelEnable": "Aktivieren",
|
||||
"LabelEnableMp3IndexSeeking": "Aktiviere MP3-Indexsuche",
|
||||
"LabelEnableMp3IndexSeeking": "MP3-Indexsuche",
|
||||
"LabelEnableMp3IndexSeekingHelp": "Diese Einstellung sollte nur aktiviert werden, wenn Sie MP3-Dateien haben, bei denen das Navigieren (Seeking) nicht korrekt funktioniert. Ungenaues Navigieren ist höchstwahrscheinlich auf MP3-Dateien mit variabler Bitrate (VBR) zurückzuführen. Diese Einstellung erzwingt das Index-Navigieren (Index Seeking), bei dem eine Zeit-zu-Byte-Zuordnung erstellt wird, während die Datei gelesen wird. In einigen Fällen kann es bei großen MP3-Dateien zu einer Verzögerung kommen, wenn gegen Ende der Datei navigiert wird.",
|
||||
"LabelEnd": "Ende",
|
||||
"LabelEndOfChapter": "Kapitelende",
|
||||
|
@ -140,7 +141,7 @@
|
|||
"LabelHapticFeedback": "Haptische Rückmeldung",
|
||||
"LabelHasEbook": "Hat Ebook",
|
||||
"LabelHasSupplementaryEbook": "Hat zusätzliches Ebook",
|
||||
"LabelHeavy": "Schwer",
|
||||
"LabelHeavy": "Stark",
|
||||
"LabelHigh": "Hoch",
|
||||
"LabelHost": "Host",
|
||||
"LabelIncomplete": "Unvollständig",
|
||||
|
@ -149,14 +150,14 @@
|
|||
"LabelJumpBackwardsTime": "Rückspulzeit",
|
||||
"LabelJumpForwardsTime": "Vorwärtsspulzeit",
|
||||
"LabelLanguage": "Sprache",
|
||||
"LabelLight": "Licht",
|
||||
"LabelLight": "Leicht",
|
||||
"LabelLineSpacing": "Zeilenabstand",
|
||||
"LabelListenAgain": "Erneut anhören",
|
||||
"LabelLocalBooks": "Lokale Bücher",
|
||||
"LabelLocalPodcasts": "Lokale Podcasts",
|
||||
"LabelLockOrientation": "Ausrichtung sperren",
|
||||
"LabelLockOrientation": "automatische Bildschirmausrichtung sperren",
|
||||
"LabelLockPlayer": "Mediaplayer sperren",
|
||||
"LabelLow": "Niedrig",
|
||||
"LabelLow": "Wenig",
|
||||
"LabelMediaType": "Medientyp",
|
||||
"LabelMedium": "Mittel",
|
||||
"LabelMore": "Mehr",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Erzähler",
|
||||
"LabelNarrators": "Erzähler",
|
||||
"LabelNewestAuthors": "Neueste Autoren",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "Nein",
|
||||
"LabelNotFinished": "nicht beendet",
|
||||
"LabelNotStarted": "Nicht begonnen",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Veröffentlichungsdatum",
|
||||
"LabelPublishYear": "Jahr",
|
||||
"LabelRead": "Lesen",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Kürzlich hinzugefügt",
|
||||
"LabelRecentSeries": "Aktuelle Serien",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Benutzerdefinierte Eigentümer-E-Mail",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotStarted": "Not Started",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Descripción",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrador",
|
||||
"LabelNarrators": "Narradores",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "No Terminado",
|
||||
"LabelNotStarted": "Sin Iniciar",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Fecha de Publicación",
|
||||
"LabelPublishYear": "Año de Publicación",
|
||||
"LabelRead": "Leído",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Email de dueño personalizado",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrateur",
|
||||
"LabelNarrators": "Narrateurs",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Non terminé(e)",
|
||||
"LabelNotStarted": "Non Démarré(e)",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Date de publication",
|
||||
"LabelPublishYear": "Année d’édition",
|
||||
"LabelRead": "Lire",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Courriel du propriétaire personnalisé",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotStarted": "Not Started",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Description",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Narrators",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Not Finished",
|
||||
"LabelNotStarted": "Not Started",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Pub Date",
|
||||
"LabelPublishYear": "Publish Year",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Opis",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Naratori",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Nedovršeno",
|
||||
"LabelNotStarted": "Not Started",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Datam izdavanja",
|
||||
"LabelPublishYear": "Godina izdavanja",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Descrizione",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narratore",
|
||||
"LabelNarrators": "Narratori",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Da Completare",
|
||||
"LabelNotStarted": "Non iniziato",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Data Pubblicazione",
|
||||
"LabelPublishYear": "Anno Pubblicazione",
|
||||
"LabelRead": "Leggi",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Email del proprietario personalizzato",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Aprašymas",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Skaitytojas",
|
||||
"LabelNarrators": "Skaitytojai",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Nebaigta",
|
||||
"LabelNotStarted": "Nepasileista",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Publikavimo data",
|
||||
"LabelPublishYear": "Leidimo metai",
|
||||
"LabelRead": "Skaityta",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Pasirinktinis savininko el. paštas",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Beschrijving",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Verteller",
|
||||
"LabelNarrators": "Vertellers",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Niet Voltooid",
|
||||
"LabelNotStarted": "Niet Gestart",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Publicatiedatum",
|
||||
"LabelPublishYear": "Jaar van uitgave",
|
||||
"LabelRead": "Lees",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Aangepast e-mailadres eigenaar",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Beskrivelse",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Forteller",
|
||||
"LabelNarrators": "Fortellere",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Ikke fullført",
|
||||
"LabelNotStarted": "Ikke startet",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Publiseringsdato",
|
||||
"LabelPublishYear": "Publikasjonsår",
|
||||
"LabelRead": "Les",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Tilpasset eier Epost",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Opis",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Narrator",
|
||||
"LabelNarrators": "Lektorzy",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Nieukończone",
|
||||
"LabelNotStarted": "Nie rozpoęczto",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Data publikacji",
|
||||
"LabelPublishYear": "Rok publikacji",
|
||||
"LabelRead": "Read",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Custom owner Email",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Продолжить",
|
||||
"LabelContinueEpisodes": "Продолжить эпизоды",
|
||||
"LabelContinueListening": "Продолжить слушать",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Продолжить серии",
|
||||
"LabelCustomTime": "Пользовательское время",
|
||||
"LabelDescription": "Описание",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Читает",
|
||||
"LabelNarrators": "Чтецы",
|
||||
"LabelNewestAuthors": "Новые авторы",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "Нет",
|
||||
"LabelNotFinished": "Не завершено",
|
||||
"LabelNotStarted": "Не запущено",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Дата публикации",
|
||||
"LabelPublishYear": "Год публикации",
|
||||
"LabelRead": "Читать",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Недавно добавленные",
|
||||
"LabelRecentSeries": "Недавние серии",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Пользовательский Email владельца",
|
||||
|
|
|
@ -104,6 +104,7 @@
|
|||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueReading": "Continue Reading",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelCustomTime": "Custom time",
|
||||
"LabelDescription": "Beskrivning",
|
||||
|
@ -165,6 +166,7 @@
|
|||
"LabelNarrator": "Berättare",
|
||||
"LabelNarrators": "Berättare",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestEpisodes": "Newest Episodes",
|
||||
"LabelNo": "No",
|
||||
"LabelNotFinished": "Ej avslutad",
|
||||
"LabelNotStarted": "Inte påbörjad",
|
||||
|
@ -182,6 +184,7 @@
|
|||
"LabelPubDate": "Publiceringsdatum",
|
||||
"LabelPublishYear": "Publiceringsår",
|
||||
"LabelRead": "Läst",
|
||||
"LabelReadAgain": "Read Again",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelRSSFeedCustomOwnerEmail": "Anpassad ägarens e-post",
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
"ButtonSendEbookToDevice": "将电子书发送到设备",
|
||||
"ButtonSeries": "系列",
|
||||
"ButtonSetTimer": "设置定时器",
|
||||
"ButtonStream": "流",
|
||||
"ButtonStream": "串流播放",
|
||||
"ButtonSubmit": "提交",
|
||||
"ButtonSwitchServerUser": "切换服务器/用户",
|
||||
"ButtonUserStats": "用户统计信息",
|
||||
|
@ -101,10 +101,11 @@
|
|||
"LabelClosePlayer": "关闭播放器",
|
||||
"LabelCollapseSeries": "折叠系列",
|
||||
"LabelComplete": "已完成",
|
||||
"LabelContinueBooks": "Continue Books",
|
||||
"LabelContinueEpisodes": "Continue Episodes",
|
||||
"LabelContinueListening": "Continue Listening",
|
||||
"LabelContinueSeries": "Continue Series",
|
||||
"LabelContinueBooks": "继续看书",
|
||||
"LabelContinueEpisodes": "继续剧集",
|
||||
"LabelContinueListening": "继续收听",
|
||||
"LabelContinueReading": "继续阅读",
|
||||
"LabelContinueSeries": "继续收听系列",
|
||||
"LabelCustomTime": "自定义时间",
|
||||
"LabelDescription": "描述",
|
||||
"LabelDisableAudioFadeOut": "禁用音频淡出",
|
||||
|
@ -114,7 +115,7 @@
|
|||
"LabelDisableShakeToResetHelp": "在计时器运行时摇晃设备,或者在计时器到期后2分钟内摇晃设备将重置睡眠计时器。启用此设置可禁用抖动以重置。",
|
||||
"LabelDisableVibrateOnReset": "重置时禁用振动",
|
||||
"LabelDisableVibrateOnResetHelp": "当睡眠计时器重置时,你的设备会振动。启用此设置以在睡眠计时器重置时不振动。",
|
||||
"LabelDiscover": "Discover",
|
||||
"LabelDiscover": "发现",
|
||||
"LabelDownload": "下载",
|
||||
"LabelDownloaded": "已下载",
|
||||
"LabelDuration": "持续时间",
|
||||
|
@ -151,7 +152,7 @@
|
|||
"LabelLanguage": "语言",
|
||||
"LabelLight": "轻",
|
||||
"LabelLineSpacing": "行间距",
|
||||
"LabelListenAgain": "Listen Again",
|
||||
"LabelListenAgain": "再次收听",
|
||||
"LabelLocalBooks": "本地书籍",
|
||||
"LabelLocalPodcasts": "本地播客",
|
||||
"LabelLockOrientation": "锁定方向",
|
||||
|
@ -164,7 +165,8 @@
|
|||
"LabelName": "名称",
|
||||
"LabelNarrator": "演播者",
|
||||
"LabelNarrators": "演播者",
|
||||
"LabelNewestAuthors": "Newest Authors",
|
||||
"LabelNewestAuthors": "最新作者",
|
||||
"LabelNewestEpisodes": "最新剧集",
|
||||
"LabelNo": "取消",
|
||||
"LabelNotFinished": "未听完",
|
||||
"LabelNotStarted": "未开始",
|
||||
|
@ -182,8 +184,9 @@
|
|||
"LabelPubDate": "出版日期",
|
||||
"LabelPublishYear": "发布年份",
|
||||
"LabelRead": "阅读",
|
||||
"LabelRecentlyAdded": "Recently Added",
|
||||
"LabelRecentSeries": "Recent Series",
|
||||
"LabelReadAgain": "再次阅读",
|
||||
"LabelRecentlyAdded": "最近添加",
|
||||
"LabelRecentSeries": "最近添加系列",
|
||||
"LabelRSSFeedCustomOwnerEmail": "自定义所有者电子邮件",
|
||||
"LabelRSSFeedCustomOwnerName": "自定义所有者名称",
|
||||
"LabelRSSFeedPreventIndexing": "防止索引",
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue