diff --git a/electron/main/apis/kugou/core/song.ts b/electron/main/apis/kugou/core/song.ts index 6fcada5d1..d6631c470 100644 --- a/electron/main/apis/kugou/core/song.ts +++ b/electron/main/apis/kugou/core/song.ts @@ -36,6 +36,8 @@ export interface KGResourceSong { audio_info?: KGAudioInfo; authors?: Array<{ author_id?: string | number; author_name?: string }>; album_info?: { album_name?: string; cover?: string }; + /** 专辑内碟片信息,disc 从 1 开始 */ + extend?: { disc?: number; cd_name?: string }; trans_param?: { union_cover?: string }; deprecated?: { pay_type?: number; @@ -112,6 +114,8 @@ export const normalizeKGSong = (raw: KGResourceSong, defaults: SongDefaults = {} artists, album: decodeName(raw.album_info?.album_name || defaults.albumName), albumId: Number(base.album_id) > 0 ? String(base.album_id) : defaults.albumId || "", + disc: raw.extend?.disc || undefined, + discTitle: decodeName(raw.extend?.cd_name) || undefined, cover: fillCover(cover, 300), coverOriginal: fillCover(cover, 480), interval: duration / 1000, diff --git a/electron/main/apis/qqmusic/core/song.ts b/electron/main/apis/qqmusic/core/song.ts index 5eb5ee10f..2d05d47ca 100644 --- a/electron/main/apis/qqmusic/core/song.ts +++ b/electron/main/apis/qqmusic/core/song.ts @@ -10,6 +10,8 @@ export interface QMAudioInfo { interval?: number; singer?: Array<{ id?: number; mid?: string; name?: string }>; album?: { mid?: string; name?: string; title?: string; pmid?: string }; + /** 碟片索引,从 0 开始 */ + index_cd?: number; file?: { media_mid?: string; size_128mp3?: number; @@ -46,6 +48,7 @@ export const normalizeQMSong = (song: QMAudioInfo, albumMid = ""): QMSong => { artists: song.singer ?? [], album: song.album?.name || song.album?.title || "", albumMid: mid, + disc: song.index_cd !== undefined ? song.index_cd + 1 : undefined, duration: (song.interval ?? 0) * 1000, mediaMid: song.file?.media_mid ?? "", pay: { diff --git a/electron/main/services/streaming/adapters/jellyfin.ts b/electron/main/services/streaming/adapters/jellyfin.ts index 00c3c7860..221b65761 100644 --- a/electron/main/services/streaming/adapters/jellyfin.ts +++ b/electron/main/services/streaming/adapters/jellyfin.ts @@ -30,6 +30,8 @@ interface JellyItem { Artists?: string[]; ArtistItems?: { Id: string; Name: string }[]; RunTimeTicks?: number; + /** 碟号 */ + ParentIndexNumber?: number; ProductionYear?: number; ChildCount?: number; ImageTags?: { Primary?: string }; @@ -225,6 +227,7 @@ const toTrack = (config: StreamingRuntimeConfig, item: JellyItem): Track => { item.Artists?.map((name) => ({ name })) ?? [], album: item.Album ? { id: item.AlbumId, name: item.Album, serverId: config.id } : undefined, + disc: item.ParentIndexNumber || undefined, duration: item.RunTimeTicks ? Math.floor(item.RunTimeTicks / 10_000) : 0, cover: coverId ? imageUrl(config, coverId, imageTag, 300) : undefined, coverOriginal: coverId ? imageUrl(config, coverId, imageTag, 1500) : undefined, diff --git a/electron/main/services/streaming/adapters/subsonic.ts b/electron/main/services/streaming/adapters/subsonic.ts index b8b3826f2..f5283d805 100644 --- a/electron/main/services/streaming/adapters/subsonic.ts +++ b/electron/main/services/streaming/adapters/subsonic.ts @@ -20,6 +20,7 @@ interface SubsonicSong { artistId?: string; album?: string; albumId?: string; + discNumber?: number; duration?: number; bitRate?: number; samplingRate?: number; @@ -40,6 +41,8 @@ interface SubsonicAlbum { songCount?: number; year?: number; displayArtist?: string; + /** OpenSubsonic 扩展的碟片名称 */ + discTitles?: { disc: number; title: string }[]; song?: SubsonicSong[]; } @@ -174,6 +177,7 @@ const toTrack = (config: StreamingRuntimeConfig, song: SubsonicSong): Track => { title: song.title || "", artists, album: song.album ? { id: song.albumId, name: song.album, serverId: config.id } : undefined, + disc: song.discNumber || undefined, duration: Math.round((song.duration ?? 0) * 1000), cover: coverUrl(config, song.coverArt, 300), coverOriginal: coverUrl(config, song.coverArt, 1500), @@ -435,7 +439,14 @@ export const subsonicAdapter: StreamingAdapter = { */ async getAlbumSongs(config, albumId) { const result = await callApi<{ album?: SubsonicAlbum }>(config, "getAlbum", { id: albumId }); - return (result.album?.song ?? []).map((song) => toTrack(config, song)); + const discTitles = new Map( + result.album?.discTitles?.map((disc) => [disc.disc, disc.title.trim()]), + ); + return (result.album?.song ?? []).map((song) => { + const track = toTrack(config, song); + const discTitle = track.disc && discTitles.get(track.disc); + return discTitle ? { ...track, discTitle } : track; + }); }, /** diff --git a/shared/types/platform/kugou.ts b/shared/types/platform/kugou.ts index 9c8d6e838..8c79770b1 100644 --- a/shared/types/platform/kugou.ts +++ b/shared/types/platform/kugou.ts @@ -12,6 +12,10 @@ export interface KGSong { artists?: Array<{ id?: string | number; name: string }>; album: string; albumId: string | number; + /** 碟号,从 1 开始 */ + disc?: number; + /** 碟片名称 */ + discTitle?: string; cover?: string; coverOriginal?: string; interval: number; diff --git a/shared/types/platform/qqmusic.ts b/shared/types/platform/qqmusic.ts index 996697dc1..9938d7582 100644 --- a/shared/types/platform/qqmusic.ts +++ b/shared/types/platform/qqmusic.ts @@ -8,6 +8,8 @@ export interface QMSong { artists?: Array<{ mid?: string; name?: string }>; album?: string; albumMid?: string; + /** 碟号,从 1 开始 */ + disc?: number; cover?: string; coverOriginal?: string; /** 歌曲时长,单位为毫秒 */ diff --git a/shared/types/player.ts b/shared/types/player.ts index 93a93aff2..d33cfcab7 100644 --- a/shared/types/player.ts +++ b/shared/types/player.ts @@ -128,6 +128,10 @@ export interface Track { artists: Artist[]; /** 专辑 */ album?: Album; + /** 碟号,从 1 开始 */ + disc?: number; + /** 碟片名称 */ + discTitle?: string; /** 曲目编号 */ track?: number; /** 时长(毫秒) */ diff --git a/src/components/list/SongList.vue b/src/components/list/SongList.vue index c4c14b1cb..1d6ea2f93 100644 --- a/src/components/list/SongList.vue +++ b/src/components/list/SongList.vue @@ -202,8 +202,37 @@ const filteredItems = computed(() => { }); }); +/** 多碟专辑的碟片与碟内曲序,基于完整专辑计算,搜索过滤时保持不变 */ +const albumDiscs = computed(() => { + if (props.collectionType !== "album") return null; + const groups = Map.groupBy(props.items, (track) => track.disc ?? 1); + if (groups.size < 2) return null; + const trackNumbers = new Map(); + for (const tracks of groups.values()) { + tracks.forEach((track, position) => trackNumbers.set(track.id, position + 1)); + } + return { groups, trackNumbers }; +}); + +/** 默认顺序下按碟号排列的列表,碟片标题附在每碟首曲上以保持播放与多选索引一致 */ +const discLayout = computed(() => { + const discs = albumDiscs.value; + if (!discs || (props.enableSort && sortField.value !== "none")) return null; + const items: Track[] = []; + const headers = new Map(); + const visible = Map.groupBy(filteredItems.value, (track) => track.disc ?? 1); + for (const [disc, tracks] of [...visible].sort(([left], [right]) => left - right)) { + const albumTracks = discs.groups.get(disc) ?? tracks; + const title = albumTracks.find((track) => track.discTitle)?.discTitle; + headers.set(items.length, { disc, title, count: albumTracks.length }); + items.push(...tracks); + } + return { items, headers }; +}); + /** 排序后列表 */ const sortedItems = computed(() => { + if (discLayout.value) return discLayout.value.items; if (!props.enableSort || sortField.value === "none") return filteredItems.value; const result = [...filteredItems.value]; const field = sortField.value; @@ -273,6 +302,21 @@ const { deleteConfirmOpen, deleteDialogTitle, deleteDialogContent } = batch; /** 是否使用紧凑列表样式 */ const isCompactList = computed(() => settings.appearance.songListStyle === "compact"); +/** 歌曲行高度 */ +const rowHeight = computed(() => (isCompactList.value ? 56 : 88)); + +/** 碟片标题行高度 */ +const DISC_HEADER_HEIGHT = 40; + +/** 分碟时每行的精确高度,碟片首曲额外包含标题行 */ +const discItemHeights = computed(() => { + const layout = discLayout.value; + if (!layout) return undefined; + return layout.items.map( + (_track, index) => rowHeight.value + (layout.headers.has(index) ? DISC_HEADER_HEIGHT : 0), + ); +}); + /** * 计算歌曲行样式 * @param item - 歌曲数据 @@ -418,10 +462,11 @@ defineExpose({