Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions build-config/build-before-pack.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ const fs = require('fs')
const fsPromises = require('fs').promises
const path = require('path')
const { Arch } = require('electron-builder')
const nodeAbi = require('node-abi')
const loadNodeAbi = async () => {
const mod = await import('node-abi')
const getAbi = mod.getAbi ?? mod.default?.getAbi
if (!getAbi) throw new Error('Unable to locate getAbi function in node-abi module. This may indicate an incompatible version or module structure.')
return getAbi
}

const better_sqlite3_fileNameMap = {
[Arch.x64]: 'linux-x64',
Expand Down Expand Up @@ -54,7 +59,8 @@ const replaceQrcDecodeLib = async(electronNodeAbi, platform, arch) => {
module.exports = async(context) => {
const { electronPlatformName, arch } = context
const electronVersion = context.packager?.info?._framework?.version ?? require('../package.json').devDependencies.electron.replace(/^[^\d]*?(\d+)/, '$1')
const electronNodeAbi = nodeAbi.getAbi(electronVersion, 'electron')
const getAbi = await loadNodeAbi()
const electronNodeAbi = getAbi(electronVersion, 'electron')
await replaceQrcDecodeLib(electronNodeAbi, electronPlatformName, arch)
if (electronPlatformName !== 'linux' || process.env.FORCE) return
const bindingFilePath = path.join(__dirname, '../node_modules/better-sqlite3/binding.gyp')
Expand Down
5 changes: 5 additions & 0 deletions src/lang/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
"comment__hot_load_error": "Failed to load top comments. Click to try to reload.",
"comment__hot_loading": "Top comments are loading...",
"comment__hot_title": "Top comments",
"comment__image": "Comment image",
"comment__location": "From {location}",
"comment__new_load_error": "Failed to load latest comments. Click to try to reload.",
"comment__new_loading": "Latest comments are loading...",
"comment__new_title": "Latest comments",
"comment__no_content": "No comments yet",
"comment__refresh": "Refresh comments",
"comment__save_image_desc": "Choose where to save the image",
"comment__show": "Song comments",
"comment__title": "Comments for \"{name}\"",
"comment__unavailable": "Unable to get comments for this song.",
"save_image_failed": "Failed to save image: {message}",
"confirm_button_text": "Yes",
"copy_tip": " (Click to copy)",
"date_format_hour": "{num} hours ago",
Expand Down Expand Up @@ -229,7 +232,9 @@
"player__music_singer": "Artist: ",
"player__next": "Next",
"player__pause": "Pause",
"player__cover": "Cover",
"player__pic_tip": "Playback detail page\n(Right-click to locate the currently playing song in \"Your Library\")",
"player__save_cover_desc": "Choose where to save the cover image",
"player__play": "Play",
"player__play_toggle_mode_list": "In Order",
"player__play_toggle_mode_list_loop": "Repeat Playlist",
Expand Down
5 changes: 5 additions & 0 deletions src/lang/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
"comment__hot_load_error": "热门评论加载失败,点击尝试重新加载",
"comment__hot_loading": "热门评论加载中...",
"comment__hot_title": "热门评论",
"comment__image": "评论图片",
"comment__location": "来自{location}",
"comment__new_load_error": "最新评论加载失败,点击尝试重新加载",
"comment__new_loading": "最新评论加载中",
"comment__new_title": "最新评论",
"comment__no_content": "暂无评论",
"comment__refresh": "刷新评论",
"comment__save_image_desc": "选择图片保存位置",
"comment__show": "歌曲评论",
"comment__title": "「{name}」的评论",
"comment__unavailable": "此歌曲不支持获取评论",
"save_image_failed": "保存图片失败:{message}",
"confirm_button_text": "是的",
"copy_tip": "(点击复制)",
"date_format_hour": "{num} 小时前",
Expand Down Expand Up @@ -229,7 +232,9 @@
"player__music_singer": "艺术家:",
"player__next": "下一首",
"player__pause": "暂停",
"player__cover": "封面",
"player__pic_tip": "播放详情页(右击在「我的列表」定位当前播放的歌曲)",
"player__save_cover_desc": "选择封面保存位置",
"player__play": "播放",
"player__play_toggle_mode_list": "顺序播放",
"player__play_toggle_mode_list_loop": "列表循环播放",
Expand Down
5 changes: 5 additions & 0 deletions src/lang/zh-tw.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,18 @@
"comment__hot_load_error": "熱門評論載入失敗,點擊嘗試重新載入",
"comment__hot_loading": "熱門評論載入中...",
"comment__hot_title": "熱門評論",
"comment__image": "評論圖片",
"comment__location": "來自{location}",
"comment__new_load_error": "最新評論載入失敗,點擊嘗試重新載入",
"comment__new_loading": "最新評論載入中",
"comment__new_title": "最新評論",
"comment__no_content": "暫無評論",
"comment__refresh": "重新整理評論",
"comment__save_image_desc": "選擇圖片保存位置",
"comment__show": "歌曲評論",
"comment__title": "「{name}」的評論",
"comment__unavailable": "此歌曲不支援獲取評論",
"save_image_failed": "保存圖片失敗:{message}",
"confirm_button_text": "是的",
"copy_tip": "(點擊複製)",
"date_format_hour": "{num} 小時前",
Expand Down Expand Up @@ -229,7 +232,9 @@
"player__music_singer": "演出者:",
"player__next": "下一首",
"player__pause": "暫停",
"player__cover": "封面",
"player__pic_tip": "播放詳情頁(右擊在「我的清單」定位目前播放的歌曲)",
"player__save_cover_desc": "選擇封面保存位置",
"player__play": "播放",
"player__play_toggle_mode_list": "順序播放",
"player__play_toggle_mode_list_loop": "重複播放清單",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ div(:class="$style.container")
| {{ item.likedCount }}
p.select(:class="$style.comment_text") {{ item.text }}
div(v-if="item.images?.length" :class="$style.comment_images")
img(v-for="(url, index) in item.images" :key="index" :src="url" loading="lazy" decoding="async")
img(v-for="(url, index) in item.images" :key="index" :src="url" loading="lazy" decoding="async" @contextmenu.stop.prevent="handleSaveCommentImage(url, item, index)")
comment-floor(v-if="item.reply && item.reply.length" :class="$style.reply_floor" :comments="item.reply")
</template>

<script>
import commentDefImg from '@renderer/assets/images/defaultUser.jpg'
import { saveImage } from '@renderer/utils/saveImage'

export default {
name: 'CommentFloor',
Expand All @@ -48,6 +49,18 @@ export default {
handleUserImg(event) {
event.target.src = this.commentDefImg
},
handleSaveCommentImage(url, item, index) {
if (!url) return
const imageLabel = window.i18n.t('comment__image')
const baseNameParts = [imageLabel]
if (item?.userName) baseNameParts.push(item.userName)
baseNameParts.push(item?.id ?? String(index + 1))
void saveImage({
url,
defaultName: baseNameParts.filter(Boolean).join('-'),
title: window.i18n.t('comment__save_image_desc'),
})
},
},
}
</script>
Expand Down
18 changes: 17 additions & 1 deletion src/renderer/components/layout/PlayDetail/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ transition(enter-active-class="animated slideInRight" leave-active-class="animat
div.left(:class="$style.left")
//- div(:class="$style.info")
div(:class="$style.info")
img(v-if="musicInfo.pic" :class="$style.img" :src="musicInfo.pic")
img(v-if="musicInfo.pic" :class="$style.img" :src="musicInfo.pic" @contextmenu.stop.prevent="handleSaveCover")
div.description(:class="['scroll', $style.description]")
p {{ $t('player__music_name') }}{{ musicInfo.name }}
p {{ $t('player__music_singer') }}{{ musicInfo.singer }}
Expand Down Expand Up @@ -47,6 +47,8 @@ import ControlBtnsLeftHeader from './ControlBtnsLeftHeader.vue'
import ControlBtnsRightHeader from './ControlBtnsRightHeader.vue'
import { registerAutoHideMounse, unregisterAutoHideMounse } from './autoHideMounse'
import { appSetting } from '@renderer/store/setting'
import { formatMusicName } from '@renderer/utils'
import { saveImage } from '@renderer/utils/saveImage'
import { closeWindow, maxWindow, minWindow, setFullScreen } from '@renderer/utils/ipc'

export default {
Expand Down Expand Up @@ -93,6 +95,19 @@ export default {
unregisterAutoHideMounse()
}

const handleSaveCover = () => {
if (!musicInfo.pic) return
const coverLabel = window.i18n.t('player__cover')
const baseName = musicInfo.name
? formatMusicName(appSetting['download.fileName'], musicInfo.name, musicInfo.singer)
: coverLabel
void saveImage({
url: musicInfo.pic,
defaultName: baseName ? `${baseName}-${coverLabel}` : coverLabel,
title: window.i18n.t('player__save_cover_desc'),
})
}

watch(isFullscreen, isFullscreen => {
(isFullscreen ? registerAutoHideMounse : unregisterAutoHideMounse)()
})
Expand All @@ -109,6 +124,7 @@ export default {
hideComment,
handleAfterEnter,
handleAfterLeave,
handleSaveCover,
visibled,
isFullscreen,
fullscreenExit() {
Expand Down
24 changes: 24 additions & 0 deletions src/renderer/types/request.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
declare module '@renderer/utils/request' {
export interface HttpFetchResponse<T = unknown> {
statusCode?: number
headers?: Record<string, string | string[] | undefined>
raw?: Buffer | string
body?: T
}

export interface HttpFetchResult<T = unknown> {
isCancelled: boolean
cancelHttp: () => void
promise: Promise<HttpFetchResponse<T>>
}

export interface HttpFetchOptions {
method?: string
timeout?: number
format?: string
[key: string]: unknown
}

export function httpFetch<T = unknown>(url: string, options?: HttpFetchOptions): HttpFetchResult<T>
export function cancelHttp(requestObj?: { abort?: () => void }): void
}
101 changes: 101 additions & 0 deletions src/renderer/utils/saveImage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { fileURLToPath } from 'node:url'
import { openSaveDir } from '@renderer/utils/ipc'
import { httpFetch } from '@renderer/utils/request'
import { dialog } from '@renderer/plugins/Dialog'
import { filterFileName } from '@common/utils/common'
import { clipFileNameLength } from '@common/utils/tools'
import { extname, readFile } from '@common/utils/nodejs'

const mimeExtMap: Record<string, string> = {
'image/jpeg': 'jpg',
'image/jpg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
'image/bmp': 'bmp',
'image/svg+xml': 'svg',
}

const normalizeExt = (ext: string) => ext.replace(/^\./, '').toLowerCase()

const getExtFromMime = (mime?: string) => {
if (!mime) return ''
const key = mime.split(';')[0].trim().toLowerCase()
return mimeExtMap[key] ?? ''
}

const getExtFromUrl = (url: string) => {
try {
return normalizeExt(extname(new URL(url).pathname))
} catch {
return normalizeExt(extname(url))
}
}

const parseDataUrl = (url: string) => {
const match = /^data:([^;]+);base64,(.*)$/i.exec(url)
if (!match) return null
const ext = getExtFromMime(match[1])
return {
buffer: Buffer.from(match[2], 'base64'),
ext,
}
}

const resolveImageData = async(url: string) => {
if (url.startsWith('data:')) {
const parsed = parseDataUrl(url)
if (parsed) return parsed
}

if (/^https?:/i.test(url) || url.startsWith('//')) {
const requestUrl = url.startsWith('//') ? `https:${url}` : url
const response = await httpFetch(requestUrl, { method: 'get', timeout: 15000, format: 'buffer' }).promise
if (response.statusCode && response.statusCode >= 400) throw new Error(`HTTP ${response.statusCode}`)
const raw = (response as { raw?: Buffer | string }).raw
const buffer = Buffer.isBuffer(raw) ? raw : Buffer.from(raw ?? '')
if (!buffer.length) throw new Error('empty response')
const ext = getExtFromUrl(requestUrl) || getExtFromMime(response.headers?.['content-type'] as string | undefined)
return { buffer, ext }
}

const filePath = url.startsWith('file://') ? fileURLToPath(url) : url
const buffer = await readFile(filePath)
return { buffer, ext: normalizeExt(extname(filePath)) }
}

const ensureExt = (ext: string) => ext || 'jpg'

const buildDefaultFileName = (name: string, ext: string) => {
const safeName = filterFileName(clipFileNameLength(name || 'image')) || 'image'
return `${safeName}.${ext}`
}

const ensureFileExtension = (filePath: string, ext: string) => {
return extname(filePath) ? filePath : `${filePath}.${ext}`
}

export const saveImage = async({ url, defaultName, title }: {
url: string
defaultName: string
title: string
}) => {
if (!url) return
try {
const { buffer, ext } = await resolveImageData(url)
const normalizedExt = ensureExt(ext)
const { canceled, filePath } = await openSaveDir({
title,
defaultPath: buildDefaultFileName(defaultName, normalizedExt),
})
if (canceled || !filePath) return
await window.lx.worker.main.saveStrToFile(ensureFileExtension(filePath, normalizedExt), buffer)
} catch (error) {
console.log(error)
const message = error instanceof Error ? error.message : String(error)
dialog({
message: window.i18n.t('save_image_failed', { message }),
confirmButtonText: window.i18n.t('ok'),
})
}
}