Add:Year in review banner for user stats page #2373

This commit is contained in:
advplyr 2023-12-23 15:29:34 -06:00
parent 72fa6b8200
commit 0d644fe0c9
7 changed files with 583 additions and 99 deletions

View File

@ -1,24 +1,34 @@
<template> <template>
<div> <div>
<div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center"> <div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
variant: {
type: Number,
default: 0
},
year: Number,
processing: Boolean processing: Boolean
}, },
data() { data() {
return { return {
canvas: null,
dataUrl: null, dataUrl: null,
year: null,
yearStats: null yearStats: null
} }
}, },
watch: {
variant() {
this.init()
}
},
methods: { methods: {
async initCanvas() { async initCanvas() {
if (!this.yearStats) return if (!this.yearStats) return
@ -72,7 +82,12 @@ export default {
ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.fillRect(0, 0, canvas.width, canvas.height)
// Cover image tiles // Cover image tiles
if (this.yearStats.booksWithCovers.length) { const bookCovers = this.yearStats.finishedBooksWithCovers
bookCovers.push(...this.yearStats.booksWithCovers)
let finishedBookCoverImgs = {}
if (bookCovers.length) {
let index = 0 let index = 0
ctx.globalAlpha = 0.25 ctx.globalAlpha = 0.25
ctx.save() ctx.save()
@ -82,8 +97,8 @@ export default {
ctx.translate(-130, -120) ctx.translate(-130, -120)
for (let x = 0; x < 5; x++) { for (let x = 0; x < 5; x++) {
for (let y = 0; y < 5; y++) { for (let y = 0; y < 5; y++) {
const coverIndex = index % this.yearStats.booksWithCovers.length const coverIndex = index % bookCovers.length
let libraryItemId = this.yearStats.booksWithCovers[coverIndex] let libraryItemId = bookCovers[coverIndex]
index++ index++
await new Promise((resolve) => { await new Promise((resolve) => {
@ -98,6 +113,14 @@ export default {
let sy = -(sw - img.height) / 2 let sy = -(sw - img.height) / 2
ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215) ctx.drawImage(img, sx, sy, sw, sw, 215 * x, 215 * y, 215, 215)
resolve() resolve()
if (this.yearStats.finishedBooksWithCovers.includes(libraryItemId) && !finishedBookCoverImgs[libraryItemId]) {
finishedBookCoverImgs[libraryItemId] = {
img,
sx,
sy,
sw
}
}
}) })
img.addEventListener('error', () => { img.addEventListener('error', () => {
resolve() resolve()
@ -141,7 +164,7 @@ export default {
// Box top right // Box top right
createRoundedRect(410, 100, 340, 160) createRoundedRect(410, 100, 340, 160)
addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165) addText(this.$elapsedPrettyExtended(this.yearStats.totalListeningTime, true, false), '40px', 'bold', 'white', '0px', 500, 165)
addText('spent listening', '28px', 'normal', tanColor, '0.5px', 500, 205) addText('spent listening', '28px', 'normal', tanColor, '0px', 500, 205)
addIcon('watch_later', 'white', '52px', 440, 180) addIcon('watch_later', 'white', '52px', 440, 180)
// Box bottom left // Box bottom left
@ -153,9 +176,10 @@ export default {
// Box bottom right // Box bottom right
createRoundedRect(410, 280, 340, 160) createRoundedRect(410, 280, 340, 160)
addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345) addText(this.yearStats.numBooksListened, '64px', 'bold', 'white', '0px', 500, 345)
addText('books listened to', '28px', 'normal', tanColor, '0.5px', 500, 390) addText('books listened to', '28px', 'normal', tanColor, '0px', 500, 390)
addIcon('local_library', 'white', '52px', 440, 360) addIcon('local_library', 'white', '52px', 440, 360)
if (!this.variant) {
// Text stats // Text stats
const topNarrator = this.yearStats.mostListenedNarrator const topNarrator = this.yearStats.mostListenedNarrator
if (topNarrator) { if (topNarrator) {
@ -178,17 +202,72 @@ export default {
addText(this.$elapsedPrettyExtended(topAuthor.time, true, false), '24px', 'lighter', 'white', '1px', 70, 749) 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') this.dataUrl = canvas.toDataURL('png')
}, },
refresh() { refresh() {
this.init() this.init()
}, },
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreview.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
this.$toast.error('Failed to share: ' + error.message)
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
async init() { async init() {
this.$emit('update:processing', true) this.$emit('update:processing', true)
let year = new Date().getFullYear() this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
if (new Date().getMonth() < 11) year--
this.year = year
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${year}`).catch((err) => {
console.error('Failed to load stats for year', err) console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats') this.$toast.error('Failed to load year stats')
return null return null

View File

@ -0,0 +1,134 @@
<template>
<div class="bg-bg rounded-md shadow-lg border border-white border-opacity-5 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 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 v-if="showShareButton" 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="text-lg font-semibold">Your Year <span class="hidden md:inline-block">in Review&nbsp;</span>({{ 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">
<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-outlined text-lg px-1 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 v-if="showShareButton" 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="text-lg font-semibold">Server <span class="hidden md:inline-block">Year in Review&nbsp;</span>({{ 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 px-1 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,
showShareButton: false
}
},
computed: {
isAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
}
},
methods: {
shareYearInReviewServer() {
this.$refs.yearInReviewServer.share()
},
shareYearInReview() {
this.$refs.yearInReview.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() {
if (typeof navigator.share !== 'undefined' && navigator.share) {
this.showShareButton = true
} else {
console.warn('Navigator.share not supported')
}
}
}
</script>

View File

@ -1,24 +1,34 @@
<template> <template>
<div> <div>
<div v-if="processing" class="w-[400px] h-[400px] flex items-center justify-center"> <div v-if="processing" class="max-w-[800px] h-80 md:h-[800px] mx-auto flex items-center justify-center">
<widgets-loading-spinner /> <widgets-loading-spinner />
</div> </div>
<img v-else-if="dataUrl" :src="dataUrl" /> <img v-else-if="dataUrl" :src="dataUrl" class="mx-auto" />
</div> </div>
</template> </template>
<script> <script>
export default { export default {
props: { props: {
processing: Boolean variant: {
type: Number,
default: 0
},
processing: Boolean,
year: Number
}, },
data() { data() {
return { return {
canvas: null,
dataUrl: null, dataUrl: null,
year: null,
yearStats: null yearStats: null
} }
}, },
watch: {
variant() {
this.init()
}
},
methods: { methods: {
async initCanvas() { async initCanvas() {
if (!this.yearStats) return if (!this.yearStats) return
@ -61,12 +71,6 @@ export default {
ctx.fillText(text, x, y) 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 // Bg color
ctx.fillStyle = '#232323' ctx.fillStyle = '#232323'
ctx.fillRect(0, 0, canvas.width, canvas.height) ctx.fillRect(0, 0, canvas.width, canvas.height)
@ -168,28 +172,81 @@ export default {
addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470) addText('+' + this.$elapsedPrettyExtended(this.yearStats.totalBooksAddedDuration, true, false), '20px', 'lighter', 'white', '0px', canvas.width / 2, 470)
} }
if (!this.variant) {
// Bottom images // Bottom images
imgsToAdd = Object.values(imgsToAdd) imgsToAdd = Object.values(imgsToAdd)
if (imgsToAdd.length >= 5) { if (imgsToAdd.length > 0) {
addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540) addText('Some additions include...', '24px', 'normal', tanColor, '0px', canvas.width / 2, 540)
for (let i = 0; i < 5; i++) { for (let i = 0; i < Math.min(5, imgsToAdd.length); i++) {
let imgToAdd = imgsToAdd[i] let imgToAdd = imgsToAdd[i]
ctx.drawImage(imgToAdd.img, imgToAdd.sx, imgToAdd.sy, imgToAdd.sw, imgToAdd.sw, 40 + 145 * i, 580, 140, 140) 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') this.dataUrl = canvas.toDataURL('png')
}, },
share() {
this.canvas.toBlob((blob) => {
const file = new File([blob], 'yearinreviewserver.png', { type: blob.type })
const shareData = {
files: [file]
}
if (navigator.canShare(shareData)) {
navigator
.share(shareData)
.then(() => {
console.log('Share success')
})
.catch((error) => {
console.error('Failed to share', error)
this.$toast.error('Failed to share: ' + error.message)
})
} else {
this.$toast.error('Cannot share natively on this device')
}
})
},
refresh() { refresh() {
this.init() this.init()
}, },
async init() { async init() {
this.$emit('update:processing', true) this.$emit('update:processing', true)
let year = new Date().getFullYear() this.yearStats = await this.$axios.$get(`/api/stats/year/${this.year}`).catch((err) => {
if (new Date().getMonth() < 11) year--
this.year = year
this.yearStats = await this.$axios.$get(`/api/stats/year/${year}`).catch((err) => {
console.error('Failed to load stats for year', err) console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats') this.$toast.error('Failed to load year stats')
return null return null

View File

@ -0,0 +1,169 @@
<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>
export default {
props: {
processing: Boolean,
year: Number
},
data() {
return {
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.dataUrl = canvas.toDataURL('png')
},
refresh() {
this.init()
},
async init() {
this.$emit('update:processing', true)
this.yearStats = await this.$axios.$get(`/api/me/stats/year/${this.year}`).catch((err) => {
console.error('Failed to load stats for year', err)
this.$toast.error('Failed to load year stats')
return null
})
await this.initCanvas()
this.$emit('update:processing', false)
}
},
mounted() {
this.init()
}
}
</script>

View File

@ -1,6 +1,9 @@
<template> <template>
<div> <div>
<app-settings-content :header-text="$strings.HeaderYourStats"> <!-- Year in review banner shown at the top in December and January -->
<stats-year-in-review-banner v-if="showYearInReviewBanner" />
<app-settings-content :header-text="$strings.HeaderYourStats" class="!mb-4">
<div class="flex justify-center"> <div class="flex justify-center">
<div class="flex p-2"> <div class="flex p-2">
<svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24"> <svg class="hidden sm:block h-14 w-14 lg:h-18 lg:w-18" viewBox="0 0 24 24">
@ -62,16 +65,10 @@
</div> </div>
</div> </div>
<stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" /> <stats-heatmap v-if="listeningStats" :days-listening="listeningStats.days" class="my-2" />
<ui-btn small :loading="processingYearInReview || processingYearInReviewAlt" @click.stop="clickShowYearInReview">{{ showYearInReview ? 'Refresh Year in Review' : 'Year in Review' }}</ui-btn>
<div v-if="showYearInReview">
<div class="w-full h-px bg-slate-200/10 my-4" />
<stats-year-in-review ref="yearInReview" :processing.sync="processingYearInReview" />
<stats-year-in-review-server v-if="isAdminOrUp" ref="yearInReviewAlt" :processing.sync="processingYearInReviewAlt" />
</div>
</app-settings-content> </app-settings-content>
<!-- Year in review banner shown at the bottom Feb - Nov -->
<stats-year-in-review-banner v-if="!showYearInReviewBanner" />
</div> </div>
</template> </template>
@ -81,9 +78,7 @@ export default {
return { return {
listeningStats: null, listeningStats: null,
windowWidth: 0, windowWidth: 0,
showYearInReview: false, showYearInReviewBanner: false
processingYearInReview: false,
processingYearInReviewAlt: false
} }
}, },
watch: { watch: {
@ -126,22 +121,17 @@ export default {
} }
}, },
methods: { methods: {
clickShowYearInReview() {
if (this.showYearInReview) {
this.$refs.yearInReview.refresh()
if (this.$refs.yearInReviewAlt) {
this.$refs.yearInReviewAlt.refresh()
}
} else {
this.showYearInReview = true
}
},
async init() { async init() {
this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => { this.listeningStats = await this.$axios.$get(`/api/me/listening-stats`).catch((err) => {
console.error('Failed to load listening sesions', err) console.error('Failed to load listening sesions', err)
return [] return []
}) })
let month = new Date().getMonth()
// January and December show year in review banner
if (month === 11 || month === 0) {
this.showYearInReviewBanner = true
}
} }
}, },
mounted() { mounted() {

View File

@ -88,12 +88,53 @@ module.exports = {
const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year) const numAuthorsAdded = await this.getNumAuthorsAddedForYear(year)
let authorListeningMap = {}
let narratorListeningMap = {}
let genreListeningMap = {}
const listeningSessions = await this.getListeningSessionsForYear(year) const listeningSessions = await this.getListeningSessionsForYear(year)
let totalListeningTime = 0 let totalListeningTime = 0
for (const listeningSession of listeningSessions) { for (const ls of listeningSessions) {
totalListeningTime += (listeningSession.timeListening || 0) totalListeningTime += (ls.timeListening || 0)
const authors = ls.mediaMetadata.authors || []
authors.forEach((au) => {
if (!authorListeningMap[au.name]) authorListeningMap[au.name] = 0
authorListeningMap[au.name] += (ls.timeListening || 0)
})
const narrators = ls.mediaMetadata.narrators || []
narrators.forEach((narrator) => {
if (!narratorListeningMap[narrator]) narratorListeningMap[narrator] = 0
narratorListeningMap[narrator] += (ls.timeListening || 0)
})
// Filter out bad genres like "audiobook" and "audio book"
const genres = (ls.mediaMetadata.genres || []).filter(g => !g.toLowerCase().includes('audiobook') && !g.toLowerCase().includes('audio book'))
genres.forEach((genre) => {
if (!genreListeningMap[genre]) genreListeningMap[genre] = 0
genreListeningMap[genre] += (ls.timeListening || 0)
})
} }
let topAuthors = null
topAuthors = Object.keys(authorListeningMap).map(authorName => ({
name: authorName,
time: Math.round(authorListeningMap[authorName])
})).sort((a, b) => b.time - a.time).slice(0, 3)
let topNarrators = null
topNarrators = Object.keys(narratorListeningMap).map(narratorName => ({
name: narratorName,
time: Math.round(narratorListeningMap[narratorName])
})).sort((a, b) => b.time - a.time).slice(0, 3)
let topGenres = null
topGenres = Object.keys(genreListeningMap).map(genre => ({
genre,
time: Math.round(genreListeningMap[genre])
})).sort((a, b) => b.time - a.time).slice(0, 3)
// Stats for total books, size and duration for everything added this year or earlier // Stats for total books, size and duration for everything added this year or earlier
const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, { const [totalStatResultsRow] = await Database.sequelize.query(`SELECT SUM(li.size) AS totalSize, SUM(b.duration) AS totalDuration, COUNT(*) AS totalItems FROM libraryItems li, books b WHERE b.id = li.mediaId AND li.mediaType = 'book' AND li.createdAt < ":nextYear-01-01";`, {
replacements: { replacements: {
@ -112,7 +153,10 @@ module.exports = {
totalBooksSize: totalStatResults?.totalSize || 0, totalBooksSize: totalStatResults?.totalSize || 0,
totalBooksDuration: totalStatResults?.totalDuration || 0, totalBooksDuration: totalStatResults?.totalDuration || 0,
totalListeningTime, totalListeningTime,
numBooks: totalStatResults?.totalItems || 0 numBooks: totalStatResults?.totalItems || 0,
topAuthors,
topNarrators,
topGenres
} }
} }
} }

View File

@ -52,12 +52,14 @@ module.exports = {
}, },
include: { include: {
model: Database.bookModel, model: Database.bookModel,
attributes: ['id', 'title', 'coverPath'],
include: { include: {
model: Database.libraryItemModel, model: Database.libraryItemModel,
attributes: ['id', 'mediaId', 'mediaType'] attributes: ['id', 'mediaId', 'mediaType']
}, },
required: true required: true
} },
order: Database.sequelize.random()
}) })
return progresses return progresses
}, },
@ -69,6 +71,7 @@ module.exports = {
async getStatsForYear(user, year) { async getStatsForYear(user, year) {
const userId = user.id const userId = user.id
const listeningSessions = await this.getUserListeningSessionsForYear(userId, year) const listeningSessions = await this.getUserListeningSessionsForYear(userId, year)
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
let totalBookListeningTime = 0 let totalBookListeningTime = 0
let totalPodcastListeningTime = 0 let totalPodcastListeningTime = 0
@ -79,11 +82,33 @@ module.exports = {
let narratorListeningMap = {} let narratorListeningMap = {}
let monthListeningMap = {} let monthListeningMap = {}
let bookListeningMap = {} let bookListeningMap = {}
const booksWithCovers = []
const booksWithCovers = []
const finishedBooksWithCovers = []
// Get finished book stats
const numBooksFinished = bookProgressesFinished.length
let longestAudiobookFinished = null
for (const mediaProgress of bookProgressesFinished) {
// Grab first 5 that have a cover
if (mediaProgress.mediaItem?.coverPath && !finishedBooksWithCovers.includes(mediaProgress.mediaItem.libraryItem.id) && finishedBooksWithCovers.length < 5 && await fsExtra.pathExists(mediaProgress.mediaItem.coverPath)) {
finishedBooksWithCovers.push(mediaProgress.mediaItem.libraryItem.id)
}
if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {
longestAudiobookFinished = {
id: mediaProgress.mediaItem.id,
title: mediaProgress.mediaItem.title,
duration: Math.round(mediaProgress.duration),
finishedAt: mediaProgress.finishedAt
}
}
}
// Get listening session stats
for (const ls of listeningSessions) { for (const ls of listeningSessions) {
// Grab first 25 that have a cover // Grab first 25 that have a cover
if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) { if (ls.mediaItem?.coverPath && !booksWithCovers.includes(ls.mediaItem.libraryItem.id) && !finishedBooksWithCovers.includes(ls.mediaItem.libraryItem.id) && booksWithCovers.length < 25 && await fsExtra.pathExists(ls.mediaItem.coverPath)) {
booksWithCovers.push(ls.mediaItem.libraryItem.id) booksWithCovers.push(ls.mediaItem.libraryItem.id)
} }
@ -162,21 +187,6 @@ module.exports = {
} }
} }
const bookProgressesFinished = await this.getBookMediaProgressFinishedForYear(userId, year)
const numBooksFinished = bookProgressesFinished.length
let longestAudiobookFinished = null
bookProgressesFinished.forEach((mediaProgress) => {
if (mediaProgress.duration && (!longestAudiobookFinished?.duration || mediaProgress.duration > longestAudiobookFinished.duration)) {
longestAudiobookFinished = {
id: mediaProgress.mediaItem.id,
title: mediaProgress.mediaItem.title,
duration: Math.round(mediaProgress.duration),
finishedAt: mediaProgress.finishedAt
}
}
})
return { return {
totalListeningSessions: listeningSessions.length, totalListeningSessions: listeningSessions.length,
totalListeningTime, totalListeningTime,
@ -189,7 +199,8 @@ module.exports = {
numBooksFinished, numBooksFinished,
numBooksListened: Object.keys(bookListeningMap).length, numBooksListened: Object.keys(bookListeningMap).length,
longestAudiobookFinished, longestAudiobookFinished,
booksWithCovers booksWithCovers,
finishedBooksWithCovers
} }
} }
} }