播放器主题色根据专辑封面图片提取

This commit is contained in:
灌糖包子 2023-01-28 03:30:55 +08:00
parent 2be35021b4
commit c0291d2404
Signed by: sookie
GPG Key ID: 691E688C160D3188
4 changed files with 177 additions and 48 deletions

View File

@ -1,10 +1,11 @@
<template>
<div
class="aplayer-pic"
:style="currentPicStyleObj"
:style="{backgroundColor: theme}"
@mousedown="onDragBegin"
@click="onClick"
>
<img v-if="pic" ref="thumbnailPic" :src="pic" @load="imgLoad" />
<div class="aplayer-button" :class="playing ? 'aplayer-pause' : 'aplayer-play'">
<icon-button
:icon="playing ? 'pause' : 'play'"
@ -14,12 +15,13 @@
</div>
</template>
<script lang="ts" setup>
import { computed, ref } from 'vue'
import { ref, watch } from 'vue'
import IconButton from './aplayer-iconbutton.vue'
import { themeColor } from '../utils'
const props = withDefaults(defineProps<{
pic?: string
theme?: string
theme: string
playing: boolean
enableDrag: boolean
}>(), {
@ -27,19 +29,12 @@ const props = withDefaults(defineProps<{
enableDrag: false
})
const emit = defineEmits(['dragbegin', 'dragging', 'dragend', 'toggleplay'])
const emit = defineEmits(['dragbegin', 'dragging', 'dragend', 'toggleplay', 'adaptingTheme'])
const hasMovedSinceMouseDown = ref(false)
const dragStartX = ref(0)
const dragStartY = ref(0)
const currentPicStyleObj = computed(() => {
if (!props.pic) return {}
return {
backgroundImage: `url(${props.pic})`,
backgroundColor: props.theme
}
})
const thumbnailPic = ref<unknown>(null)
const onDragBegin = (e: MouseEvent) => {
if (props.enableDrag) {
@ -65,6 +60,12 @@ const onClick = () => {
emit('toggleplay')
}
}
const imgLoad = () => {
emit('adaptingTheme', themeColor(<HTMLImageElement>thumbnailPic.value))
}
watch(() => props.pic, (pic?: string) => {
if (!pic) emit('adaptingTheme', props.theme)
}, { immediate: true })
</script>
<style lang="less" scoped>
@ -79,8 +80,6 @@ const onClick = () => {
position: relative;
height: @aplayer-height;
width: @aplayer-height;
background-image: url(../default.jpg);
background-size: cover;
transition: all 0.3s ease;
cursor: pointer;
&:hover {
@ -129,5 +128,9 @@ const onClick = () => {
width: 12px;
}
}
img {
width: 100%;
height: 100%;
}
}
</style>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@ -66,3 +66,157 @@ export function getElementViewTop (element: HTMLElement): number {
elementScrollTop = document.body.scrollTop + document.documentElement.scrollTop
return actualTop - elementScrollTop
}
interface Color {
r: number
g: number
b: number
}
class OctreeNode {
isLeaf: boolean = false
pixelCount: number = 0
red: number = 0
green: number = 0
blue: number = 0
children: OctreeNode[] = []
next: OctreeNode | null = null
}
let leafNum = 0,
reducible: {[propName: number]: OctreeNode | null} = {}
function createNode(level: number) {
const node = new OctreeNode()
if (level === 7) {
node.isLeaf = true
leafNum++
} else {
// 将其丢到第 level 层的 reducible 链表中
node.next = reducible[level]
reducible[level] = node
}
return node
}
function addColor(node: OctreeNode, color: Color, level: number) {
if (node.isLeaf) {
node.pixelCount += 1
node.red += color.r
node.green += color.g
node.blue += color.b
}
else {
let str = ''
let r = color.r.toString(2)
let g = color.g.toString(2)
let b = color.b.toString(2)
while (r.length < 8) r = '0' + r
while (g.length < 8) g = '0' + g
while (b.length < 8) b = '0' + b
str += r[level]
str += g[level]
str += b[level]
const index = parseInt(str, 2)
if (!node.children[index]) {
node.children[index] = createNode(level + 1)
}
if (!node.children[index]) {
console.log(index, level, color.r.toString(2))
}
addColor(node.children[index], color, level + 1)
}
}
function reduceTree() {
// 找到最深层次的并且有可合并节点的链表
let level = 6
while (!reducible[level]) {
level -= 1
}
// 取出链表头并将其从链表中移除
const node = reducible[level]
if (!node) return
reducible[level] = (node).next
// 合并子节点
let r = 0;
let g = 0;
let b = 0;
let count = 0;
for (let i = 0; i < 8; i++) {
if (!node.children[i]) continue
r += node.children[i].red
g += node.children[i].green
b += node.children[i].blue
count += node.children[i].pixelCount
leafNum--
}
// 赋值
node.isLeaf = true
node.red = r
node.green = g
node.blue = b
node.pixelCount = count
leafNum++
}
function buidOctree(root: OctreeNode, imageData: Uint8ClampedArray, maxColors: number) {
const total = imageData.length / 4
for (let i = 0; i < total; i++) {
// 添加颜色
addColor(root, {
r: imageData[i * 4],
g: imageData[i * 4 + 1],
b: imageData[i * 4 + 2]
}, 0)
// 合并叶子节点
while (leafNum > maxColors) reduceTree()
}
}
function colorsStats(node: OctreeNode, colorMap: {[propName: string]: number}) {
if (node.isLeaf) {
const r = Math.floor(node.red / node.pixelCount)
const g = Math.floor(node.green / node.pixelCount)
const b = Math.floor(node.blue / node.pixelCount)
const color = `${r},${g},${b}`
if (colorMap[color]) colorMap[color] += node.pixelCount
else colorMap[color] = node.pixelCount
return
}
for (let i = 0 ; i < 8 ; i++) {
if (node.children[i]) {
colorsStats(node.children[i], colorMap)
}
}
}
export const themeColor = function (img: HTMLImageElement): Color {
const canvas = document.createElement('canvas'),
ctx = <CanvasRenderingContext2D>canvas.getContext('2d'),
width = canvas.width = img.width,
height = canvas.height = img.height
ctx.drawImage(img, 0, 0, width, height)
const imageData = ctx.getImageData(0, 0, width, height).data
const colorMap: {[propName: string]: number} = {}
const root = new OctreeNode()
reducible = {}
leafNum = 0
buidOctree(root, imageData, 8)
colorsStats(root, colorMap)
const arr: string[] = []
for (let key in colorMap) {
arr.push(key)
}
arr.sort(function (a, b) {
return colorMap[a] - colorMap[b]
})
const rgb = arr[arr.length - 1].split(',')
return {r: parseInt(rgb[0]), g: parseInt(rgb[1]), b: parseInt(rgb[2])}
}

View File

@ -15,10 +15,11 @@
:pic="currentMusic.pic"
:playing="isPlaying"
:enable-drag="isFloatMode"
:theme="currentTheme"
:theme="currentMusic.theme || theme"
@toggleplay="toggle"
@dragbegin="onDragBegin"
@dragging="onDragAround"
@adaptingTheme="setSelfAdaptingTheme"
/>
<div class="aplayer-info" v-show="!mini">
<div class="aplayer-music">
@ -67,12 +68,6 @@
import Lyrics from './components/aplayer-lrc.vue'
import { warn } from './utils'
/**
* memorize self-adapting theme for cover image urls
* @type {Object.<url, rgb()>}
*/
const picThemeCache = {}
// mutex playing instance
let activeMutex = null
@ -226,7 +221,7 @@
// handle Promise returned from audio.play()
// @see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/play
audioPlayPromise: Promise.resolve(),
rejectPlayPromise: Promise.reject(),
// @since 1.2.0 float mode
@ -655,28 +650,9 @@
this.audio.src = this.currentMusic.src || this.currentMusic.url
}
},
setSelfAdaptingTheme () {
// auto theme according to current music cover image
if ((this.currentMusic.theme || this.theme) === 'pic') {
const pic = this.currentMusic.pic
// use cache
if (picThemeCache[pic]) {
this.selfAdaptingTheme = picThemeCache[pic]
} else {
try {
new ColorThief().getColorAsync(pic, ([r, g, b]) => {
picThemeCache[pic] = `rgb(${r}, ${g}, ${b})`
this.selfAdaptingTheme = `rgb(${r}, ${g}, ${b})`
})
} catch (e) {
warn('color-thief is required to support self-adapting theme')
}
}
} else {
this.selfAdaptingTheme = null
}
},
setSelfAdaptingTheme (color) {
this.selfAdaptingTheme = `rgb(${color.r},${color.g},${color.b})`
}
},
watch: {
music (music) {
@ -685,9 +661,6 @@
currentMusic: {
handler (music) {
// async
this.setSelfAdaptingTheme()
const src = music.src || music.url
// HLS support
if (/\.m3u8(?=(#|\?|$))/.test(src)) {
@ -753,7 +726,6 @@
},
mounted () {
this.initAudio()
this.setSelfAdaptingTheme()
if (this.autoplay) this.play()
},
beforeDestroy () {