This commit is contained in:
lyswhut
2022-10-29 11:36:35 +08:00
parent 66b965d9f7
commit e45cee6596
715 changed files with 57096 additions and 31771 deletions
@@ -0,0 +1,118 @@
<template>
<material-modal :show="show" teleport="#view" @close="handleClose" @after-enter="$refs.input.focus()">
<main :class="$style.main">
<h2>{{ selectedNum > 0 ? $t('music_sort__title_multiple', { num: selectedNum }) : $t('music_sort__title', { name: musicInfo ? musicInfo.name : '' }) }}</h2>
<base-input
ref="input"
v-model="sortNum"
:class="$style.input"
type="number"
:placeholder="$t('music_sort__input_tip')"
@submit="handleSubmit" @blur="verify"
/>
<div :class="$style.footer">
<base-btn :class="$style.btn" @click="handleSubmit">{{ $t('btn_confirm') }}</base-btn>
</div>
</main>
</material-modal>
</template>
<script>
export default {
props: {
show: {
type: Boolean,
default: false,
},
musicInfo: {
type: Object,
default() {
return {}
},
},
selectedNum: {
type: Number,
default: 0,
},
},
emits: ['update:show', 'confirm'],
data() {
return {
sortNum: '',
}
},
watch: {
show(n) {
if (n) {
this.sortNum = ''
}
},
},
methods: {
handleClose() {
this.$emit('update:show', false)
},
verify() {
let num = /^[1-9]\d*/.exec(this.sortNum)
num = num ? parseInt(num[0]) : ''
this.sortNum = num.toString()
return num
},
handleSubmit() {
let num = this.verify()
if (this.sortNum == '') return
this.handleClose()
this.$emit('confirm', num)
},
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.main {
padding: 0 15px;
max-width: 530px;
min-width: 280px;
display: flex;
flex-flow: column nowrap;
min-height: 0;
// max-height: 100%;
// overflow: hidden;
h2 {
font-size: 13px;
color: var(--color-font);
line-height: 1.3;
word-break: break-all;
// text-align: center;
padding: 15px 0 8px;
}
}
.input {
// width: 100%;
// height: 26px;
padding: 8px 8px;
}
.footer {
margin: 20px 0 15px auto;
}
.btn {
// box-sizing: border-box;
// margin-left: 15px;
// margin-bottom: 15px;
// height: 36px;
// line-height: 36px;
// padding: 0 10px !important;
min-width: 70px;
// .mixin-ellipsis-1;
+.btn {
margin-left: 10px;
}
}
</style>
@@ -0,0 +1,348 @@
<template>
<teleport to="#view">
<div v-show="isShow" ref="dom_container" :class="$style.container">
<transition enter-active-class="animated-fast zoomIn" leave-active-class="animated zoomOut" @after-leave="handleAnimated">
<div v-show="visible" :class="$style.search">
<div :class="$style.form">
<input
ref="dom_input" v-model.trim="text" class="ignore-esc" :placeholder="placeholder" @input="handleDelaySearch"
@keyup.enter="handleTemplistClick(selectIndex)" @keyup.arrow-down.prevent.exact="handleKeyDown" @keyup.arrow-up.prevent.exact="handleKeyUp"
@keyup.escape.prevent.exact="handleKeyEsc" @keydown.control.prevent="handle_key_mod_down" @keydown.meta.prevent="handle_key_mod_down"
@keyup.control.prevent="handle_key_mod_up" @keyup.meta.prevent="handle_key_mod_up" @contextmenu="handleContextMenu"
>
<button type="button" @click="handleHide">
<slot>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" height="100%" viewBox="0 0 212.982 212.982" space="preserve">
<use xlink:href="#icon-delete" />
</svg>
</slot>
</button>
</div>
<div v-if="resultList" ref="dom_scrollContainer" class="scroll" :class="$style.list" :style="listStyle">
<ul ref="dom_list">
<li v-for="(item, index) in resultList" :key="item.songmid" :class="selectIndex === index ? $style.select : null" @mouseenter="selectIndex = index" @click="handleTemplistClick(index)">
<div :class="$style.img" />
<div :class="$style.text">
<h3 :class="$style.text">{{ item.name }} - {{ item.singer }}</h3>
<h3 v-if="item.meta.albumName" :class="[$style.text, $style.albumName]">{{ item.meta.albumName }}</h3>
</div>
<div :class="$style.source">{{ item.source }}</div>
</li>
</ul>
</div>
</div>
</transition>
</div>
</teleport>
</template>
<script>
import { debounce } from '@common/utils'
import { clipboardReadText } from '@common/utils/electron'
import { toRaw } from '@common/utils/vueTools'
export default {
props: {
placeholder: {
type: String,
default: 'Find for something...',
},
list: {
type: Array,
default() {
return []
},
},
visible: {
type: Boolean,
default: false,
},
},
emits: ['action'],
data() {
return {
text: '',
selectIndex: -1,
listStyle: {
height: 0,
maxHeight: 0,
overflow: 'hidden',
},
maxHeight: 0,
resultList: [],
isModDown: false,
isShow: false,
}
},
watch: {
resultList(n) {
if (this.selectIndex > -1) this.selectIndex = -1
this.$nextTick(() => {
const height = this.$refs.dom_list.scrollHeight
if (height > this.maxHeight) {
this.listStyle.height = this.maxHeight + 'px'
this.listStyle.overflow = 'auto'
} else {
this.listStyle.height = height + 'px'
this.listStyle.overflow = 'hidden'
}
})
},
list(n) {
if (!this.visible) return
this.handleDelaySearch()
},
visible(n) {
if (!n) return
this.isShow = true
this.init()
},
},
created() {
this.handleDelaySearch = debounce(() => {
this.handleSearch()
})
if (this.visible) this.isShow = true
},
mounted() {
this.init()
// window.key_event.on('key_mod_down', this.handle_key_mod_down)
// window.key_event.on('key_mod_up', this.handle_key_mod_up)
window.key_event.on('key_mod+f_down', this.handle_key_mod_f_down)
},
beforeUnmount() {
// window.key_event.off('key_mod_down', this.handle_key_mod_down)
// window.key_event.off('key_mod_up', this.handle_key_mod_up)
window.key_event.off('key_mod+f_down', this.handle_key_mod_f_down)
},
methods: {
init() {
if (!this.visible) return
this.handleSearch()
this.$nextTick(() => {
if (!this.listStyle.maxHeight) {
this.maxHeight = this.$refs.dom_container.offsetParent.clientHeight - this.$refs.dom_list.offsetTop - 70
this.listStyle.maxHeight = this.maxHeight + 'px'
}
this.$refs.dom_input.focus()
})
},
handleKeyEsc() {
if (this.text.length > 0) {
this.text = ''
this.resultList = []
} else {
this.handleHide()
}
},
handle_key_mod_down() {
console.log('handle_key_mod_down')
if (!this.isModDown) this.isModDown = true
},
handle_key_mod_up() {
if (this.isModDown) this.isModDown = false
},
handle_key_mod_f_down() {
if (this.visible) this.$refs.dom_input.focus()
},
handleAnimated() {
if (this.visible) return
this.isShow = false
},
handleTemplistClick(index) {
if (index < 0) return
const id = this.resultList[index].id
this.sendEvent('listClick', {
index: this.list.findIndex(m => m.id == id),
isPlay: this.isModDown,
})
},
handleHide() {
this.sendEvent('hide')
},
sendEvent(action, data) {
this.$emit('action', {
action,
data,
})
},
handleKeyDown() {
if (!this.resultList.length) return
this.selectIndex = this.selectIndex + 1 < this.resultList.length ? this.selectIndex + 1 : 0
this.handleScrollList()
},
handleKeyUp() {
if (!this.resultList.length) return
this.selectIndex = this.selectIndex - 1 < -1 ? this.resultList.length - 1 : this.selectIndex - 1
this.handleScrollList()
},
handleScrollList() {
if (this.selectIndex < 0) return
let dom = this.$refs.dom_list.children[this.selectIndex]
let offsetTop = dom.offsetTop
let scrollTop = this.$refs.dom_scrollContainer.scrollTop
let top
if (offsetTop < scrollTop) {
top = offsetTop
} else if (offsetTop + dom.clientHeight > this.$refs.dom_scrollContainer.clientHeight + scrollTop) {
top = offsetTop + dom.clientHeight - this.$refs.dom_scrollContainer.clientHeight
} else return
this.$refs.dom_scrollContainer.scrollTo(0, top)
},
handleContextMenu() {
let str = clipboardReadText()
str = str.replace(/\t|\r\n|\n|\r/g, ' ')
str = str.replace(/\s+/g, ' ')
let dom_input = this.$refs.dom_input
this.text = `${this.text.substring(0, dom_input.selectionStart)}${str}${this.text.substring(dom_input.selectionEnd, this.text.length)}`
},
async handleSearch() {
if (!this.text.length) return this.resultList = []
this.resultList = await window.lx.worker.main.searchListMusic(toRaw(this.list), this.text)
},
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.container {
position: absolute;
left: 50%;
transform: translateX(-50%);
top: 20px;
width: 45%;
height: @height-toolbar * 0.52;
z-index: 99;
}
.search {
position: absolute;
width: 100%;
border-radius: 4px;
transition: box-shadow .4s ease, background-color @transition-normal;
display: flex;
flex-flow: column nowrap;
background-color: var(--color-primary-light-600-alpha-100);
box-shadow: 0 1px 2px rgba(0,0,0,0.07),
0 2px 4px rgba(0,0,0,0.07),
0 4px 8px rgba(0,0,0,0.07),
0 8px 16px rgba(0,0,0,0.07),
0 16px 32px rgba(0,0,0,0.07),
0 32px 64px rgba(0,0,0,0.07);
&.active {
.form {
input {
border-bottom-left-radius: 0;
}
button {
border-bottom-right-radius: 0;
}
}
}
.form {
display: flex;
height: @height-toolbar * 0.52;
position: relative;
input {
flex: auto;
// border: 1px solid;
border-top-left-radius: 4px;
border-bottom-left-radius: 4px;
background-color: transparent;
// border-bottom: 2px solid var(--color-primary);
// border-color: var(--color-primary);
border: none;
outline: none;
// height: @height-toolbar * .7;
padding: 0 5px;
overflow: hidden;
font-size: 13.5px;
line-height: @height-toolbar * 0.52 + 5px;
&::placeholder {
color: var(--color-button-font);
}
}
button {
flex: none;
border: none;
// background-color: @color-search-form-background;
background-color: transparent;
outline: none;
border-top-right-radius: 4px;
border-bottom-right-radius: 4px;
cursor: pointer;
height: 100%;
padding: 6px 7px;
color: var(--color-button-font);
transition: background-color .2s ease;
opacity: 0.8;
&:hover {
background-color: var(--color-button-background-hover);
}
&:active {
background-color: var(--color-button-background-active);
}
}
}
.list {
// background-color: @color-search-form-background;
font-size: 13px;
transition: .3s ease;
height: 0;
transition-property: height;
position: relative;
scroll-behavior: smooth;
li {
position: relative;
cursor: pointer;
padding: 8px 5px;
transition: background-color .2s ease;
line-height: 1.3;
// overflow: hidden;
display: flex;
flex-flow: row nowrap;
&.select {
background-color: var(--color-primary-dark-100-alpha-700);
}
border-radius: 4px;
// &:last-child {
// border-bottom-left-radius: 4px;
// border-bottom-right-radius: 4px;
// }
}
}
}
.img {
flex: none;
}
.text {
flex: auto;
.mixin-ellipsis-1;
}
.albumName {
font-size: 12px;
opacity: 0.6;
.mixin-ellipsis-1;
}
.source {
flex: none;
font-size: 12px;
opacity: 0.5;
padding: 0 5px;
display: flex;
align-items: center;
// transform: rotate(45deg);
// background-color:
}
</style>
+375
View File
@@ -0,0 +1,375 @@
<template>
<div :class="$style.list">
<div class="thead">
<table>
<thead>
<tr v-if="actionButtonsVisible">
<th class="num" style="width: 5%;">#</th>
<th class="nobreak">{{ $t('music_name') }}</th>
<th class="nobreak" style="width: 22%;">{{ $t('music_singer') }}</th>
<th class="nobreak" style="width: 22%;">{{ $t('music_album') }}</th>
<th class="nobreak" style="width: 9%;">{{ $t('music_time') }}</th>
<th class="nobreak" style="width: 16%;">{{ $t('action') }}</th>
</tr>
<tr v-else>
<th class="num" style="width: 5%;">#</th>
<th class="nobreak">{{ $t('music_name') }}</th>
<th class="nobreak" style="width: 25%;">{{ $t('music_singer') }}</th>
<th class="nobreak" style="width: 28%;">{{ $t('music_album') }}</th>
<th class="nobreak" style="width: 10%;">{{ $t('music_time') }}</th>
</tr>
</thead>
</table>
</div>
<div v-show="list.length" ref="dom_listContent" :class="$style.content">
<base-virtualized-list
v-if="actionButtonsVisible" ref="listRef" v-slot="{ item, index }" :list="list" key-name="id"
:item-height="listItemHeight" container-class="scroll" content-class="list"
@scroll="saveListPosition" @contextmenu.capture="handleListRightClick"
>
<div
class="list-item" :class="[{ [$style.active]: playerInfo.isPlayList && playerInfo.playIndex === index }, { selected: selectedIndex == index || rightClickSelectedIndex == index }, { active: selectedList.includes(item) }, { disabled: !assertApiSupport(item.source) }]"
@click="handleListItemClick($event, index)" @contextmenu="handleListItemRightClick($event, index)"
>
<div class="list-item-cell no-select num" style="flex: 0 0 5%;" @click.stop>{{ index + 1 }}</div>
<div class="list-item-cell auto name" :aria-label="item.name">
<span class="select name">{{ item.name }}</span>
<span v-if="isShowSource" class="no-select label-source">{{ item.source }}</span>
</div>
<div class="list-item-cell" style="flex: 0 0 22%;"><span class="select" :aria-label="item.singer">{{ item.singer }}</span></div>
<div class="list-item-cell" style="flex: 0 0 22%;"><span class="select" :aria-label="item.meta.albumName">{{ item.meta.albumName }}</span></div>
<div class="list-item-cell" style="flex: 0 0 9%;"><span class="no-select">{{ item.interval || '--/--' }}</span></div>
<div class="list-item-cell" style="flex: 0 0 16%; padding-left: 0; padding-right: 0;">
<material-list-buttons :index="index" :download-btn="assertApiSupport(item.source) && item.source != 'local'" @btn-click="handleListBtnClick" />
</div>
</div>
</base-virtualized-list>
<base-virtualized-list
v-else ref="listRef" v-slot="{ item, index }" :list="list" key-name="id"
:item-height="listItemHeight" container-class="scroll" content-class="list"
@scroll="saveListPosition" @contextmenu.capture="handleListRightClick"
>
<div
class="list-item"
:class="[{ [$style.active]: playerInfo.isPlayList && playerInfo.playIndex === index }, { selected: selectedIndex == index || rightClickSelectedIndex == index }, { active: selectedList.includes(item) }, { disabled: !assertApiSupport(item.source) }]"
@click="handleListItemClick($event, index)" @contextmenu="handleListItemRightClick($event, index)"
>
<div class="list-item-cell no-select num" style="flex: 0 0 5%;" @click.stop>{{ index + 1 }}</div>
<div class="list-item-cell auto name">
<span class="select name" :aria-label="item.name">{{ item.name }}</span>
<span v-if="isShowSource" class="no-select label-source">{{ item.source }}</span>
</div>
<div class="list-item-cell" style="flex: 0 0 25%;"><span class="select" :aria-label="item.singer">{{ item.singer }}</span></div>
<div class="list-item-cell" style="flex: 0 0 28%;"><span class="select" :aria-label="item.meta.albumName">{{ item.meta.albumName }}</span></div>
<div class="list-item-cell" style="flex: 0 0 10%;"><span class="no-select">{{ item.interval || '--/--' }}</span></div>
</div>
</base-virtualized-list>
</div>
<div v-show="!list.length" :class="$style.noItem">
<p v-text="$t('no_item')" />
</div>
<common-list-add-modal
v-model:show="isShowListAdd" :is-move="isMove" :from-list-id="listId"
:music-info="selectedAddMusicInfo" :exclude-list-id="excludeListIds" teleport="#view"
/>
<common-list-add-multiple-modal
v-model:show="isShowListAddMultiple" :from-list-id="listId"
:is-move="isMoveMultiple" :music-list="selectedList" :exclude-list-id="excludeListIds" teleport="#view" @confirm="removeAllSelect"
/>
<common-download-modal v-model:show="isShowDownload" :music-info="selectedDownloadMusicInfo" teleport="#view" />
<common-download-multiple-modal v-model:show="isShowDownloadMultiple" :list="selectedList" teleport="#view" @confirm="removeAllSelect" />
<search-list :list="list" :visible="isShowSearchBar" @action="handleMusicSearchAction" />
<music-sort-modal v-model:show="isShowMusicSortModal" :music-info="selectedSortMusicInfo" :selected-num="selectedNum" @confirm="sortMusic" />
<base-menu v-model="isShowItemMenu" :menus="menus" :xy="menuLocation" item-name="name" @menu-click="handleMenuClick" />
</div>
</template>
<script>
import { clipboardWriteText } from '@common/utils/electron'
import { assertApiSupport } from '@renderer/store/utils'
import SearchList from './components/SearchList'
import MusicSortModal from './components/MusicSortModal'
import useListInfo from './useListInfo'
import useList from './useList'
import useMenu from './useMenu'
import usePlay from './usePlay'
import useMusicDownload from './useMusicDownload'
import useMusicAdd from './useMusicAdd'
import useSort from './useSort'
import useMusicActions from './useMusicActions'
import useSearch from './useSearch'
import useListScroll from './useListScroll'
import { appSetting } from '@renderer/store/setting'
export default {
name: 'MusicList',
components: {
SearchList,
MusicSortModal,
},
props: {
listId: {
type: String,
required: true,
},
},
emits: ['show-menu'],
setup(props, { emit }) {
const actionButtonsVisible = appSetting['list.actionButtonsVisible']
let scrollIndex = null
let isAnimation = false
const handleRestoreScroll = (_scrollIndex, _isAnimation) => {
scrollIndex = _scrollIndex
isAnimation = _isAnimation
if (isAnimation) restoreScroll(scrollIndex, isAnimation)
console.log('handleRestoreScroll', scrollIndex, isAnimation)
}
const onLoadedList = () => {
console.log('restoreScroll', scrollIndex, isAnimation)
restoreScroll(scrollIndex, isAnimation)
}
const {
rightClickSelectedIndex,
selectedIndex,
dom_listContent,
listRef,
list,
playerInfo,
setSelectedIndex,
isShowSource,
excludeListIds,
} = useListInfo({ props, onLoadedList })
const {
selectedList,
listItemHeight,
handleSelectData,
removeAllSelect,
} = useList({ list })
const {
handlePlayMusic,
handlePlayMusicLater,
doubleClickPlay,
} = usePlay({ props, selectedList, list, removeAllSelect })
const {
isShowListAdd,
isMove,
isShowListAddMultiple,
isMoveMultiple,
selectedAddMusicInfo,
handleShowMusicAddModal,
handleShowMusicMoveModal,
} = useMusicAdd({ selectedList, list })
const {
isShowDownload,
isShowDownloadMultiple,
selectedDownloadMusicInfo,
handleShowDownloadModal,
} = useMusicDownload({ selectedList, list })
const {
isShowMusicSortModal,
selectedNum,
selectedSortMusicInfo,
handleShowSortModal,
sortMusic,
} = useSort({ props, list, selectedList, removeAllSelect })
const {
handleSearch,
handleOpenMusicDetail,
handleCopyName,
handleRemoveMusic,
} = useMusicActions({ props, list, removeAllSelect, selectedList })
const {
menus,
menuLocation,
isShowItemMenu,
showMenu,
menuClick,
} = useMenu({
assertApiSupport,
emit,
handleShowDownloadModal,
handlePlayMusic,
handlePlayMusicLater,
handleSearch,
handleShowMusicAddModal,
handleShowMusicMoveModal,
handleShowSortModal,
handleOpenMusicDetail,
handleCopyName,
handleRemoveMusic,
})
const {
isShowSearchBar,
searchList,
handleMusicSearchAction,
} = useSearch({
setSelectedIndex,
handlePlayMusic,
listRef,
})
const { saveListPosition, restoreScroll } = useListScroll({ props, listRef, list, handleRestoreScroll })
const handleListItemClick = (event, index) => {
if (rightClickSelectedIndex.value > -1) return
handleSelectData(index)
doubleClickPlay(index)
}
const handleListItemRightClick = (event, index) => {
rightClickSelectedIndex.value = index
showMenu(event, list.value[index], index)
}
const handleMenuClick = (action) => {
let index = rightClickSelectedIndex.value
rightClickSelectedIndex.value = -1
menuClick(action, index)
}
const handleListRightClick = (event) => {
if (!event.target.classList.contains('select')) return
event.stopImmediatePropagation()
let classList = dom_listContent.value.classList
classList.add('copying')
window.requestAnimationFrame(() => {
let str = window.getSelection().toString()
classList.remove('copying')
str = str.split(/\n\n/).map(s => s.replace(/\n/g, ' ')).join('\n').trim()
if (!str.length) return
clipboardWriteText(str)
})
}
const handleListBtnClick = ({ action, index }) => {
switch (action) {
case 'download':
handleShowDownloadModal(index, true)
break
case 'play':
handlePlayMusic(index, true)
break
case 'search':
handleSearch(index)
break
case 'listAdd':
handleShowMusicAddModal(index, true)
break
}
}
const scrollToTop = () => {
listRef.value.scrollTo(0, true)
}
return {
listItemHeight,
handleListItemClick,
selectedList,
handleListItemRightClick,
removeAllSelect,
handleListBtnClick,
rightClickSelectedIndex,
selectedIndex,
dom_listContent,
listRef,
excludeListIds,
menus,
isShowItemMenu,
menuLocation,
handleMenuClick,
handleListRightClick,
assertApiSupport,
isShowListAdd,
isMove,
isShowListAddMultiple,
isMoveMultiple,
selectedAddMusicInfo,
isShowMusicSortModal,
selectedNum,
selectedSortMusicInfo,
sortMusic,
isShowDownload,
isShowDownloadMultiple,
selectedDownloadMusicInfo,
scrollToTop,
isShowSearchBar,
searchList,
handleMusicSearchAction,
list,
playerInfo,
saveListPosition,
isShowSource,
handleRestoreScroll,
actionButtonsVisible,
}
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.list {
overflow: hidden;
height: 100%;
flex: auto;
display: flex;
flex-flow: column nowrap;
:global(.list-item) {
&.active {
color: var(--color-button-font);
}
}
:global {
.label-source {
color: var(--color-primary);
padding: 5px;
font-size: .8em;
line-height: 1;
opacity: .75;
display: inline-block;
}
}
}
.content {
min-height: 0;
font-size: 14px;
display: flex;
flex-flow: column nowrap;
flex: auto;
}
.no-item {
position: relative;
height: 100%;
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
p {
font-size: 24px;
color: var(--color-font-label);
}
}
</style>
@@ -0,0 +1,106 @@
import { computed, watch, ref, onBeforeUnmount } from '@common/utils/vueTools'
import { isFullscreen } from '@renderer/store'
import { getFontSizeWithScreen } from '@renderer/utils'
import { appSetting } from '@renderer/store/setting'
const useKeyEvent = ({ handleSelectAllData }) => {
const keyEvent = {
isShiftDown: false,
isModDown: false,
}
const handle_key_shift_down = () => {
if (!keyEvent.isShiftDown) keyEvent.isShiftDown = true
}
const handle_key_shift_up = () => {
if (keyEvent.isShiftDown) keyEvent.isShiftDown = false
}
const handle_key_mod_down = () => {
if (!keyEvent.isModDown) keyEvent.isModDown = true
}
const handle_key_mod_up = () => {
if (keyEvent.isModDown) keyEvent.isModDown = false
}
const handle_key_mod_a_down = ({ event }) => {
if (event.target.tagName == 'INPUT') return
event.preventDefault()
if (event.repeat) return
keyEvent.isModDown = false
handleSelectAllData()
}
onBeforeUnmount(() => {
window.key_event.off('key_shift_down', handle_key_shift_down)
window.key_event.off('key_shift_up', handle_key_shift_up)
window.key_event.off('key_mod_down', handle_key_mod_down)
window.key_event.off('key_mod_up', handle_key_mod_up)
window.key_event.off('key_mod+a_down', handle_key_mod_a_down)
})
window.key_event.on('key_shift_down', handle_key_shift_down)
window.key_event.on('key_shift_up', handle_key_shift_up)
window.key_event.on('key_mod_down', handle_key_mod_down)
window.key_event.on('key_mod_up', handle_key_mod_up)
window.key_event.on('key_mod+a_down', handle_key_mod_a_down)
return keyEvent
}
export default ({ list }) => {
const selectedList = ref([])
let lastSelectIndex = -1
const listItemHeight = computed(() => {
return Math.ceil((isFullscreen.value ? getFontSizeWithScreen() : appSetting['common.fontSize']) * 2.3)
})
const removeAllSelect = () => {
selectedList.value = []
}
const handleSelectAllData = () => {
removeAllSelect()
selectedList.value = [...list.value]
}
const keyEvent = useKeyEvent({ handleSelectAllData })
const handleSelectData = clickIndex => {
if (keyEvent.isShiftDown) {
if (selectedList.value.length) {
removeAllSelect()
if (lastSelectIndex != clickIndex) {
let isNeedReverse = false
if (clickIndex < lastSelectIndex) {
let temp = lastSelectIndex
lastSelectIndex = clickIndex
clickIndex = temp
isNeedReverse = true
}
selectedList.value = list.value.slice(lastSelectIndex, clickIndex + 1)
if (isNeedReverse) selectedList.value.reverse()
}
} else {
selectedList.value.push(list.value[clickIndex])
lastSelectIndex = clickIndex
}
} else if (keyEvent.isModDown) {
lastSelectIndex = clickIndex
let item = list.value[clickIndex]
let index = selectedList.value.indexOf(item)
if (index < 0) {
selectedList.value.push(item)
} else {
selectedList.value.splice(index, 1)
}
} else if (selectedList.value.length) {
removeAllSelect()
}
}
watch(list, removeAllSelect)
return {
selectedList,
listItemHeight,
removeAllSelect,
handleSelectData,
}
}
@@ -0,0 +1,49 @@
import { ref, watch, computed } from '@common/utils/vueTools'
import { playMusicInfo, playInfo } from '@renderer/store/player/state'
import { getListMusics } from '@renderer/store/list/action'
import { appSetting } from '@renderer/store/setting'
export default ({ props, onLoadedList }) => {
const rightClickSelectedIndex = ref(-1)
const selectedIndex = ref(-1)
const dom_listContent = ref(null)
const listRef = ref(null)
const excludeListIds = computed(() => ([props.listId]))
const list = ref([])
watch(() => props.listId, id => {
getListMusics(id).then(l => {
list.value = l
if (id != props.listId) return
onLoadedList()
})
}, {
immediate: true,
})
const playerInfo = computed(() => ({
isPlayList: playMusicInfo.listId == props.listId,
playIndex: playInfo.playIndex,
}))
const setSelectedIndex = index => {
selectedIndex.value = index
}
const isShowSource = computed(() => appSetting['list.isShowSource'])
return {
rightClickSelectedIndex,
selectedIndex,
dom_listContent,
listRef,
list,
playerInfo,
setSelectedIndex,
isShowSource,
excludeListIds,
}
}
@@ -0,0 +1,52 @@
import { onMounted, onBeforeUnmount } from '@common/utils/vueTools'
import { useRoute, useRouter } from '@common/utils/vueRouter'
import { setListPosition, getListPosition } from '@renderer/utils/data'
import { appSetting } from '@renderer/store/setting'
export default ({ props, listRef, list, handleRestoreScroll }) => {
const route = useRoute()
const router = useRouter()
const saveListPosition = () => {
setListPosition(props.listId, listRef.value?.getScrollTop() || 0)
}
const handleScrollList = (index, isAnimation, callback = () => {}) => {
listRef.value.scrollToIndex(index, -150, isAnimation, callback)
}
const restoreScroll = async(index, isAnimation) => {
// console.log(index, isAnimation)
if (!list.value.length) return
if (index == null) {
let location = await getListPosition(props.listId) || 0
if (appSetting['list.isSaveScrollLocation'] && location != null) {
listRef.value?.scrollTo(location)
}
return
}
handleScrollList(index, isAnimation)
}
onMounted(() => {
handleRestoreScroll(route.query.scrollIndex, false)
if (route.query.scrollIndex != null) {
router.replace({
path: '/list',
query: {
id: props.listId,
updated: true,
},
})
}
})
onBeforeUnmount(() => {
saveListPosition()
})
return {
saveListPosition,
restoreScroll,
}
}
@@ -0,0 +1,156 @@
import { computed, ref, shallowReactive, reactive, nextTick } from '@common/utils/vueTools'
import musicSdk from '@renderer/utils/musicSdk'
import { useI18n } from '@renderer/plugins/i18n'
export default ({
assertApiSupport,
emit,
handleShowDownloadModal,
handlePlayMusic,
handlePlayMusicLater,
handleSearch,
handleShowMusicAddModal,
handleShowMusicMoveModal,
handleShowSortModal,
handleOpenMusicDetail,
handleCopyName,
handleRemoveMusic,
}) => {
const itemMenuControl = reactive({
play: true,
playLater: true,
copyName: true,
addTo: true,
moveTo: true,
sort: true,
download: true,
search: true,
remove: true,
sourceDetail: true,
})
const t = useI18n()
const menuLocation = shallowReactive({ x: 0, y: 0 })
const isShowItemMenu = ref(false)
const menus = computed(() => {
return [
{
name: t('list__play'),
action: 'play',
disabled: !itemMenuControl.play,
},
{
name: t('list__download'),
action: 'download',
disabled: !itemMenuControl.download,
},
{
name: t('list__play_later'),
action: 'playLater',
disabled: !itemMenuControl.playLater,
},
{
name: t('list__add_to'),
action: 'addTo',
disabled: !itemMenuControl.addTo,
},
{
name: t('list__move_to'),
action: 'moveTo',
disabled: !itemMenuControl.moveTo,
},
{
name: t('list__sort'),
action: 'sort',
disabled: !itemMenuControl.sort,
},
{
name: t('list__copy_name'),
action: 'copyName',
disabled: !itemMenuControl.copyName,
},
{
name: t('list__source_detail'),
action: 'sourceDetail',
disabled: !itemMenuControl.sourceDetail,
},
{
name: t('list__search'),
action: 'search',
disabled: !itemMenuControl.search,
},
{
name: t('list__remove'),
action: 'remove',
disabled: !itemMenuControl.remove,
},
]
})
const showMenu = (event, musicInfo) => {
itemMenuControl.sourceDetail = !!musicSdk[musicInfo.source]?.getMusicDetailPageUrl
// itemMenuControl.play =
// itemMenuControl.playLater =
itemMenuControl.download = assertApiSupport(musicInfo.source) && musicInfo.source != 'local'
menuLocation.x = event.pageX
menuLocation.y = event.pageY
if (isShowItemMenu.value) return
emit('show-menu')
nextTick(() => {
isShowItemMenu.value = true
})
}
const hideMenu = () => {
isShowItemMenu.value = false
}
const menuClick = (action, index) => {
// console.log(action)
hideMenu()
if (!action) return
switch (action.action) {
case 'play':
handlePlayMusic(index)
break
case 'playLater':
handlePlayMusicLater(index)
break
case 'copyName':
handleCopyName(index)
break
case 'addTo':
handleShowMusicAddModal(index)
break
case 'moveTo':
handleShowMusicMoveModal(index)
break
case 'sort':
handleShowSortModal(index)
break
case 'download':
handleShowDownloadModal(index)
break
case 'search':
handleSearch(index)
break
case 'remove':
handleRemoveMusic(index)
break
case 'sourceDetail':
handleOpenMusicDetail(index)
}
}
return {
menus,
menuLocation,
isShowItemMenu,
showMenu,
menuClick,
}
}
@@ -0,0 +1,60 @@
import { useRouter } from '@common/utils/vueRouter'
import musicSdk from '@renderer/utils/musicSdk'
import { openUrl, clipboardWriteText } from '@common/utils/electron'
import { dialog } from '@renderer/plugins/Dialog'
import { useI18n } from '@renderer/plugins/i18n'
import { removeListMusics } from '@renderer/store/list/action'
import { appSetting } from '@renderer/store/setting'
import { toOldMusicInfo } from '@renderer/utils/index'
export default ({ props, list, selectedList, removeAllSelect }) => {
const router = useRouter()
const t = useI18n()
const handleSearch = index => {
const info = list.value[index]
router.push({
path: '/search',
query: {
text: `${info.name} ${info.singer}`,
},
})
}
const handleOpenMusicDetail = index => {
const minfo = list.value[index]
const url = musicSdk[minfo.source].getMusicDetailPageUrl(toOldMusicInfo(minfo))
if (!url) return
openUrl(url)
}
const handleCopyName = index => {
const minfo = list.value[index]
clipboardWriteText(appSetting['download.fileName'].replace('歌名', minfo.name).replace('歌手', minfo.singer))
}
const handleRemoveMusic = async(index, single) => {
if (selectedList.value.length && !single) {
const confirm = await (selectedList.value.length > 1
? dialog.confirm({
message: t('lists__remove music_tip', { len: selectedList.value.length }),
confirmButtonText: t('lists__remove_tip_button'),
})
: Promise.resolve(true)
)
if (!confirm) return
removeListMusics({ listId: props.listId, ids: selectedList.value.map(m => m.id) })
removeAllSelect()
} else {
removeListMusics({ listId: props.listId, ids: [list.value[index].id] })
}
}
return {
handleSearch,
handleOpenMusicDetail,
handleCopyName,
handleRemoveMusic,
}
}
@@ -0,0 +1,45 @@
import { ref, nextTick } from '@common/utils/vueTools'
export default ({ selectedList, list }) => {
const isShowListAdd = ref(false)
const isMove = ref(false)
const isMoveMultiple = ref(false)
const isShowListAddMultiple = ref(false)
const selectedAddMusicInfo = ref(null)
const handleShowMusicAddModal = (index, single) => {
if (selectedList.value.length && !single) {
isMoveMultiple.value = false
isShowListAddMultiple.value = true
} else {
isMove.value = false
selectedAddMusicInfo.value = list.value[index]
nextTick(() => {
isShowListAdd.value = true
})
}
}
const handleShowMusicMoveModal = (index, single) => {
if (selectedList.value.length && !single) {
isMoveMultiple.value = true
isShowListAddMultiple.value = true
} else {
isMove.value = true
selectedAddMusicInfo.value = list.value[index]
nextTick(() => {
isShowListAdd.value = true
})
}
}
return {
isShowListAdd,
isMove,
isMoveMultiple,
isShowListAddMultiple,
selectedAddMusicInfo,
handleShowMusicAddModal,
handleShowMusicMoveModal,
}
}
@@ -0,0 +1,25 @@
import { ref, nextTick } from '@common/utils/vueTools'
export default ({ selectedList, list }) => {
const isShowDownload = ref(false)
const isShowDownloadMultiple = ref(false)
const musicInfo = ref(null)
const handleShowDownloadModal = (index, single) => {
if (selectedList.value.length && !single) {
isShowDownloadMultiple.value = true
} else {
musicInfo.value = list.value[index]
nextTick(() => {
isShowDownload.value = true
})
}
}
return {
isShowDownload,
isShowDownloadMultiple,
selectedDownloadMusicInfo: musicInfo,
handleShowDownloadModal,
}
}
@@ -0,0 +1,40 @@
import { addTempPlayList } from '@renderer/store/player/action'
import { playList } from '@renderer/core/player'
export default ({ props, selectedList, list, removeAllSelect }) => {
let clickTime = 0
let clickIndex = -1
const handlePlayMusic = (index) => {
playList(props.listId, index)
}
const handlePlayMusicLater = (index, single) => {
if (selectedList.value.length && !single) {
addTempPlayList(selectedList.value.map(s => ({ listId: props.listId, musicInfo: s })))
removeAllSelect()
} else {
addTempPlayList([{ listId: props.listId, musicInfo: list.value[index] }])
}
}
const doubleClickPlay = index => {
if (
window.performance.now() - clickTime > 400 ||
clickIndex !== index
) {
clickTime = window.performance.now()
clickIndex = index
return
}
handlePlayMusic(index, true)
clickTime = 0
clickIndex = -1
}
return {
handlePlayMusic,
handlePlayMusicLater,
doubleClickPlay,
}
}
@@ -0,0 +1,38 @@
import { ref, onBeforeUnmount } from '@common/utils/vueTools'
export default ({ setSelectedIndex, handlePlayMusic, listRef }) => {
const isShowSearchBar = ref(false)
const searchList = ref([])
const handleShowSearchBar = () => {
isShowSearchBar.value = true
}
const handleMusicSearchAction = ({ action, data: { index, isPlay } = {} }) => {
isShowSearchBar.value = false
switch (action) {
case 'listClick':
if (index < 0) return
listRef.value.scrollToIndex(index, -150, true, () => {
setSelectedIndex(index)
setTimeout(() => {
setSelectedIndex(-1)
if (isPlay) handlePlayMusic(index)
}, 600)
})
break
}
}
window.key_event.on('key_mod+f_down', handleShowSearchBar)
onBeforeUnmount(() => {
window.key_event.off('key_mod+f_down', handleShowSearchBar)
})
return {
isShowSearchBar,
searchList,
handleMusicSearchAction,
}
}
@@ -0,0 +1,39 @@
import { updateListMusicsPosition } from '@renderer/store/list/action'
import { ref, nextTick } from '@common/utils/vueTools'
export default ({ props, list, selectedList, removeAllSelect }) => {
const isShowMusicSortModal = ref(false)
const selectedNum = ref(0)
const musicInfo = ref(null)
const handleShowSortModal = (index, single) => {
if (selectedList.value.length && !single) {
selectedNum.value = selectedList.value.length
} else {
selectedNum.value = 0
musicInfo.value = list.value[index]
}
nextTick(() => {
isShowMusicSortModal.value = true
})
}
const sortMusic = num => {
num = Math.min(num, list.value.length)
updateListMusicsPosition({
listId: props.listId,
position: num - 1,
ids: (selectedNum.value ? [...selectedList.value] : [musicInfo.value]).map(m => m.id),
})
removeAllSelect()
isShowMusicSortModal.value = false
}
return {
isShowMusicSortModal,
selectedNum,
selectedSortMusicInfo: musicInfo,
handleShowSortModal,
sortMusic,
}
}
+28
View File
@@ -0,0 +1,28 @@
import { addListMusics, setFetchingListStatus } from '@renderer/store/list/action'
import { showSelectDialog } from '@renderer/utils/ipc'
const handleAddMusics = async(listId: string, filePaths: string[], index: number = -1) => {
// console.log(index + 1, index + 201)
const paths = filePaths.slice(index + 1, index + 201)
const musicInfos = await window.lx.worker.main.createLocalMusicInfos(paths)
if (musicInfos.length) await addListMusics(listId, musicInfos)
index += 200
if (filePaths.length - 1 > index) await handleAddMusics(listId, filePaths, index)
}
export const addLocalFile = async(listInfo: LX.List.MyListInfo) => {
const { canceled, filePaths } = await showSelectDialog({
title: window.i18n.t('lists__add_local_file_desc'),
properties: ['openFile', 'multiSelections'],
filters: [
{ name: 'Media File', extensions: ['mp3', 'flac', 'ogg', 'wav'] },
// { name: 'All Files', extensions: ['*'] },
],
})
if (canceled || !filePaths.length) return
console.log(filePaths)
setFetchingListStatus(listInfo.id, true)
await handleAddMusics(listInfo.id, filePaths)
setFetchingListStatus(listInfo.id, false)
}
@@ -0,0 +1,232 @@
<template>
<material-modal :show="visible" bg-close teleport="#view" width="60%" max-width="900px" @close="$emit('update:visible', false)">
<div :class="$style.header">
<h2>{{ listInfo.name }}</h2>
</div>
<base-virtualized-list
v-if="duplicateList.length" v-slot="{ item, index }" :list="duplicateList" key-name="id" :class="$style.list" style="contain: none;"
:item-height="listItemHeight" container-class="scroll" content-class="list"
>
<div :class="$style.listItem">
<div :class="$style.num">{{ item.index + 1 }}</div>
<div :class="$style.textContent">
<h3 :class="$style.text" :aria-label="`${item.musicInfo.name} - ${item.musicInfo.singer}`">{{ item.musicInfo.name }} - {{ item.musicInfo.singer }}</h3>
<h3 v-if="item.musicInfo.meta.albumName" :class="[$style.text, $style.albumName]" :aria-label="item.musicInfo.meta.albumName">{{ item.musicInfo.meta.albumName }}</h3>
</div>
<div :class="$style.label">{{ item.musicInfo.source }}</div>
<div :class="$style.label">{{ item.musicInfo.interval }}</div>
<div :class="$style.btns">
<button type="button" :class="$style.btn" @click="handlePlay(index)">
<svg v-once version="1.1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" height="50%" viewBox="0 0 287.386 287.386" space="preserve">
<use xlink:href="#icon-testPlay" />
</svg>
</button>
<button type="button" :class="$style.btn" @click="handleRemove(index)">
<svg v-once version="1.1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" height="50%" viewBox="0 0 212.982 212.982" space="preserve">
<use xlink:href="#icon-delete" />
</svg>
</button>
</div>
</div>
</base-virtualized-list>
<div v-else :class="$style.noItem">
<p v-text="$t('no_item')" />
</div>
</material-modal>
</template>
<script>
import { shallowReactive, watch, toRaw, computed } from '@common/utils/vueTools'
import { playList } from '@renderer/core/player'
import { getListMusics, removeListMusics } from '@renderer/store/list/action'
import { isFullscreen } from '@renderer/store'
import { appSetting } from '@renderer/store/setting'
import { getFontSizeWithScreen } from '@renderer/utils'
export default {
props: {
visible: {
type: Boolean,
default: false,
},
listInfo: { // { id: '', name: '' }
type: Object,
required: true,
},
},
emits: ['update:visible'],
setup(props) {
const duplicateList = shallowReactive([])
const listItemHeight = computed(() => {
return Math.ceil((isFullscreen.value ? getFontSizeWithScreen() : appSetting['common.fontSize']) * 3.2)
})
const handlePlay = (index) => {
const { index: musicInfoIndex } = duplicateList[index]
playList(props.listInfo.id, musicInfoIndex)
}
const handleFilterList = async() => {
// console.time('filter')
duplicateList.splice(
0,
duplicateList.length,
...(await window.lx.worker.main.filterDuplicateMusic(toRaw(await getListMusics(props.listInfo.id)))))
// console.timeEnd('filter')
}
const handleRemove = async(index) => {
const { musicInfo: targetMusicInfo } = duplicateList.splice(index, 1)[0]
await removeListMusics({ listId: props.listInfo.id, ids: [targetMusicInfo.id] })
await handleFilterList()
}
watch(() => props.visible, (visible) => {
if (visible) {
if (duplicateList.length) duplicateList.splice(0, duplicateList.length)
handleFilterList()
}
})
return {
listItemHeight,
duplicateList,
handleFilterList,
handleRemove,
handlePlay,
}
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.header {
flex: none;
padding: 15px;
text-align: center;
h2 {
word-break: break-all;
}
}
.main {
min-height: 200px;
min-width: 460px;
// display: flex;
// flex-flow: column nowrap;
}
.list {
min-height: 200px;
min-width: 460px;
// background-color: @color-search-form-background;
font-size: 13px;
transition-property: height;
// position: relative;
.listItem {
position: relative;
padding: 0 5px;
transition: background-color .2s ease;
line-height: 1.4;
height: 100%;
// overflow: hidden;
display: flex;
flex-flow: row nowrap;
align-items: center;
&:hover {
background-color: var(--color-primary-background-hover);
}
// border-radius: 4px;
// &:last-child {
// border-bottom-left-radius: 4px;
// border-bottom-right-radius: 4px;
// }
}
}
.num {
flex: none;
font-size: 12px;
width: 30px;
text-align: center;
color: var(--color-font-label);
}
.textContent {
flex: auto;
padding-left: 5px;
min-width: 0;
display: flex;
flex-flow: column nowrap;
align-items: flex-start;
overflow: hidden;
}
.text {
max-width: 100%;
.mixin-ellipsis-1;
}
.albumName {
font-size: 12px;
opacity: 0.6;
// .mixin-ellipsis-1;
}
.label {
flex: none;
font-size: 12px;
opacity: 0.5;
padding: 0 5px;
display: flex;
align-items: center;
// transform: rotate(45deg);
// background-color:
}
.btns {
flex: none;
font-size: 12px;
padding: 0 5px;
display: flex;
align-items: center;
}
.btn {
background-color: transparent;
border: none;
border-radius: @form-radius;
margin-right: 5px;
cursor: pointer;
padding: 4px 7px;
color: var(--color-button-font);
outline: none;
transition: background-color 0.2s ease;
line-height: 0;
&:last-child {
margin-right: 0;
}
svg {
height: 16px;
}
&:hover {
background-color: var(--color-primary-background-hover);
}
&:active {
background-color: var(--color-primary-font-active);
}
}
.no-item {
position: relative;
height: 200px;
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
p {
font-size: 16px;
color: var(--color-font-label);
}
}
</style>
@@ -0,0 +1,183 @@
<template>
<material-modal :show="visible" teleport="#view" bg-close @close="closeModal" @after-leave="handleAfterLeave">
<main :class="$style.main">
<div :class="$style.header">
<h2>{{ listInfo.name }}</h2>
</div>
<section>
<h3 :class="$style.title">{{ $t('list_sort_modal_by_field') }}</h3>
<ul :class="$style.list">
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_field_name" v-model="sortField" name="list_sort_modal_field" :aria-label="$t('list_sort_modal_by_name')"
need="need" value="name" :label="$t('list_sort_modal_by_name')"
/>
</li>
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_field_singer" v-model="sortField" name="list_sort_modal_field"
need="need" value="singer" :label="$t('list_sort_modal_by_singer')"
/>
</li>
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_field_album" v-model="sortField" name="list_sort_modal_field"
need="need" value="album" :label="$t('list_sort_modal_by_album')"
/>
</li>
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_field_time" v-model="sortField" name="list_sort_modal_field"
need="need" value="interval" :label="$t('list_sort_modal_by_time')"
/>
</li>
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_field_source" v-model="sortField" name="list_sort_modal_field"
need="need" value="source" :label="$t('list_sort_modal_by_source')"
/>
</li>
</ul>
</section>
<section>
<h3 :class="$style.title">{{ $t('list_sort_modal_by_type') }}</h3>
<ul :class="$style.list">
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_type_up" v-model="sortType" name="list_sort_modal_type"
need="need" value="up" :label="$t('list_sort_modal_by_up')"
/>
</li>
<li :class="$style.listItem">
<base-checkbox
id="list_sort_modal_type_down" v-model="sortType" name="list_sort_modal_type"
need="need" value="down" :label="$t('list_sort_modal_by_down')"
/>
</li>
</ul>
</section>
<div :class="$style.footer">
<base-btn :class="$style.btn" @click="closeModal">{{ $t('btn_cancel') }}</base-btn>
<base-btn :class="$style.btn" @click="handleSort">{{ $t('btn_confirm') }}</base-btn>
</div>
</main>
</material-modal>
</template>
<script>
import { ref } from '@common/utils/vueTools'
// import { dialog } from '@renderer/plugins/Dialog'
import { getListMusics, updateListMusicsPosition } from '@renderer/store/list/action'
export default {
props: {
visible: {
type: Boolean,
default: false,
},
listInfo: { // { id: '', name: '' }
type: Object,
required: true,
},
},
emits: ['update:visible'],
setup(props, { emit }) {
// const { t } = useI18n()
const sortField = ref('')
const sortType = ref('')
const closeModal = () => {
emit('update:visible', false)
}
const handleAfterLeave = () => {
sortField.value = ''
sortType.value = ''
}
const verify = () => {
return !!sortField.value && !!sortType.value
}
const handleSort = async() => {
if (!verify()) return
// if (!await dialog.confirm({
// message: t('list_sort_modal_tip_confirm'),
// cancelButtonText: t('cancel_button_text'),
// confirmButtonText: t('confirm_button_text'),
// })) return
let list = [...(await getListMusics(props.listInfo.id))]
list = await window.lx.worker.main.sortListMusicInfo(list, sortType.value, sortField.value, window.i18n.locale)
console.log(sortType.value, sortField.value)
closeModal()
updateListMusicsPosition({ listId: props.listInfo.id, position: 0, ids: list.map(m => m.id) })
}
return {
sortField,
sortType,
closeModal,
handleSort,
handleAfterLeave,
}
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.header {
flex: none;
padding: 15px;
text-align: center;
h2 {
color: var(--color-font);
word-break: break-all;
}
}
.main {
padding: 0 15px;
width: 320px;
display: flex;
flex-flow: column nowrap;
min-height: 0;
// max-height: 100%;
// overflow: hidden;
}
.title {
font-size: 14px;
color: var(--color-font-label);
padding: 10px 0 8px;
}
.list {
display: flex;
flex-flow: row wrap;
font-size: 14px;
}
.listItem {
width: (100% / 3);
padding-left: 10px;
margin-bottom: 8px;
box-sizing: border-box;
}
.footer {
margin: 20px 0 15px auto;
}
.btn {
// box-sizing: border-box;
// margin-left: 15px;
// margin-bottom: 15px;
// height: 36px;
// line-height: 36px;
// padding: 0 10px !important;
min-width: 70px;
// .mixin-ellipsis-1;
+.btn {
margin-left: 10px;
}
}
</style>
@@ -0,0 +1,231 @@
<template>
<material-modal :show="visible" bg-close teleport="#view" @close="$emit('update:visible', false)">
<div :class="$style.header">
<h2>{{ $t('list_update_modal__title') }}</h2>
</div>
<main class="scroll" :class="$style.main">
<ul v-if="lists.length" ref="dom_list" :class="$style.list">
<li v-for="list in lists" :key="list.id" :class="[$style.listItem, {[$style.fetching]: fetchingListStatus[list.id]}]">
<div :class="$style.listLeft">
<h3 :class="$style.text">{{ list.name }} <span :class="$style.label">{{ list.source }}</span></h3>
<div>
<base-checkbox
:id="`list_auto_update_${list.id}`" :model-value="updateInfo[list.id]?.isAutoUpdate == true"
:class="$style.checkbox" :label="$t('list_update_modal__auto_update')" @change="handleChangeAutoUpdate(list, $event)"
/>
<span :class="$style.label" style="vertical-align: text-top;">{{ listUpdateTimes[list.id] }}</span>
</div>
</div>
<div :class="$style.btns">
<button :class="$style.btn" :disabled="fetchingListStatus[list.id]" outline="outline" :aria-label="$t('list_update_modal__update')" @click.stop="handleUpdate(list)">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" style="transform: rotate(45deg);" viewBox="0 0 24 24" space="preserve">
<use xlink:href="#icon-refresh" />
</svg>
</button>
</div>
</li>
</ul>
<div v-else :class="$style.noItem">
<p v-text="$t('no_item')" />
</div>
</main>
<div :class="$style.footer">
<div :class="$style.tips">{{ $t('list_update_modal__tips') }}</div>
</div>
</material-modal>
</template>
<script>
import { computed, ref } from '@common/utils/vueTools'
import { userLists, fetchingListStatus, listUpdateTimes } from '@renderer/store/list/state'
import handleSyncSourceList from '@renderer/store/list/syncSourceList'
import musicSdk from '@renderer/utils/musicSdk'
// import { dateFormat } from '@common/utils/renderer'
import { getListUpdateInfo, setListAutoUpdate } from '@renderer/utils/data'
export default {
props: {
visible: {
type: Boolean,
default: false,
},
},
emits: ['update:visible'],
setup() {
const lists = computed(() => userLists.filter(l => !!l.source && !!musicSdk[l.source]?.songList))
const updateInfo = ref({})
// const updateTimes = ref({})
getListUpdateInfo().then((listUpdateInfo) => {
updateInfo.value = listUpdateInfo
// if (listUpdateTimes._inited) {
// for (const [id, value] of Object.entries(info)) {
// autoUpdate[id] = value.isAutoUpdate == true
// }
// } else {
// for (const [id, value] of Object.entries(info)) {
// autoUpdate[id] = value.isAutoUpdate == true
// listUpdateTimes[id] = value.updateTime ? dateFormat(value.updateTime) : ''
// }
// }
// listUpdateTimes._inited = true
})
const handleUpdate = (targetListInfo) => {
handleSyncSourceList(targetListInfo)
// console.log(targetListInfo.list.length, list.length)
}
const handleChangeAutoUpdate = (list, enable) => {
setListAutoUpdate(list.id, enable)
}
return {
lists,
updateInfo,
fetchingListStatus,
handleUpdate,
handleChangeAutoUpdate,
listUpdateTimes,
}
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
@width: 460px;
.header {
flex: none;
padding: 15px;
text-align: center;
h2 {
word-break: break-all;
}
}
.main {
min-height: 200px;
width: @width;
}
.list {
// background-color: @color-search-form-background;
font-size: 13px;
transition-property: height;
position: relative;
.listItem {
position: relative;
padding: 15px 10px 15px 15px;
transition: .3s ease;
transition-property: background-color, opacity;
line-height: 1.3;
// overflow: hidden;
display: flex;
flex-flow: row nowrap;
align-items: center;
&:hover {
background-color: var(--color-primary-background-hover);
}
// border-radius: 4px;
// &:last-child {
// border-bottom-left-radius: 4px;
// border-bottom-right-radius: 4px;
// }
&.fetching {
opacity: .5;
}
}
}
.listLeft {
flex: auto;
min-width: 0;
display: flex;
flex-flow: column nowrap;
justify-content: center;
}
.text {
flex: auto;
margin-bottom: 2px;
.mixin-ellipsis-1;
}
.checkbox {
margin-top: 3px;
font-size: 14px;
opacity: .86;
}
.label {
flex: none;
font-size: 12px;
opacity: 0.5;
padding: 0 10px;
// display: flex;
// align-items: center;
// transform: rotate(45deg);
// background-color:
}
.btns {
flex: none;
font-size: 12px;
padding: 0 5px;
display: flex;
align-items: center;
}
.btn {
background-color: transparent;
border: none;
border-radius: @form-radius;
margin-right: 5px;
cursor: pointer;
padding: 4px 7px;
color: var(--color-button-font);
outline: none;
transition: background-color 0.2s ease;
line-height: 0;
&:last-child {
margin-right: 0;
}
svg {
height: 22px;
width: 22px;
}
&:hover {
background-color: var(--color-primary-background-hover);
}
&:active {
background-color: var(--color-primary-font-active);
}
}
.footer {
width: @width;
}
.tips {
padding: 8px 15px;
font-size: 13px;
line-height: 1.25;
color: var(--color-font);
}
.no-item {
position: relative;
height: 200px;
display: flex;
flex-flow: column nowrap;
justify-content: center;
align-items: center;
p {
font-size: 16px;
color: var(--color-font-label);
}
}
</style>
@@ -0,0 +1,259 @@
<!-- <template>
<teleport to="#root">
<div ref="dom_menu" :class="$style.container" :style="menuStyles" :aria-hidden="!modelValue">
<div :class="$style.group">
<div :class="$style.subGroup">
<button :class="$style.btn" :disabled="!itemMenuControl.rename" ignore-tip :aria-label="$t('lists__rename')" @click="menuClick('rename')">{{ $t('lists__rename') }}</button>
<button :class="$style.btn" :disabled="offsetDisabled" ignore-tip :aria-label="$t('lists__sync')" @click="menuClick('sync')">{{ $t('lists__sync') }}</button>
</div>
<div :class="$style.subGroup">
<button :class="$style.btn" :disabled="offsetDisabled" ignore-tip :aria-label="$t('lists__sort_list')" @click="menuClick('sort')">{{ $t('lists__sort_list') }}</button>
<button :class="$style.btn" :disabled="offsetDisabled" ignore-tip :aria-label="$t('lyric_menu__offset_dec_100')" @click="menuClick(-100)">- 100ms</button>
</div>
</div>
</div>
</teleport>
</template>
<script>
import { computed, useRefGetter, ref, useCommit, watch } from '@common/utils/vueTools'
import useMenuLocation from '@renderer/utils/compositions/useMenuLocation'
import { setLyricEdited, removeLyricEdited, debounce } from '@renderer/utils'
const offsetTagRxp = /(?:^|\n)\s*\[offset:\s*(\S+(?:\d+)*)\s*\]/
const offsetTagAllRxp = /(?:^|\n)\s*\[offset:\s*(\S+(?:\d+)*)\s*\]/g
const saveLyric = debounce((musicInfo, lyricInfo) => {
setLyricEdited(musicInfo, lyricInfo)
})
const removeLyric = debounce(musicInfo => {
removeLyricEdited(musicInfo)
})
const getOffset = lrc => {
let offset = offsetTagRxp.exec(lrc)
if (offset) {
offset = parseInt(offset[1])
if (Number.isNaN(offset)) offset = 0
} else offset = 0
return offset
}
export default {
name: 'LyricMenu',
props: {
modelValue: Boolean,
xy: Object,
lyricInfo: Object,
},
emits: ['updateLyric', 'update:modelValue'],
setup(props, { emit }) {
const setting = useRefGetter('setting')
const playDetailSetting = useRefGetter('playDetailSetting')
const setPlayDetailLyricAlign = useCommit('setPlayDetailLyricAlign')
const setPlayDetailLyricFont = useCommit('setPlayDetailLyricFont')
const offset = ref(0)
const offsetDisabled = ref(true)
const originOffset = ref(0)
const visible = computed(() => props.modelValue)
const musicInfo = computed(() => props.lyricInfo.musicInfo)
const location = computed(() => props.xy)
const onHide = () => {
emit('update:modelValue', false)
}
const setFontAlign = val => {
if (playDetailSetting.value.style.align == val) return
setPlayDetailLyricAlign(val)
}
const fontSizeUp = step => {
if (setting.value.playDetail.style.fontSize >= 200) return
setPlayDetailLyricFont(Math.min(setting.value.playDetail.style.fontSize + step, 200))
}
const fontSizeDown = step => {
if (setting.value.playDetail.style.fontSize <= 70) return
setPlayDetailLyricFont(Math.max(setting.value.playDetail.style.fontSize - step, 70))
}
const fontSizeReset = () => {
setPlayDetailLyricFont(100)
}
const updateLyric = offset => {
let lyric = props.lyricInfo.lyric
let tlyric = props.lyricInfo.tlyric
let rlyric = props.lyricInfo.rlyric
let lxlyric = props.lyricInfo.lxlyric
if (offsetTagRxp.test(lyric)) {
lyric = lyric.replace(offsetTagAllRxp, `[offset:${offset}]`)
if (tlyric) tlyric = tlyric.replace(offsetTagAllRxp, `[offset:${offset}]`)
if (lxlyric) lxlyric = lxlyric.replace(offsetTagAllRxp, `[offset:${offset}]`)
if (rlyric) rlyric = rlyric.replace(offsetTagAllRxp, `[offset:${offset}]`)
} else {
lyric = `[offset:${offset}]\n` + lyric
if (tlyric) tlyric = `[offset:${offset}]\n` + tlyric
if (lxlyric) lxlyric = `[offset:${offset}]\n` + lxlyric
if (rlyric) rlyric = `[offset:${offset}]\n` + rlyric
}
if (offset == originOffset.value) {
removeLyric(props.lyricInfo.musicInfo)
} else {
saveLyric(props.lyricInfo.musicInfo, {
lyric,
tlyric,
rlyric,
lxlyric,
})
}
emit('updateLyric', {
lyric,
tlyric,
rlyric,
lxlyric,
offset,
})
}
const setOffset = step => {
offset.value += step
updateLyric(offset.value)
}
const offsetReset = () => {
if (offset.value == originOffset.value) return
offset.value = originOffset.value
updateLyric(originOffset.value)
}
const parseLrcOffset = () => {
offset.value = getOffset(props.lyricInfo.lyric)
originOffset.value = getOffset(props.lyricInfo.rawlyric)
offsetDisabled.value = !props.lyricInfo.lyric
}
const { dom_menu, menuStyles } = useMenuLocation({
visible,
location,
onHide,
})
watch(musicInfo, () => {
if (!props.modelValue) return
parseLrcOffset()
})
watch(visible, val => {
if (!val) return
parseLrcOffset()
})
return {
dom_menu,
menuStyles,
playDetailSetting,
offset,
originOffset,
fontSizeUp,
fontSizeDown,
fontSizeReset,
setOffset,
offsetReset,
setFontAlign,
offsetDisabled,
}
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.container {
font-size: 12px;
position: absolute;
opacity: 0;
transform: scale(0);
transform-origin: 0 0 0;
transition: .25s ease;
transition-property: transform, opacity;
border-radius: @radius-border;
background-color: var(--color-main-background);
box-shadow: 0 1px 8px 0 rgba(0,0,0,.2);
z-index: 10;
overflow: hidden;
}
.group {
display: flex;
flex-direction: column;
}
.title {
flex: auto;
padding: 10px 0 10px 10px;
color: var(--color-font-label);
white-space: nowrap;
min-width: 120px;
}
.subGroup {
display: flex;
flex-flow: row nowrap;
}
.btn {
flex: auto;
white-space: nowrap;
cursor: pointer;
min-width: 60px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
// color: var(--color-button-font);
padding: 0 10px;
outline: none;
transition: @transition-normal;
transition-property: background-color, opacity;
box-sizing: border-box;
.mixin-ellipsis-1;
background-color: var(--color-main-background);
border: none;
&:hover {
opacity: .7;
background-color: @color-theme_2-hover;
}
&:active {
background-color: @color-theme_2-active;
}
&.active {
background-color: var(--color-main-background);
color: var(--color-button-background-active);
cursor: default;
opacity: 1;
}
&[disabled] {
cursor: default;
opacity: .4;
&:hover {
background: none !important;
}
}
}
.titleBtn {
flex: none;
padding: 0 10;
min-width: 40px;
opacity: .7;
&[disabled] {
opacity: .3;
}
}
</style>
-->
@@ -0,0 +1,117 @@
<template>
<material-modal :show="show" teleport="#view" @close="handleClose" @after-enter="$refs.input.focus()">
<main :class="$style.main">
<h2>{{ selectedNum > 0 ? $t('music_sort__title_multiple', { num: selectedNum }) : $t('music_sort__title', { name: musicInfo ? musicInfo.name : '' }) }}</h2>
<base-input
ref="input"
v-model="sortNum"
:class="$style.input"
type="number"
:placeholder="$t('music_sort__input_tip')"
@submit="handleSubmit" @blur="verify"
/>
<div :class="$style.footer">
<base-btn :class="$style.btn" @click="handleSubmit">{{ $t('btn_confirm') }}</base-btn>
</div>
</main>
</material-modal>
</template>
<script>
export default {
props: {
show: {
type: Boolean,
default: false,
},
musicInfo: {
type: Object,
default() {
return {}
},
},
selectedNum: {
type: Number,
default: 0,
},
},
emits: ['update:show', 'confirm'],
data() {
return {
sortNum: '',
}
},
watch: {
show(n) {
if (n) {
this.sortNum = ''
}
},
},
methods: {
handleClose() {
this.$emit('update:show', false)
},
verify() {
let num = /^[1-9]\d*/.exec(this.sortNum)
num = num ? parseInt(num[0]) : ''
this.sortNum = num.toString()
return num
},
handleSubmit() {
let num = this.verify()
if (this.sortNum == '') return
this.handleClose()
this.$emit('confirm', num)
},
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.main {
padding: 0 15px;
max-width: 530px;
min-width: 280px;
display: flex;
flex-flow: column nowrap;
min-height: 0;
// max-height: 100%;
// overflow: hidden;
h2 {
font-size: 13px;
color: var(--color-font);
line-height: 1.3;
word-break: break-all;
// text-align: center;
padding: 15px 0 8px;
}
}
.input {
// width: 100%;
// height: 26px;
padding: 8px 8px;
}
.footer {
margin: 20px 0 15px auto;
}
.btn {
// box-sizing: border-box;
// margin-left: 15px;
// margin-bottom: 15px;
// height: 36px;
// line-height: 36px;
// padding: 0 10px !important;
min-width: 70px;
// .mixin-ellipsis-1;
+.btn {
margin-left: 10px;
}
}
</style>
+379
View File
@@ -0,0 +1,379 @@
<template>
<div ref="dom_lists" :class="$style.lists">
<div :class="$style.listHeader">
<h2 :class="$style.listsTitle">{{ $t('my_list') }}</h2>
<div :class="$style.headerBtns">
<button :class="$style.listsAdd" :aria-label="$t('lists__new_list_btn')" @click="isShowNewList = true">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" height="70%" viewBox="0 0 24 24" space="preserve">
<use xlink:href="#icon-list-add" />
</svg>
</button>
<button :class="$style.listsAdd" :aria-label="$t('list_update_modal__title')" @click="isShowListUpdateModal = true">
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xlink="http://www.w3.org/1999/xlink" style="transform: rotate(45deg);" height="70%" viewBox="0 0 24 24" space="preserve">
<use xlink:href="#icon-refresh" />
</svg>
</button>
</div>
</div>
<ul ref="dom_lists_list" class="scroll" :class="[$style.listsContent, { [$style.sortable]: isModDown }]">
<li
class="default-list" :class="[$style.listsItem, {[$style.active]: defaultList.id == listId}, {[$style.clicked]: rightClickItemIndex == -2}, {[$style.fetching]: fetchingListStatus[defaultList.id]}]"
:aria-label="defaultList.name" :aria-selected="defaultList.id == listId"
@contextmenu="handleListsItemRigthClick($event, -2)" @click="handleListToggle(defaultList.id)"
>
<span :class="$style.listsLabel">{{ defaultList.name }}</span>
</li>
<li
class="default-list" :class="[$style.listsItem, {[$style.active]: loveList.id == listId}, {[$style.clicked]: rightClickItemIndex == -1}, {[$style.fetching]: fetchingListStatus[loveList.id]}]"
:aria-label="loveList.name" :aria-selected="loveList.id == listId"
@contextmenu="handleListsItemRigthClick($event, -1)" @click="handleListToggle(loveList.id)"
>
<span :class="$style.listsLabel">{{ loveList.name }}</span>
</li>
<li
v-for="(item, index) in userLists"
:key="item.id" class="user-list"
:class="[$style.listsItem, {[$style.active]: item.id == listId}, {[$style.clicked]: rightClickItemIndex == index}, {[$style.fetching]: fetchingListStatus[item.id]}]"
:data-index="index" :aria-label="item.name" :aria-selected="defaultList.id == listId" @contextmenu="handleListsItemRigthClick($event, index)"
>
<span :class="$style.listsLabel" @click="handleListToggle(item.id, index + 2)">{{ item.name }}</span>
<input
:class="$style.listsInput" type="text" :value="item.name"
:placeholder="item.name" @contextmenu.stop @keyup.enter="handleSaveListName(index, $event)" @blur="handleSaveListName(index, $event)"
>
</li>
<transition enter-active-class="animated-fast slideInLeft" leave-active-class="animated-fast fadeOut" @after-leave="isNewListLeave = false" @after-enter="$refs.dom_listsNewInput.focus()">
<li v-if="isShowNewList" :class="[$style.listsItem, $style.listsNew, {[$style.newLeave]: isNewListLeave}]">
<input
ref="dom_listsNewInput" :class="$style.listsInput" type="text" :placeholder="$t('lists__new_list_input')" @contextmenu.stop
@keyup.enter="handleCreateList" @blur="handleCreateList"
>
</li>
</transition>
</ul>
<base-menu v-model="isShowMenu" :menus="menus" :xy="menuLocation" item-name="name" @menu-click="handleMenuClick" />
<DuplicateMusicModal v-model:visible="isShowDuplicateMusicModal" :list-info="duplicateListInfo" />
<ListSortModal v-model:visible="isShowListSortModal" :list-info="sortListInfo" />
<ListUpdateModal v-model:visible="isShowListUpdateModal" />
</div>
</template>
<script>
import { openUrl } from '@common/utils/electron'
import musicSdk from '@renderer/utils/musicSdk'
import DuplicateMusicModal from './components/DuplicateMusicModal'
import ListSortModal from './components/ListSortModal'
import ListUpdateModal from './components/ListUpdateModal'
import { defaultList, loveList, userLists, fetchingListStatus } from '@renderer/store/list/state'
import { removeUserList } from '@renderer/store/list/action'
import { ref, watch } from '@common/utils/vueTools'
import { useRouter } from '@common/utils/vueRouter'
import { LIST_IDS } from '@common/constants'
import { dialog } from '@renderer/plugins/Dialog'
import { saveListPrevSelectId } from '@renderer/utils/data'
import { useI18n } from '@renderer/plugins/i18n'
import useShare from './useShare'
import useMenu from './useMenu'
import useListUpdate from './useListUpdate'
import useSort from './useSort'
import useDarg from './useDarg'
import useEditList from './useEditList'
import useListScroll from './useListScroll'
import useDuplicate from './useDuplicate'
export default {
name: 'MyLists',
components: {
DuplicateMusicModal,
ListSortModal,
ListUpdateModal,
},
props: {
listId: {
type: String,
required: true,
},
},
emits: ['show-menu'],
setup(props, { emit }) {
const router = useRouter()
const t = useI18n()
const dom_lists_list = ref(null)
const rightClickItemIndex = ref(-10)
const { handleImportList, handleExportList } = useShare()
const { isShowListUpdateModal, handleUpdateSourceList } = useListUpdate()
const { isShowListSortModal, sortListInfo, handleSortList } = useSort()
const { isShowDuplicateMusicModal, duplicateListInfo, handleDuplicateList } = useDuplicate()
const { handleRename, handleSaveListName, isShowNewList, isNewListLeave, handleCreateList } = useEditList({ dom_lists_list })
useListScroll({ dom_lists_list })
const handleOpenSourceDetailPage = async(listInfo) => {
const { source, sourceListId } = listInfo
if (!sourceListId) return
let url
if (/board__/.test(sourceListId)) {
const id = sourceListId.replace(/board__/, '')
url = musicSdk[source].leaderboard.getDetailPageUrl(id)
} else if (musicSdk[source].songList?.getDetailPageUrl) {
url = await musicSdk[source].songList.getDetailPageUrl(sourceListId)
}
if (!url) return
openUrl(url)
}
const handleRemove = (listInfo) => {
dialog.confirm({
message: t('lists__remove_tip', { name: listInfo.name }),
confirmButtonText: t('lists__remove_tip_button'),
}).then(isRemove => {
if (!isRemove) return
removeUserList([listInfo.id])
if (props.listId == listInfo.id) {
handleListToggle(LIST_IDS.DEFAULT)
}
})
}
const {
menus,
menuLocation,
isShowMenu,
showMenu,
menuClick,
} = useMenu({
emit,
handleImportList,
handleExportList,
handleUpdateSourceList,
handleOpenSourceDetailPage,
handleSortList,
handleDuplicateList,
handleRename,
handleRemove,
})
const handleListsItemRigthClick = (event, index) => {
rightClickItemIndex.value = index
showMenu(event, index)
}
const handleListToggle = (id) => {
if (id == props.listId) return
router.replace({
path: '/list',
query: { id },
}).catch(_ => _)
}
const handleMenuClick = (action) => {
if (rightClickItemIndex.value < -2) return
let index = rightClickItemIndex.value
rightClickItemIndex.value = -10
menuClick(action, index)
}
const { isModDown } = useDarg({ dom_lists_list, handleMenuClick, handleSaveListName })
watch(() => props.listId, (listId) => {
saveListPrevSelectId(listId)
})
watch(() => userLists, (lists) => {
if (lists.some(l => l.id == props.listId)) return
router.replace({
path: '/list',
query: {
id: defaultList.id,
},
})
})
return {
rightClickItemIndex,
defaultList,
loveList,
userLists,
fetchingListStatus,
dom_lists_list,
isShowListUpdateModal,
isShowListSortModal,
sortListInfo,
isShowDuplicateMusicModal,
duplicateListInfo,
handleSaveListName,
isShowNewList,
isNewListLeave,
handleCreateList,
handleListsItemRigthClick,
isShowMenu,
handleMenuClick,
menus,
menuLocation,
handleListToggle,
isModDown,
hideMenu: handleMenuClick,
}
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
@lists-item-height: 36px;
.lists {
flex: none;
width: 16%;
display: flex;
flex-flow: column nowrap;
}
.listHeader {
position: relative;
display: flex;
flex-flow: row nowrap;
border-bottom: var(--color-list-header-border-bottom);
&:hover {
.listsAdd {
opacity: 1;
}
}
}
.listsTitle {
flex: auto;
font-size: 12px;
line-height: 38px;
padding: 0 10px;
.mixin-ellipsis-1;
}
.headerBtns {
flex: none;
display: flex;
}
.listsAdd {
// position: absolute;
// right: 0;
margin-top: 6px;
background: none;
height: 30px;
border: none;
outline: none;
border-radius: @radius-border;
cursor: pointer;
opacity: .1;
transition: opacity @transition-normal;
color: var(--color-button-font);
svg {
vertical-align: bottom;
}
&:active {
opacity: .7 !important;
}
&:hover {
opacity: .6 !important;
}
}
.listsContent {
flex: auto;
min-width: 0;
overflow-y: scroll !important;
// border-right: 1px solid rgba(0, 0, 0, 0.12);
&.sortable {
* {
-webkit-user-drag: element;
}
.listsItem {
&:hover, &.active, &.selected, &.clicked {
background-color: transparent !important;
}
&.dragingItem {
background-color: var(--color-primary-background-hover) !important;
}
}
}
}
.listsItem {
position: relative;
transition: .3s ease;
transition-property: color, background-color, opacity;
background-color: transparent;
&:not(.active) {
&:hover {
background-color: var(--color-primary-background-hover);
cursor: pointer;
}
}
&.active {
// background-color:
color: var(--color-primary);
}
&.selected {
background-color: var(--color-primary-font-active);
}
&.clicked {
background-color: var(--color-primary-background-hover);
}
&.fetching {
opacity: .5;
}
&.editing {
padding: 0 10px;
background-color: var(--color-primary-background-hover);
.listsLabel {
display: none;
}
.listsInput {
display: block;
}
}
}
.listsLabel {
display: block;
height: @lists-item-height;
padding: 0 10px;
font-size: 13px;
line-height: @lists-item-height;
.mixin-ellipsis-1;
}
.listsInput {
width: 100%;
height: @lists-item-height;
border: none;
padding: 0;
// padding-bottom: 1px;
line-height: @lists-item-height;
background: none;
outline: none;
font-size: 13px;
display: none;
font-family: inherit;
}
.listsNew {
padding: 0 10px;
background-color: var(--color-primary-background-hover);
.listsInput {
display: block;
}
}
.newLeave {
margin-top: -@lists-item-height;
z-index: -1;
}
</style>
+59
View File
@@ -0,0 +1,59 @@
import { onBeforeUnmount, ref, Ref, useCssModule } from '@common/utils/vueTools'
import { updateUserListPosition } from '@renderer/store/list/action'
import { userLists } from '@renderer/store/list/state'
import useDarg from '@renderer/utils/compositions/useDrag'
export default ({ dom_lists_list, handleSaveListName, handleMenuClick }: {
dom_lists_list: Ref<HTMLElement | null>
handleSaveListName: () => void
handleMenuClick: () => void
}) => {
const isModDown = ref(false)
const styles = useCssModule()
const { setDisabled } = useDarg({
dom_list: dom_lists_list,
dragingItemClassName: styles.dragingItem,
filter: 'default-list',
onUpdate(newIndex: number, oldIndex: number) {
void updateUserListPosition({ ids: [userLists[oldIndex - 2].id], position: newIndex - 2 })
},
})
const handle_key_mod_down = ({ event }: LX.KeyDownEevent) => {
if (!isModDown.value) {
// console.log(event)
switch ((event!.target as HTMLElement).tagName) {
case 'INPUT':
case 'SELECT':
case 'TEXTAREA':
return
default: if ((event!.target as HTMLElement).isContentEditable) return
}
isModDown.value = true
setDisabled(false)
handleSaveListName()
}
handleMenuClick()
}
const handle_key_mod_up = () => {
if (isModDown.value) {
isModDown.value = false
setDisabled(true)
}
}
window.key_event.on('key_mod_down', handle_key_mod_down)
window.key_event.on('key_mod_up', handle_key_mod_up)
onBeforeUnmount(() => {
window.key_event.off('key_mod_down', handle_key_mod_down)
window.key_event.off('key_mod_up', handle_key_mod_up)
})
return {
isModDown,
}
}
@@ -0,0 +1,19 @@
import { ref, reactive } from '@common/utils/vueTools'
export default () => {
const isShowDuplicateMusicModal = ref(false)
const duplicateListInfo = reactive({ id: '', name: '' })
const handleDuplicateList = (listInfo: LX.List.MyListInfo) => {
duplicateListInfo.id = listInfo.id
duplicateListInfo.name = listInfo.name
isShowDuplicateMusicModal.value = true
}
return {
isShowDuplicateMusicModal,
duplicateListInfo,
handleDuplicateList,
}
}
@@ -0,0 +1,65 @@
import { ref, nextTick, useCssModule, Ref } from '@common/utils/vueTools'
import { userLists } from '@renderer/store/list/state'
import { updateUserList, createUserList } from '@renderer/store/list/action'
export default ({ dom_lists_list }: {
dom_lists_list: Ref<HTMLElement | null>
}) => {
const isShowNewList = ref(false)
const isNewListLeave = ref(false)
const editIndex = ref(-1)
const styles = useCssModule()
const handleRename = (index: number) => {
console.log(index)
const dom = dom_lists_list.value?.querySelectorAll('.user-list')[index]
if (!dom) return
void nextTick(() => {
dom.classList.add(styles.editing)
dom.querySelector('input')?.focus()
})
}
const handleSaveListName = async() => {
let dom_target = dom_lists_list.value?.querySelector('.' + styles.editing) as HTMLElement
if (!dom_target) return
const dom_input = dom_target.querySelector('.' + styles.listsInput) as HTMLInputElement
if (!dom_input) return
let name = dom_input.value.trim()
if (dom_target.dataset.index == null) return
const targetList = userLists[parseInt(dom_target.dataset.index)]
if (name.length) {
await updateUserList([{ ...targetList, name }])
dom_target.classList.remove(styles.editing)
}
dom_input.value = targetList.name
}
const handleCreateList = async(event: Event) => {
const target = event.target as HTMLInputElement
if (target.readOnly) return
let name = target.value.trim()
target.readOnly = true
if (name == '') {
isShowNewList.value = false
return
}
await createUserList({ name })
isNewListLeave.value = true
void nextTick(() => {
isShowNewList.value = false
})
}
return {
isShowNewList,
isNewListLeave,
editIndex,
handleRename,
handleSaveListName,
handleCreateList,
}
}
@@ -0,0 +1,21 @@
import { onMounted, useCssModule, Ref } from '@common/utils/vueTools'
export default ({ dom_lists_list }: {
dom_lists_list: Ref<HTMLElement | null>
}) => {
const styles = useCssModule()
const setListsScroll = () => {
if (!dom_lists_list.value) return
let target = dom_lists_list.value.querySelector('.' + styles.active) as HTMLElement
if (!target) return
let offsetTop = target.offsetTop
let location = offsetTop - 150
if (location > 0) dom_lists_list.value.scrollTop = location
}
onMounted(() => {
setListsScroll()
})
}
@@ -0,0 +1,26 @@
import { ref } from '@common/utils/vueTools'
import { dialog } from '@renderer/plugins/Dialog'
import syncSourceList from '@renderer/store/list/syncSourceList'
import { useI18n } from '@renderer/plugins/i18n'
export default () => {
const isShowListUpdateModal = ref(false)
const t = useI18n()
const handleUpdateSourceList = (listInfo: LX.List.UserListInfo) => {
void dialog.confirm({
message: t('lists__sync_confirm_tip', { name: listInfo.name }),
confirmButtonText: t('lists__remove_tip_button'),
}).then(isSync => {
if (!isSync) return
void syncSourceList(listInfo)
})
}
return {
isShowListUpdateModal,
handleUpdateSourceList,
}
}
+192
View File
@@ -0,0 +1,192 @@
import { computed, ref, reactive, nextTick } from '@common/utils/vueTools'
import { useI18n } from '@renderer/plugins/i18n'
import { userLists, defaultList, loveList } from '@renderer/store/list/state'
import musicSdk from '@renderer/utils/musicSdk'
import { addLocalFile } from './actions'
export default ({
emit,
handleRename,
handleDuplicateList,
handleSortList,
handleOpenSourceDetailPage,
handleImportList,
handleExportList,
handleUpdateSourceList,
handleRemove,
}) => {
const menuControl = reactive({
rename: true,
duplicate: true,
sort: true,
local_file: true,
sourceDetail: true,
import: true,
export: true,
sync: false,
remove: true,
})
const t = useI18n()
const menuLocation = reactive({ x: 0, y: 0 })
const isShowMenu = ref(false)
const menus = computed(() => {
return [
{
name: t('lists__rename'),
action: 'rename',
disabled: !menuControl.rename,
},
{
name: t('lists__sort_list'),
action: 'sort',
disabled: !menuControl.sort,
},
{
name: t('lists__duplicate'),
action: 'duplicate',
disabled: !menuControl.duplicate,
},
{
name: t('lists__select_local_file'),
action: 'local_file',
disabled: !menuControl.local_file,
},
{
name: t('lists__sync'),
action: 'sync',
disabled: !menuControl.sync,
},
{
name: t('lists__source_detail'),
action: 'sourceDetail',
disabled: !menuControl.sourceDetail,
},
{
name: t('lists__import'),
action: 'import',
disabled: !menuControl.export,
},
{
name: t('lists__export'),
action: 'export',
disabled: !menuControl.export,
},
{
name: t('lists__remove'),
action: 'remove',
disabled: !menuControl.remove,
},
]
})
const assertSupportDetail = (source, index) => {
if (source) {
const { sourceListId } = userLists[index]
if (sourceListId) {
if (/board__/.test(sourceListId)) {
// const id = sourceListId.replace(/board__/, '')
return !!musicSdk[source]?.leaderboard?.getDetailPageUrl
} else {
return !!musicSdk[source]?.songList?.getDetailPageUrl
}
}
}
return false
}
const showMenu = (event, index) => {
let source
switch (index) {
case -1:
case -2:
menuControl.rename = false
menuControl.remove = false
menuControl.sync = false
break
default:
menuControl.rename = true
menuControl.remove = true
source = userLists[index].source
menuControl.sync = !!source && !!musicSdk[source]?.songList
break
}
// menuControl.sort = !!getList(this.getTargetListInfo(index)?.id).length
menuControl.sourceDetail = assertSupportDetail(source, index)
menuLocation.x = event.pageX
menuLocation.y = event.pageY
if (isShowMenu.value) return
emit('show-menu')
nextTick(() => {
isShowMenu.value = true
})
}
const hideMenu = () => {
isShowMenu.value = false
}
const getListInfo = (index) => {
let list
switch (index) {
case -2:
list = defaultList
break
case -1:
list = loveList
break
default:
list = userLists[index]
if (!list) return null
break
}
return list
}
const menuClick = (action, index) => {
// console.log(action)
hideMenu()
if (!action) return
const listInfo = getListInfo(index)
switch (action.action) {
case 'rename':
handleRename(index)
break
case 'duplicate':
handleDuplicateList(listInfo)
break
case 'sort':
handleSortList(listInfo)
break
case 'local_file':
addLocalFile(listInfo)
break
case 'sourceDetail':
handleOpenSourceDetailPage(listInfo)
break
case 'import':
handleImportList(listInfo, index)
break
case 'export':
handleExportList(listInfo)
break
case 'sync':
handleUpdateSourceList(listInfo)
break
case 'remove':
handleRemove(listInfo)
break
}
}
return {
menus,
menuLocation,
isShowMenu,
showMenu,
menuClick,
}
}
+106
View File
@@ -0,0 +1,106 @@
import { toRaw } from '@common/utils/vueTools'
import { openSaveDir, showSelectDialog } from '@renderer/utils/ipc'
import { useI18n } from '@renderer/plugins/i18n'
import { filterFileName, toNewMusicInfo, fixNewMusicInfoQuality } from '@renderer/utils'
import { getListMusics, updateUserList, addListMusics, overwriteListMusics, createUserList } from '@renderer/store/list/action'
import { defaultList, loveList, userLists } from '@renderer/store/list/state'
import useImportTip from '@renderer/utils/compositions/useImportTip'
import { dialog } from '@renderer/plugins/Dialog'
export default () => {
const t = useI18n()
const showImportTip = useImportTip()
const handleExportList = (listInfo: LX.List.MyListInfo) => {
if (!listInfo) return
void openSaveDir({
title: t('lists__export_part_desc'),
defaultPath: `lx_list_part_${filterFileName(listInfo.name)}.lxmc`,
}).then(async result => {
if (result.canceled || !result.filePath) return
void window.lx.worker.main.saveLxConfigFile(result.filePath, {
type: 'playListPart_v2',
data: { ...toRaw(listInfo), list: toRaw(await getListMusics(listInfo.id)) },
})
})
}
const handleImportList = (listInfo: LX.List.MyListInfo, index: number) => {
void showSelectDialog({
title: t('lists__import_part_desc'),
properties: ['openFile'],
filters: [
{ name: 'Play List Part', extensions: ['json', 'lxmc'] },
{ name: 'All Files', extensions: ['*'] },
],
}).then(async result => {
if (result.canceled) return
const filePath = result.filePaths[0]
if (!filePath) return
let configData: any
try {
configData = await window.lx.worker.main.readLxConfigFile(filePath)
} catch (error) {
return
}
let listData: LX.ConfigFile.MyListInfoPart['data']
switch (configData.type) {
case 'playListPart':
listData = configData.data
listData.list = listData.list.map(m => toNewMusicInfo(m))
break
case 'playListPart_v2':
listData = configData.data
break
default: return showImportTip(configData.type)
}
const targetList = [defaultList, loveList, ...userLists].find(l => l.id == listData.id)
if (targetList) {
const confirm = await dialog.confirm({
message: t('lists__import_part_confirm', { importName: listData.name, localName: targetList.name }),
cancelButtonText: t('lists__import_part_button_cancel'),
confirmButtonText: t('lists__import_part_button_confirm'),
})
if (confirm) {
listData.name = targetList.name
switch (listData.id) {
case defaultList.id:
case loveList.id:
break
default:
void updateUserList([
{
name: listData.name,
id: listData.id,
source: (listData as LX.List.UserListInfo).source,
sourceListId: (listData as LX.List.UserListInfo).sourceListId,
locationUpdateTime: (targetList as LX.List.UserListInfo).locationUpdateTime,
},
])
break
}
void overwriteListMusics({
listId: listData.id,
musicInfos: listData.list.map(m => fixNewMusicInfoQuality(m)),
})
return
}
listData.id += `__${Date.now()}`
}
void createUserList({
position: index,
name: listData.name,
id: listData.id,
source: (listData as LX.List.UserListInfo).source,
sourceListId: (listData as LX.List.UserListInfo).sourceListId,
})
void addListMusics(listData.id, listData.list.map(m => fixNewMusicInfoQuality(m)))
})
}
return {
handleExportList,
handleImportList,
}
}
+19
View File
@@ -0,0 +1,19 @@
import { ref, reactive } from '@common/utils/vueTools'
export default () => {
const isShowListSortModal = ref(false)
const sortListInfo = reactive({ id: '', name: '' })
const handleSortList = (listInfo) => {
sortListInfo.id = listInfo.id
sortListInfo.name = listInfo.name
isShowListSortModal.value = true
}
return {
isShowListSortModal,
sortListInfo,
handleSortList,
}
}
+72
View File
@@ -0,0 +1,72 @@
<template>
<div id="my-list" :class="$style.container" @click="handleContainerClick">
<MyList ref="myList" :list-id="listId" @show-menu="$refs.musicList.handleMenuClick()" />
<MusicList ref="musicList" :list-id="listId" @show-menu="$refs.myList.handleMenuClick()" />
</div>
</template>
<script>
import { getListPrevSelectId } from '@renderer/utils/data'
import MyList from './MyList'
import MusicList from './MusicList'
export default {
name: 'List',
components: {
MyList,
MusicList,
},
async beforeRouteEnter(to, from, next) {
let id = to.query.id
if (!id) {
id = await getListPrevSelectId()
next({
path: to.path,
query: { id },
})
} else next()
},
beforeRouteUpdate(to, from) {
// console.log(to, from)
if (to.query.updated) return
let id = to.query.id
if (id == null) return
// if (!getList(id)) {
// id = defaultList.id
// }
this.listId = id
const scrollIndex = to.query.scrollIndex
const isAnimation = from.query.id == to.query.id
this.$refs.musicList?.handleRestoreScroll(scrollIndex, isAnimation)
return {
path: '/list',
query: { id, updated: true },
}
},
beforeRouteLeave(to, from) {
this.$refs.musicList?.saveListPosition()
},
data() {
return {
listId: null,
}
},
created() {
this.listId = this.$route.query.id
},
}
</script>
<style lang="less" module>
@import '@renderer/assets/styles/layout.less';
.container {
overflow: hidden;
height: 100%;
display: flex;
position: relative;
}
</style>