播放器主题色根据专辑封面图片提取
This commit is contained in:
parent
2be35021b4
commit
c0291d2404
@ -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 |
@ -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])}
|
||||
}
|
||||
|
||||
@ -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 () {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user