Skip to content
Merged
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
4 changes: 2 additions & 2 deletions .github/workflows/opencode.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
model: ${{ vars.OPENCODE_MODEL }}
prompt: |
你的名字是「${{ vars.OPENCODE_NAME }}」,在回复开头用这个名字自我介绍。

请根据当前的 issue 或 PR 内容,分析问题并给出具体的解决方案或建议。
如果是 bug 报告,请分析可能的原因并提供修复思路。
如果是功能请求,请评估可行性并给出实现建议。
Expand All @@ -53,7 +53,7 @@ jobs:
model: ${{ vars.OPENCODE_MODEL }}
prompt: |
你的名字是「${{ vars.OPENCODE_NAME }}」,在回复开头用这个名字自我介绍。

请根据当前的 issue 或 PR 内容,分析问题并给出具体的解决方案或建议。
如果是 bug 报告,请分析可能的原因并提供修复思路。
如果是功能请求,请评估可行性并给出实现建议。
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
- 🔧 修复 **跳转后 seek 位置丢失回到 0:00**
- 🔧 修复 **自动切下一首进度条跳到非 0:00 错误位置**
- 🎚️ 新增 **全局歌词偏移设置**(PR #11)

---

## v3.0.0-rc.2
Expand Down
34 changes: 17 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,23 +38,23 @@

## ✨ 特性一览

| 分类 | 描述 |
| ----------------- | ------------------------------------------------------------------------------------- |
| 🎨 **双端 UI** | 手机 / 平板自适应布局,沉浸式全屏,状态栏可切换 |
| 📐 **手机端布局** | 全宽贴底底栏 + 圆角浮岛播放栏,底栏选中态滑动指示器;列表 / 卡片 / 设置项轻度紧凑 |
| 🔍 **页面缩放** | 50%–150% 全局缩放,缩小后等效视口变宽,达到阈值可切换 Pad 布局(尚未完全适配) |
| 🎵 **播放引擎** | 原生 ExoPlayer + WebView 双引擎,长时间后台稳定,seek / gapless 切歌进度条同步 |
| 📝 **逐字歌词** | 毫秒级插值高亮,翻译 / 罗马音,拖动吸附最近行,支持全局歌词偏移 |
| 📂 **本地音乐** | 自动扫描本地歌曲,支持 TTML / LRC 歌词匹配(同目录 / 独立歌词目录),与桌面端行为对齐 |
| ☁️ **WebDAV 音乐** | 通过 WebDAV 连接远程音乐,在线播放与浏览,扩展私人曲库 |
| 🪟 **桌面歌词** | `WindowManager` 悬浮窗,逐字动画、锁定穿透、拖拽、播控 |
| 🔔 **通知栏** | 原生 `MediaSession`,完整播控,支持桌面歌词一键开关 |
| 🎚️ **精细控制** | 渐入渐出、进度吸附歌词、允许与其他应用同时播放 |
| 🌐 **在线音乐** | 网易云 + Jellyfin / Navidrome / Emby / Subsonic / OpenSubsonic / Last.fm |
| 🔗 **网络代理** | 支持配置 HTTP/HTTPS 代理,内置逆向 API 请求可走代理访问网易云接口 |
| ⬇️ **音乐下载** | 开发者模式下可下载歌曲至 SAF 授权目录,支持自定义子目录分类、歌词/ASS 附件下载 |
| 🧩 **内置 API** | `nodejs-mobile-cordova` 嵌入网易云 API,离线可用 |
| 📦 **分架构打包** | `arm64-v8a` / `armeabi-v7a` / `x86_64` / `x86` 独立 APK |
| 分类 | 描述 |
| ------------------ | ------------------------------------------------------------------------------------- |
| 🎨 **双端 UI** | 手机 / 平板自适应布局,沉浸式全屏,状态栏可切换 |
| 📐 **手机端布局** | 全宽贴底底栏 + 圆角浮岛播放栏,底栏选中态滑动指示器;列表 / 卡片 / 设置项轻度紧凑 |
| 🔍 **页面缩放** | 50%–150% 全局缩放,缩小后等效视口变宽,达到阈值可切换 Pad 布局(尚未完全适配) |
| 🎵 **播放引擎** | 原生 ExoPlayer + WebView 双引擎,长时间后台稳定,seek / gapless 切歌进度条同步 |
| 📝 **逐字歌词** | 毫秒级插值高亮,翻译 / 罗马音,拖动吸附最近行,支持全局歌词偏移 |
| 📂 **本地音乐** | 自动扫描本地歌曲,支持 TTML / LRC 歌词匹配(同目录 / 独立歌词目录),与桌面端行为对齐 |
| ☁️ **WebDAV 音乐** | 通过 WebDAV 连接远程音乐,在线播放与浏览,扩展私人曲库 |
| 🪟 **桌面歌词** | `WindowManager` 悬浮窗,逐字动画、锁定穿透、拖拽、播控 |
| 🔔 **通知栏** | 原生 `MediaSession`,完整播控,支持桌面歌词一键开关 |
| 🎚️ **精细控制** | 渐入渐出、进度吸附歌词、允许与其他应用同时播放 |
| 🌐 **在线音乐** | 网易云 + Jellyfin / Navidrome / Emby / Subsonic / OpenSubsonic / Last.fm |
| 🔗 **网络代理** | 支持配置 HTTP/HTTPS 代理,内置逆向 API 请求可走代理访问网易云接口 |
| ⬇️ **音乐下载** | 开发者模式下可下载歌曲至 SAF 授权目录,支持自定义子目录分类、歌词/ASS 附件下载 |
| 🧩 **内置 API** | `nodejs-mobile-cordova` 嵌入网易云 API,离线可用 |
| 📦 **分架构打包** | `arm64-v8a` / `armeabi-v7a` / `x86_64` / `x86` 独立 APK |

---

Expand Down
58 changes: 28 additions & 30 deletions src/api/streaming/webdav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,22 @@ const tagCacheKey = (config: StreamingServerConfig, path: string) => `${config.i
// ============ 公共辅助 ============

const AUDIO_EXTS = new Set([
"mp3", "flac", "wav", "ogg", "oga", "m4a", "aac", "ape", "wma", "opus", "alac",
"dsf", "dff", "dsd", "aiff", "aif",
"mp3",
"flac",
"wav",
"ogg",
"oga",
"m4a",
"aac",
"ape",
"wma",
"opus",
"alac",
"dsf",
"dff",
"dsd",
"aiff",
"aif",
]);

const isAudioFile = (name: string): boolean => {
Expand Down Expand Up @@ -233,11 +247,7 @@ const parsePropfindXml = (xml: string, requestedPath: string): WebDavEntry[] =>
prop.getElementsByTagNameNS(NS, "displayname")[0]?.textContent?.trim() || "";

// 计算文件名:优先 displayname,否则 href 末段
const fallbackName = decodedHref
.replace(/\/+$/, "")
.split("/")
.filter(Boolean)
.pop() || "";
const fallbackName = decodedHref.replace(/\/+$/, "").split("/").filter(Boolean).pop() || "";

entries.push({
path: decodedHref,
Expand Down Expand Up @@ -265,11 +275,7 @@ const fetchTagsForSong = async (
): Promise<CachedTag | null> => {
const cacheKey = tagCacheKey(config, entry.path);
const cached = tagCache.get(cacheKey);
if (
cached &&
cached.size === entry.size &&
cached.lastModified === entry.lastModified
) {
if (cached && cached.size === entry.size && cached.lastModified === entry.lastModified) {
return cached;
}

Expand Down Expand Up @@ -306,10 +312,7 @@ const fetchTagsForSong = async (
let binary = "";
const chunk = 0x8000;
for (let i = 0; i < bytes.length; i += chunk) {
binary += String.fromCharCode.apply(
null,
Array.from(bytes.subarray(i, i + chunk)),
);
binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
}
coverDataUrl = `data:${pic.format};base64,${btoa(binary)}`;
}
Expand Down Expand Up @@ -531,9 +534,7 @@ export const getRandomSongs = async (
};

/** 按标签聚合艺术家 */
export const getArtists = async (
config: StreamingServerConfig,
): Promise<StreamingArtistType[]> => {
export const getArtists = async (config: StreamingServerConfig): Promise<StreamingArtistType[]> => {
const all = await getOrBuildIndex(config);
const map = new Map<string, { name: string; cover: string; albumNames: Set<string> }>();
for (const song of all) {
Expand Down Expand Up @@ -566,9 +567,7 @@ export const getArtists = async (
};

/** 按标签聚合专辑 */
export const getAlbums = async (
config: StreamingServerConfig,
): Promise<StreamingAlbumType[]> => {
export const getAlbums = async (config: StreamingServerConfig): Promise<StreamingAlbumType[]> => {
const all = await getOrBuildIndex(config);
const map = new Map<
string,
Expand Down Expand Up @@ -717,12 +716,13 @@ export const search = async (
typeof s.artists === "string"
? s.artists.toLowerCase()
: Array.isArray(s.artists)
? s.artists.map((a) => a.name).join(" ").toLowerCase()
? s.artists
.map((a) => a.name)
.join(" ")
.toLowerCase()
: "";
const album =
typeof s.album === "string"
? s.album.toLowerCase()
: (s.album?.name || "").toLowerCase();
typeof s.album === "string" ? s.album.toLowerCase() : (s.album?.name || "").toLowerCase();
return name.includes(q) || artist.includes(q) || album.includes(q);
});

Expand All @@ -747,10 +747,8 @@ export const search = async (
};

/** WebDAV 没有歌词接口,留空实现保持接口一致 */
export const getLyrics = async (
_config: StreamingServerConfig,
_songId: string,
): Promise<string> => "";
export const getLyrics = async (_config: StreamingServerConfig, _songId: string): Promise<string> =>
"";

export default {
ping,
Expand Down
12 changes: 6 additions & 6 deletions src/components/Modal/Setting/StreamingServerConfig.vue
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@
/>
</n-form-item>

<n-alert v-if="isWebDav && serverForm.webdavAuth === 'digest'" type="warning" :show-icon="true">
<n-alert
v-if="isWebDav && serverForm.webdavAuth === 'digest'"
type="warning"
:show-icon="true"
>
Digest Auth 当前不支持。建议改用 Basic Auth;如果服务器强制 Digest,请先在服务器侧切换。
</n-alert>
<n-alert v-if="isWebDav" type="info" :show-icon="true" style="margin-top: 8px">
Expand All @@ -68,11 +72,7 @@
</template>

<script setup lang="ts">
import type {
StreamingServerConfig,
StreamingServerType,
WebDavAuthType,
} from "@/types/streaming";
import type { StreamingServerConfig, StreamingServerType, WebDavAuthType } from "@/types/streaming";
import type { FormInst, FormRules } from "naive-ui";

const props = defineProps<{
Expand Down
9 changes: 2 additions & 7 deletions src/components/Player/FullPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,11 +45,7 @@
@mousemove="playerMove"
>
<!-- 左侧封面和数据:内部组件自己负责歌曲信息切换的过渡,外层不再用 :key=playSong.id 的 zoom 动画 -->
<div
v-if="showLeftContent"
class="content-left"
:style="layoutStyles.left"
>
<div v-if="showLeftContent" class="content-left" :style="layoutStyles.left">
<PlayerCover />
<PlayerData :center="playerDataCenter" />
</div>
Expand Down Expand Up @@ -110,8 +106,7 @@ const { isPhonePortrait } = useDevice();
const useCompactMobilePlayer = computed(() => isPhonePortrait.value);

// 移动端卡片化进出场动画:兼容拖拽中的 inline transform,从当前位置平滑过渡
const MOBILE_CARD_ENTER =
"transform 0.36s cubic-bezier(0.22, 1, 0.36, 1)";
const MOBILE_CARD_ENTER = "transform 0.36s cubic-bezier(0.22, 1, 0.36, 1)";
const MOBILE_CARD_LEAVE = "transform 0.32s cubic-bezier(0.4, 0, 1, 1)";

const onMobileEnter = (el: Element, done: () => void) => {
Expand Down
18 changes: 17 additions & 1 deletion src/components/Player/FullPlayerMobile.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<template>
<div ref="mobileStart" class="full-player-mobile">
<div
ref="mobileStart"
class="full-player-mobile"
:style="{ '--lyric-h-offset': lyricHeaderHorizontalPadding }"
>
<div ref="topBarRef" class="top-bar">
<div class="btn" @click.stop="statusStore.showFullPlayer = false">
<SvgIcon name="Down" :size="26" />
Expand Down Expand Up @@ -174,11 +178,21 @@ const dataStore = useDataStore();
const player = usePlayerController();
const { timeDisplay, toggleTimeFormat } = useTimeFormat();

const LYRIC_HEADER_MAX_PADDING = 60;
const AMLL_LINE_PADDING_MOBILE = 20;

const mobileStart = ref<HTMLElement | null>(null);
const topBarRef = ref<HTMLElement | null>(null);
const dragHandleRef = ref<HTMLElement | null>(null);
const pageIndex = ref(0);

const lyricHeaderHorizontalPadding = computed(() => {
const padding =
Math.max(0, settingStore.lyricHorizontalOffset) +
(settingStore.useAMLyrics ? AMLL_LINE_PADDING_MOBILE : 0);
return `${Math.min(padding, LYRIC_HEADER_MAX_PADDING)}px`;
});

// 下拉关闭手势捕获区高度:信息页仅覆盖顶栏 + 封面上半段,避免遮挡下方按钮
// 歌词页含顶栏 + 歌曲信息条
const dragHandleHeight = computed(() =>
Expand Down Expand Up @@ -673,6 +687,8 @@ const contentTransform = computed(() => {
margin-bottom: 20px;
flex-shrink: 0;
padding-top: 8px;
padding-left: var(--lyric-h-offset, 0px);
padding-right: var(--lyric-h-offset, 0px);
// 喜欢按钮位于下拉手势区上方,需要抬高层级保持可点击
.action-btn {
position: relative;
Expand Down
6 changes: 2 additions & 4 deletions src/components/Player/MainPlayer.vue
Original file line number Diff line number Diff line change
Expand Up @@ -402,8 +402,7 @@ const finishDragOpen = (dy: number) => {
}
if (shouldOpen) {
if (dragOpenParent) {
dragOpenParent.style.transition =
"transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)";
dragOpenParent.style.transition = "transform 0.28s cubic-bezier(0.22, 1, 0.36, 1)";
dragOpenParent.style.transform = "";
}
if (dragOpenMain) {
Expand All @@ -418,8 +417,7 @@ const finishDragOpen = (dy: number) => {
}, 320);
} else {
if (dragOpenParent) {
dragOpenParent.style.transition =
"transform 0.24s cubic-bezier(0.4, 0, 1, 1)";
dragOpenParent.style.transition = "transform 0.24s cubic-bezier(0.4, 0, 1, 1)";
dragOpenParent.style.transform = `translate3d(0, ${dragStartTop}px, 0) scale(0.92)`;
}
if (dragOpenMain) {
Expand Down
7 changes: 4 additions & 3 deletions src/components/Player/PlayerLyric/AMLyric.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
'--amll-lyric-right-padding': settingStore.lyricAlignRight
? `${settingStore.lyricHorizontalOffset}px`
: '',
'--amll-lyric-horizontal-padding': `${settingStore.lyricHorizontalOffset}px`,
}"
>
<div v-if="statusStore.lyricLoading" class="lyric-loading">歌词正在加载中...</div>
Expand Down Expand Up @@ -199,8 +200,8 @@ watch(lyricPlayerRef, (player) => {
margin-left: 0;
.amll-lyric-player {
> div {
padding-left: 20px;
padding-right: 20px;
padding-left: var(--amll-lyric-horizontal-padding, 20px);
padding-right: var(--amll-lyric-horizontal-padding, 20px);
}
}
}
Expand All @@ -213,7 +214,7 @@ watch(lyricPlayerRef, (player) => {

@media (max-width: 990px) {
padding: 0;
margin-right: -20px;
margin-right: calc(0px - var(--amll-lyric-horizontal-padding, 20px));
}
@media (max-width: 500px) {
margin-right: 0;
Expand Down
4 changes: 2 additions & 2 deletions src/components/Setting/config/lyric.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,9 @@ export const useLyricSettings = (): SettingConfig => {
},
{
key: "lyricHorizontalOffset",
label: "歌词左侧边距",
label: "歌词两侧边距",
type: "slider",
description: "调整全屏模式下歌词的起始位置",
description: "调整全屏模式下歌词两侧的水平边距",
min: 0,
max: 200,
step: 1,
Expand Down
12 changes: 7 additions & 5 deletions src/core/player/PlayerController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -838,10 +838,10 @@ class PlayerController {
return;
}
if (!matchedById) {
console.warn(
"[Android] applyNativeAutoNext: songId 未匹配到列表,已通过下一索引兜底",
{ songId, fallbackId: nextSong.id },
);
console.warn("[Android] applyNativeAutoNext: songId 未匹配到列表,已通过下一索引兜底", {
songId,
fallbackId: nextSong.id,
});
}

if (typeof liked === "boolean" && typeof nextSong.id === "number") {
Expand Down Expand Up @@ -1381,7 +1381,9 @@ class PlayerController {
knownDuration > 0 ? Math.max(0, Math.min(time, knownDuration)) : Math.max(0, time);
// 调试:追踪 seek-to-zero
if (safeTime < 100 && statusStore.currentTime > 3000) {
console.warn(`[PC] setSeek BLOCKED: time=${time} safeTime=${safeTime} current=${statusStore.currentTime} stack=${new Error().stack?.split('\n').slice(1,4).join(' <- ')}`);
console.warn(
`[PC] setSeek BLOCKED: time=${time} safeTime=${safeTime} current=${statusStore.currentTime} stack=${new Error().stack?.split("\n").slice(1, 4).join(" <- ")}`,
);
return;
}
this.lastSeekTimestamp = Date.now();
Expand Down