import { VideoItem } from '../viewmodel/VideoItem'; import { LengthMetrics, SegmentButton, SegmentButtonItemTuple, SegmentButtonOptions, SymbolGlyphModifier } from '@kit.ArkUI'; import { PreferencesUtil, ToastUtil, StrUtil } from '@pura/harmony-utils'; import { ButtonFancyModifier, MenuModifier, SymbolGlyphFancyModifier, ShadowModifier } from '../common/util/AttributeModifierUtil'; import { CommonConstants } from '../common/constants/CommonConstants'; import { WebDavAccount } from '../viewmodel/WebDavAccount'; import Logger from '../common/util/Logger'; import { RemoteDriveType } from '../common/enums/RemoteDriveType'; import { navidromeRestApi, NavidromeRestAlbum, NavidromeRestArtist, NavidromeRestSong, NavidromeRestPlaylist } from '../common/network/NavidromeRestApi'; import { resolveAudioQualityTag, Utility } from '../common/util/Utility'; import { Constants } from '../Constants'; import { EventConstants } from '../common/constants/EventConstants'; import { emitter } from '@kit.BasicServicesKit'; import { deviceInfo } from '@kit.BasicServicesKit'; import { setNavidromePlaylist, appendToNavidromePlaylist, setNavidromeTotalCount } from '../common/util/NavidromePlaylistStore'; import { registerLoadMoreCallback, unregisterLoadMoreCallback } from '../common/util/NavidromeRandomLoader'; import { navidromeApi, NavidromeSong } from '../common/network/NavidromeApi'; import { ServerLogUtil } from '../common/util/ServerLogUtil'; import { RemoteDriveManager } from '../common/util/RemoteDriveManager'; import { SettingPage } from '../pages/SettingPage'; import { jellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../common/network/JellyfinApi'; import { embyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../common/network/EmbyApi'; import { audioStationApi, AudioStationAlbum, AudioStationArtist, AudioStationPlaylist, AudioStationSong } from '../common/network/AudioStationApi'; import { plexApi, PlexAlbum, PlexArtist, PlexPlaylist, PlexSong } from '../common/network/PlexApi'; import { daoLiYuApi, DaoLiYuAlbum, DaoLiYuArtist, DaoLiYuTrack, DaoLiYuPlaylist } from '../common/network/DaoLiYuApi'; import { getRemoteDriveDisplayLabel } from '../common/util/RemoteDriveLabel'; import NavidromeListCache, { AllCacheData } from '../common/util/NavidromeListCache'; import { LazyDataSource } from '../common/util/LazyDataSource'; import { taskpool } from '@kit.ArkTS'; import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil'; import { PlayingIndicator } from './PlayingIndicator'; import { hdsEffect } from '@kit.UIDesignKit'; import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore'; const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist'; const NAVIDROME_SEARCH_LIMIT = 500; const ITEM_HEIGHT_SMALL: number = 48; // 列表项小高度 const ITEM_HEIGHT: number = 65; // 列表项中高度 const ITEM_HEIGHT_BIG: number = 78; // 列表项大高度 enum NavFilterType { None = 0, Artist = 1, Album = 2, Playlist = 3 } interface CachePreviewInfo { cacheRoot: string; examplePath: string; } interface LibraryInfo { type: number; scheme: string; } class AudioStationAlbumKey { name: string; artist: string; constructor(name: string, artist: string) { this.name = name; this.artist = artist; } } function parseAudioMetricValue(value?: string): number | undefined { if (!value || value.trim().length === 0) { return undefined; } const normalized = value.trim().toLowerCase(); const directValue = Number(normalized); if (Number.isFinite(directValue) && directValue > 0) { return directValue; } const numericValue = parseFloat(normalized); if (!Number.isFinite(numericValue) || numericValue <= 0) { return undefined; } if (normalized.includes('kbps') || normalized.includes('khz')) { return numericValue * 1000; } return numericValue; } function buildRemoteSongFileName(song: NavidromeRestSong): string { const title = song.title ?? Constants.UNKNOWN_TITLE; return `${title}${song.suffix ? '.' + song.suffix : ''}`; } function extractAudioExtension(fileName: string): string { if (!fileName || fileName.length === 0) { return ''; } const lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex < 0 || lastDotIndex >= fileName.length - 1) { return ''; } return fileName.substring(lastDotIndex); } function resolveInitialRemoteSongQuality(formatHint: string, fallbackFileName: string, bitrate?: number, sampleRate?: number): string { const directQuality = resolveAudioQualityTag(formatHint, bitrate, sampleRate); if (directQuality.length > 0) { return directQuality; } const fallbackExtension = extractAudioExtension(fallbackFileName); if (fallbackExtension.length === 0) { return ''; } return resolveAudioQualityTag(fallbackExtension, bitrate, sampleRate); } function applyInitialRemoteSongQuality(videoItem: VideoItem, song: NavidromeRestSong, fallbackFileName: string): void { const quality = resolveInitialRemoteSongQuality(song.suffix ?? song.contentType ?? '', fallbackFileName, song.bitRate, song.sampleRate); if (quality.length > 0) { videoItem.md5Str = quality; } if (song.sampleRate !== undefined && song.sampleRate !== null && song.sampleRate > 0) { videoItem.sampleRate = song.sampleRate.toString(); } } function applyInitialQualityToExistingVideoItem(videoItem: VideoItem): void { if (!videoItem || (videoItem.md5Str && videoItem.md5Str.length > 0)) { return; } const fallbackFileName = videoItem.fileName ?? videoItem.name ?? ''; const bitrate = parseAudioMetricValue(videoItem.bit_rate); const sampleRate = parseAudioMetricValue(videoItem.sampleRate); const quality = resolveInitialRemoteSongQuality(videoItem.mimeType ?? '', fallbackFileName, bitrate, sampleRate); if (quality.length > 0) { videoItem.md5Str = quality; } } function buildSongQualityLabelText(quality?: string): string { if (!quality || quality.length === 0) { return ''; } return quality.includes('Lossless') ? '无损' : quality; } /** * taskpool 任务结果接口 * 返回带封面的完整数据 */ interface TaskResult { songs: VideoItem[]; artists: NavidromeRestArtist[]; albums: NavidromeRestAlbum[]; playlists: NavidromeRestPlaylist[]; } /** * Navidrome API 分页响应接口 */ interface PagedResponse { data: T[]; nextStart: number | null; } /** * Account 序列化数据接口 (用于 taskpool 传递) */ interface AccountData { id: number; webType: number; host: string; port: number; account: string; password: string; enableHttps: boolean; navidromeBasePath: string; jellyfinBasePath: string; embyBasePath: string; name: string; } @Component export struct RemoteMusicPage { private gridScroller: Scroller = new Scroller() private waterScroller: Scroller = new Scroller() @State isGridMusic: boolean = false //是否网格布局 @State twoFingerType: number = 3 //双指放大缩小的类型 (1=List小, 2=List中, 3=Grid大, 4=WaterFlow) @State scaleValue: number = 1 //缩放值 @State columns: number = 4 //瀑布流列数 @State isWaterFlowScrolling: boolean = false //WaterFlow是否正在滚动 @State refreshPullRatio: number = 1 @State isRefreshing: boolean = false @State maxRefreshingHeight: number = 100.0 private contentNode?: ComponentContent = undefined @State isShowTitleBar: boolean = true //是否显示分类导航条 private scroller: Scroller = new Scroller() private prevOffsetY: number = 0; // 记录上一次的Y轴偏移量 @State autoHideTitle: boolean = true //滚动自动隐藏标题栏 @State isCustomizeBg: boolean = false //自定义背景界面 @StorageProp('isLandscape') isLandscape: boolean = false; @State blurValue: number = 0 //背景模糊 @State bgBrightness: number = 0 //背景亮度 @State customizeBgPath: string | undefined = ''; @State private pointLightOptions: hdsEffect.PointLightOptions | undefined = undefined @State private activePointLightItemKey: string = '' @Link mType: number; @Link offsetX: number; @Link isShowDrawer: boolean; @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0; @Prop @Watch('onSwitchAccount') selectedAccount: WebDavAccount; @StorageProp('currentTheme') currentTheme: number = 0; @State selectedTab: number = 0; // 0: 全部, 1: 艺术家, 2: 专辑 @State allVideos: VideoItem[] = []; @State artists: NavidromeRestArtist[] = []; @State albums: NavidromeRestAlbum[] = []; @State playlists: NavidromeRestPlaylist[] = []; // LazyForEach 数据源实例 private songDataSource: LazyDataSource = new LazyDataSource([]); private artistDataSource: LazyDataSource = new LazyDataSource([]); private albumDataSource: LazyDataSource = new LazyDataSource([]); private playlistDataSource: LazyDataSource = new LazyDataSource([]); @State loading: boolean = false; @State songNextStart: number | null = 0; @State artistNextStart: number | null = 0; @State albumNextStart: number | null = 0; @State playlistNextStart: number | null = 0; @State isSongPageLoading: boolean = false; @State isArtistPageLoading: boolean = false; @State isAlbumPageLoading: boolean = false; @State isPlaylistPageLoading: boolean = false; @State serverTotalSongCount: number = 0; // 服务端返回的歌曲总数 // 新增:详情视图状态 @Link isDetailView: boolean; // 是否在艺术家/专辑详情视图 @State previousTab: number = 0; // 进入详情视图前的标签页索引 @StorageProp('isDarkMode') isDarkMode: boolean = false; @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined; @StorageProp('topSafeHeight') topSafeHeight: number = 0; @State @Watch('onTabSelectedIndexesChanged') tabSelectedIndexes: number[] = [0]; @StorageProp('themeColor') themeColor: string = PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR); private tabs: string[] = ['全部', '艺术家', '专辑', '歌单']; private loadTicket: number = 0; @State filterType: NavFilterType = NavFilterType.None; @State filterLabel: string = ''; @State filterId: string = ''; @State filterSongs: VideoItem[] = []; @State isFilterLoading: boolean = false; private coverUrlCache: Map = new Map(); private albumCoverLookup: Map = new Map(); private searchTicket: number = 0; private readonly REMOTE_PAGE_SIZE: number = 200; private jellyfinSongIdSeen: Set = new Set(); private embySongIdSeen: Set = new Set(); private jellyfinSongKeySeen: Set = new Set(); private embySongKeySeen: Set = new Set(); private listCache = NavidromeListCache.getInstance(); private isNavidromeAccount(account: WebDavAccount): boolean { return account.webType === RemoteDriveType.Navidrome; } private isJellyfinAccount(account: WebDavAccount): boolean { return account.webType === RemoteDriveType.Jellyfin; } private isEmbyAccount(account: WebDavAccount): boolean { return account.webType === RemoteDriveType.Emby; } private isAudioStationAccount(account: WebDavAccount): boolean { return account.webType === RemoteDriveType.AudioStation; } private isPlexAccount(account: WebDavAccount): boolean { return account.webType === RemoteDriveType.Plex; } private isDaoLiYuAccount(account: WebDavAccount): boolean { return account.webType === RemoteDriveType.DaoLiYu; } private createTabOptions(): SegmentButtonOptions { const buttons = [ { text: '全部' }, { text: '艺术家' }, { text: '专辑' }, { text: '歌单' } ] as SegmentButtonItemTuple; return SegmentButtonOptions.capsule({ buttons, direction: Direction.Ltr, buttonPadding: { top: 10, bottom: 10 }, backgroundColor: $r('app.color.index_background'), selectedBackgroundColor: $r('app.color.start_window_background'), selectedFontColor: $r('app.color.text_color'), fontSize: 14, selectedFontSize: 15 }); } private getCachePreviewInfo(account: WebDavAccount): CachePreviewInfo | null { try { const manager = RemoteDriveManager.getInstance(); const context = manager.context; if (!context) { return null; } const baseDir = context.filesDir ?? context.cacheDir; if (!baseDir) { return null; } const accountId = account.id?.toString() || 'default'; const cacheRoot = `${baseDir}/remote_cache/webdav/${accountId}`; const examplePath = `${cacheRoot}/navidrome_preview.cache`; return { cacheRoot, examplePath }; } catch (error) { Logger.error('RemoteMusicPage', `获取缓存预览路径失败: ${(error as Error).message}`); return null; } } // 搜索和排序相关状态 @State isSearchMode: boolean = false; @State searchText: string = ''; // 用户输入内容 @State searchHistoryItems: string[] = []; private readonly searchHistoryScope: string = 'remote_music'; @State filteredList: Array = []; // 过滤后的歌曲结果 @State sortType: number = 0; // 排序类型 0:名称升序 1:名称降序 2:时间升序 3:时间降序 4:大小升序 5:大小降序 @State isSearchLoading: boolean = false; private readonly REMOTE_SEARCH_LIMIT: number = 500; // SegmentButton选项 @State tabOptions: SegmentButtonOptions = this.createTabOptions(); //当胶囊按钮的选择发生变化时调用此函数 onTabSelectedIndexesChanged() { this.selectedTab = this.tabSelectedIndexes[0]; console.info('heanup', `Selected tab: ${this.tabs[this.selectedTab]}`); // 如果在详情视图模式下切换标签页,退出详情视图并清除筛选 if (this.isDetailView) { this.isDetailView = false; this.clearFilter(); } // 从艺术家或专辑切换回全部时,清除筛选状态 if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) { // 不清除筛选,保持筛选状态 } else if (this.selectedTab !== 0) { // 切换到艺术家或专辑标签页时,清除筛选和搜索状态 this.clearFilter(); this.isSearchMode = false; this.searchText = ''; this.filteredList = []; this.isSearchLoading = false; this.searchTicket++; } } //切换不同的RemoteMusicPage async onSwitchAccount() { await this.refreshNavidromeData(true); } initSetting(){ this.isCustomizeBg = PreferencesUtil.getBooleanSync(SettingPage.IS_CUSTOMIZE_BG, false) this.customizeBgPath = PreferencesUtil.getStringSync(SettingPage.IS_CUSTOMIZE_BG_PATH, '') this.blurValue = PreferencesUtil.getNumberSync(SettingPage.CUSTOMIZE_BG_BLUR, 0) this.bgBrightness = PreferencesUtil.getNumberSync(SettingPage.BG_BRIGHTNESS, 0) this.sortType = PreferencesUtil.getNumberSync('navidromeSortType', 0); this.isShowTitleBar = PreferencesUtil.getBooleanSync(SettingPage.IS_SHOW_TITLTBAR, true) this.autoHideTitle = PreferencesUtil.getBooleanSync('autoHideTitle', true) this.twoFingerType = PreferencesUtil.getNumberSync(SettingPage.TWO_FINGER_TYPE, 3) this.isGridMusic = PreferencesUtil.getBooleanSync(SettingPage.IS_GRID_MUSIC, true) } aboutToAppear() { this.initSetting() let eventSetting: emitter.InnerEvent = { eventId: 333 } // 监听广播事件(通用设置配置更新) emitter.on(eventSetting, (eventData: emitter.EventData) => { this.initSetting() }); // 监听手势返回事件 let eventBackSwipeBack: emitter.InnerEvent = { eventId: EventConstants.EVENT_SWIPE_BACK_NAVID } emitter.on(eventBackSwipeBack, (eventData: emitter.EventData) => { console.info('heanup', 'RemoteMusicPage 收到 EVENT_SWIPE_BACK_NAVID 事件'); // 如果在详情视图模式,退出详情视图 if (this.isDetailView) { this.getUIContext().animateTo({ duration: 555 }, () => { this.isDetailView = false; this.clearFilter(); }) } }); // 注册加载更多页回调(用于渐进式随机播放) this.registerRandomLoadCallback(); this.refreshNavidromeData(); } /** * 注册加载更多页回调函数(供LocalMusic的randomPlay调用) */ private registerRandomLoadCallback(): void { registerLoadMoreCallback( // 加载一页数据并返回新增歌曲 async (): Promise => { const account = this.resolveActiveAccount(); if (!account) { return []; } // 记录加载前的数量 const beforeCount = this.allVideos.length; // 根据账户类型调用对应的加载方法 await this.loadNextSongPageByAccountType(account); // 获取新增的歌曲 const newItems = this.allVideos.slice(beforeCount); // 同步到播放列表存储 if (newItems.length > 0) { appendToNavidromePlaylist(newItems); void ServerLogUtil.info('NavidromeRandom', `加载更多: 新增${newItems.length}首,总数${this.allVideos.length}`); } return newItems; }, // 检查是否还有更多数据 (): boolean => { return this.songNextStart !== null && !this.isSongPageLoading; } ); } /** * 根据账户类型调用对应的歌曲加载方法 */ private async loadNextSongPageByAccountType(account: WebDavAccount): Promise { if (this.isJellyfinAccount(account)) { await this.loadNextJellyfinSongPage(account); } else if (this.isEmbyAccount(account)) { await this.loadNextEmbySongPage(account); } else if (this.isAudioStationAccount(account)) { await this.loadNextAudioStationSongPage(account); } else if (this.isPlexAccount(account)) { await this.loadNextPlexSongPage(account); } else if (this.isDaoLiYuAccount(account)) { await this.loadNextDaoLiYuSongPage(account); } else { // 默认使用 Navidrome await this.loadNextSongPage(account); } } private async refreshNavidromeData(showToastWhenMissing: boolean = false): Promise { const account = this.resolveActiveAccount(); if (!account) { this.resetData(); if (showToastWhenMissing) { ToastUtil.showToast('请先选择媒体库账号'); } return; } // 记录日志开关状态 void ServerLogUtil.info('NavidromeLoad', `日志状态: ${ServerLogUtil.getLogUploadStatus()}`); // 记录账号信息和缓存路径 await this.logAccountCacheInfo(account); await this.loadMediaLibrary(account); this.doSortType(this.sortType) } private async handlePullRefresh(): Promise { try { await this.refreshNavidromeData(true) if (this.isDetailView && this.filterType !== NavFilterType.None) { await this.applyFilter(this.filterType, this.filterId, this.filterLabel) } if (this.isSearchMode && this.selectedTab === 0 && this.searchText.length > 0) { await this.onSearchInput(this.searchText) } } finally { this.isRefreshing = false } } private loadSearchHistory(): void { this.searchHistoryItems = SearchHistoryUtil.load(this.searchHistoryScope); } private saveSearchHistory(keyword: string): void { this.searchHistoryItems = SearchHistoryUtil.save(this.searchHistoryScope, keyword); } private commitSearchHistory(keyword: string): void { const normalized = keyword.trim(); if (normalized.length === 0) { return; } this.saveSearchHistory(normalized); } private applySearchHistory(keyword: string): void { this.searchText = keyword; void this.onSearchInput(keyword); } private resolveActiveAccount(): WebDavAccount | undefined { if (!this.selectedAccount) { return undefined; } if (!this.isNavidromeAccount(this.selectedAccount) && !this.isJellyfinAccount(this.selectedAccount) && !this.isEmbyAccount(this.selectedAccount) && !this.isAudioStationAccount(this.selectedAccount) && !this.isPlexAccount(this.selectedAccount) && !this.isDaoLiYuAccount(this.selectedAccount)) { return undefined; } if (!this.selectedAccount.host || this.selectedAccount.host.length === 0) { return undefined; } return this.selectedAccount; } private resetData(): void { this.allVideos = []; this.artists = []; this.albums = []; this.playlists = []; // 更新 LazyForEach 数据源 this.songDataSource.pushArrayData([]); this.artistDataSource.pushArrayData([]); this.albumDataSource.pushArrayData([]); this.playlistDataSource.pushArrayData([]); this.jellyfinSongIdSeen.clear(); this.embySongIdSeen.clear(); this.jellyfinSongKeySeen.clear(); this.embySongKeySeen.clear(); this.clearFilter(); this.coverUrlCache.clear(); this.albumCoverLookup.clear(); this.songNextStart = 0; this.artistNextStart = 0; this.albumNextStart = 0; this.playlistNextStart = 0; this.isSongPageLoading = false; this.isArtistPageLoading = false; this.isAlbumPageLoading = false; this.isPlaylistPageLoading = false; this.serverTotalSongCount = 0; } // 更新所有 LazyForEach 数据源 private updateAllDataSources(): void { this.songDataSource.pushArrayData(this.getVisibleSongs()); this.artistDataSource.pushArrayData(this.artists); this.albumDataSource.pushArrayData(this.albums); this.playlistDataSource.pushArrayData(this.playlists); } private async loadMediaLibrary(account: WebDavAccount): Promise { if (this.isNavidromeAccount(account)) { await this.loadNavidromeLibrary(account); return; } if (this.isJellyfinAccount(account)) { await this.loadJellyfinLibrary(account); return; } if (this.isEmbyAccount(account)) { await this.loadEmbyLibrary(account); return; } if (this.isAudioStationAccount(account)) { await this.loadAudioStationLibrary(account); return; } if (this.isPlexAccount(account)) { await this.loadPlexLibrary(account); return; } if (this.isDaoLiYuAccount(account)) { await this.loadDaoLiYuLibrary(account); } } private async loadNavidromeLibrary(account: WebDavAccount): Promise { const ticket = ++this.loadTicket; this.loading = true; this.resetData(); void ServerLogUtil.info('NavidromeLoad', '开始加载 Navidrome 数据库'); void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`); void ServerLogUtil.info('NavidromeLoad', `封面URL缓存已清空,当前缓存数量: ${this.coverUrlCache.size}`); try { await this.logAccountCacheInfo(account); // 尝试从缓存读取(前50条) const accountId = account.id?.toString() ?? 'default'; const cachedData: AllCacheData = this.listCache.getAllCache(accountId); let loadedFromCache = false; if (cachedData.songs && cachedData.songs.length > 0) { this.hydrateSongQuality(cachedData.songs); this.allVideos = cachedData.songs; this.songDataSource.pushArrayData(cachedData.songs); loadedFromCache = true; void ServerLogUtil.info('NavidromeLoad', `从缓存加载歌曲: ${cachedData.songs.length} 首`); } if (cachedData.artists && cachedData.artists.length > 0) { this.artists = cachedData.artists; this.artistDataSource.pushArrayData(cachedData.artists); void ServerLogUtil.info('NavidromeLoad', `从缓存加载艺术家: ${cachedData.artists.length} 位`); } if (cachedData.albums && cachedData.albums.length > 0) { this.albums = cachedData.albums; this.albumDataSource.pushArrayData(cachedData.albums); void ServerLogUtil.info('NavidromeLoad', `从缓存加载专辑: ${cachedData.albums.length} 张`); } if (cachedData.playlists && cachedData.playlists.length > 0) { this.playlists = cachedData.playlists; this.playlistDataSource.pushArrayData(cachedData.playlists); void ServerLogUtil.info('NavidromeLoad', `从缓存加载歌单: ${cachedData.playlists.length} 个`); } // 如果有缓存数据,先显示缓存,然后后台加载完整数据 if (loadedFromCache || cachedData.artists || cachedData.albums || cachedData.playlists) { this.loading = false; void ServerLogUtil.info('NavidromeLoad', '缓存数据加载完成,开始后台加载完整数据'); // 使用 taskpool 后台加载完整数据,避免 UI 卡顿 // 提取 account 的序列化数据 (taskpool 不支持 Proxy 对象) const accountData: AccountData = { id: account.id ?? 0, webType: account.webType, host: account.host ?? '', port: account.port ?? 80, account: account.account ?? '', password: account.password ?? '', enableHttps: account.enableHttps ?? false, navidromeBasePath: account.navidromeBasePath ?? '/rest', jellyfinBasePath: account.jellyfinBasePath ?? '', embyBasePath: account.embyBasePath ?? '', name: account.name ?? '' }; const task = new taskpool.Task(loadNavidromeDataTask, accountData, ticket); taskpool.execute(task, taskpool.Priority.MEDIUM).then((result: Object) => { if (ticket !== this.loadTicket) { return; } // 类型断言 const taskResult = result as TaskResult; void ServerLogUtil.info('NavidromeLoad', `后台加载完成: 歌曲 ${taskResult.songs.length} / 艺术家 ${taskResult.artists.length} / 专辑 ${taskResult.albums.length} / 歌单 ${taskResult.playlists.length}`); // 直接更新数据(封面已在后台处理) if (taskResult.songs && taskResult.songs.length > 0) { this.hydrateSongQuality(taskResult.songs); this.allVideos = taskResult.songs; this.songDataSource.pushArrayData(taskResult.songs); } if (taskResult.artists && taskResult.artists.length > 0) { this.artists = taskResult.artists; this.artistDataSource.pushArrayData(taskResult.artists); } if (taskResult.albums && taskResult.albums.length > 0) { this.albums = taskResult.albums; this.albumDataSource.pushArrayData(taskResult.albums); } if (taskResult.playlists && taskResult.playlists.length > 0) { this.playlists = taskResult.playlists; this.playlistDataSource.pushArrayData(taskResult.playlists); } // 保存新的缓存(前50条) this.listCache.saveSongsCache(accountId, this.allVideos); this.listCache.saveArtistsCache(accountId, this.artists); this.listCache.saveAlbumsCache(accountId, this.albums); this.listCache.savePlaylistsCache(accountId, this.playlists); void ServerLogUtil.info('NavidromeLoad', `后台加载完成并更新缓存: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`); void this.logCacheStatistics(account); }).catch((error: Error) => { void ServerLogUtil.error('NavidromeLoad', `后台数据加载失败: ${error.message}`); }); return; } // 没有缓存,正常加载 await Promise.all([ this.loadNextSongPage(account, ticket), this.loadNextArtistPage(account, ticket), this.loadNextAlbumPage(account, ticket), this.loadNextPlaylistPage(account, ticket) ]); if (ticket !== this.loadTicket) { return; } // 保存缓存(前50条) this.listCache.saveSongsCache(accountId, this.allVideos); this.listCache.saveArtistsCache(accountId, this.artists); this.listCache.saveAlbumsCache(accountId, this.albums); this.listCache.savePlaylistsCache(accountId, this.playlists); void ServerLogUtil.info('NavidromeLoad', `首次加载完成并已缓存: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`); await this.logCacheStatistics(account); } catch (error) { if (ticket === this.loadTicket) { void ServerLogUtil.error('NavidromeLoad', `数据加载失败: ${(error as Error).message}`); void ServerLogUtil.error('NavidromeLoad', `失败时服务器信息: ${ServerLogUtil.sanitizeAccount(account)}`); ToastUtil.showToast((error as Error).message ?? 'Navidrome 数据加载失败'); } } finally { if (ticket === this.loadTicket) { this.loading = false; void ServerLogUtil.info('NavidromeLoad', '加载流程结束'); } } } private async loadJellyfinLibrary(account: WebDavAccount): Promise { const ticket = ++this.loadTicket; this.loading = true; this.resetData(); void ServerLogUtil.info('NavidromeLoad', '开始加载 Jellyfin 媒体库'); void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`); try { this.songNextStart = 0; this.artistNextStart = 0; this.albumNextStart = 0; this.playlistNextStart = null; await Promise.all([ this.loadNextJellyfinSongPage(account, ticket), this.loadNextJellyfinArtistPage(account, ticket), this.loadNextJellyfinAlbumPage(account, ticket) ]); if (ticket === this.loadTicket) { void ServerLogUtil.info('NavidromeLoad', `Jellyfin 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`); } } catch (error) { if (ticket === this.loadTicket) { void ServerLogUtil.error('NavidromeLoad', `Jellyfin 数据加载失败: ${(error as Error).message}`); ToastUtil.showToast((error as Error).message ?? 'Jellyfin 数据加载失败'); } } finally { if (ticket === this.loadTicket) { this.loading = false; } } } private async loadEmbyLibrary(account: WebDavAccount): Promise { const ticket = ++this.loadTicket; this.loading = true; this.resetData(); void ServerLogUtil.info('NavidromeLoad', '开始加载 Emby 媒体库'); void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`); try { this.songNextStart = 0; this.artistNextStart = 0; this.albumNextStart = 0; this.playlistNextStart = null; await Promise.all([ this.loadNextEmbySongPage(account, ticket), this.loadNextEmbyArtistPage(account, ticket), this.loadNextEmbyAlbumPage(account, ticket) ]); if (ticket === this.loadTicket) { void ServerLogUtil.info('NavidromeLoad', `Emby 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`); } } catch (error) { if (ticket === this.loadTicket) { void ServerLogUtil.error('NavidromeLoad', `Emby 数据加载失败: ${(error as Error).message}`); ToastUtil.showToast((error as Error).message ?? 'Emby 数据加载失败'); } } finally { if (ticket === this.loadTicket) { this.loading = false; } } } private async loadAudioStationLibrary(account: WebDavAccount): Promise { const ticket = ++this.loadTicket; this.loading = true; this.resetData(); void ServerLogUtil.info('NavidromeLoad', '开始加载 AudioStation 媒体库'); void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`); try { this.songNextStart = 0; this.artistNextStart = 0; this.albumNextStart = 0; this.playlistNextStart = 0; await Promise.all([ this.loadNextAudioStationSongPage(account, ticket), this.loadNextAudioStationArtistPage(account, ticket), this.loadNextAudioStationAlbumPage(account, ticket), this.loadNextAudioStationPlaylistPage(account, ticket) ]); if (ticket === this.loadTicket) { void ServerLogUtil.info('NavidromeLoad', `AudioStation 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`); } } catch (error) { if (ticket === this.loadTicket) { void ServerLogUtil.error('NavidromeLoad', `AudioStation 数据加载失败: ${(error as Error).message}`); ToastUtil.showToast((error as Error).message ?? 'AudioStation 数据加载失败'); } } finally { if (ticket === this.loadTicket) { this.loading = false; } } } private async loadPlexLibrary(account: WebDavAccount): Promise { const ticket = ++this.loadTicket; this.loading = true; this.resetData(); void ServerLogUtil.info('NavidromeLoad', '开始加载 Plex 媒体库'); void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`); try { this.songNextStart = 0; this.artistNextStart = 0; this.albumNextStart = 0; this.playlistNextStart = 0; await Promise.all([ this.loadNextPlexSongPage(account, ticket), this.loadNextPlexArtistPage(account, ticket), this.loadNextPlexAlbumPage(account, ticket), this.loadNextPlexPlaylistPage(account, ticket) ]); if (ticket === this.loadTicket) { void ServerLogUtil.info('NavidromeLoad', `Plex 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`); } } catch (error) { if (ticket === this.loadTicket) { void ServerLogUtil.error('NavidromeLoad', `Plex 数据加载失败: ${(error as Error).message}`); ToastUtil.showToast((error as Error).message ?? 'Plex 数据加载失败'); } } finally { if (ticket === this.loadTicket) { this.loading = false; } } } private async loadDaoLiYuLibrary(account: WebDavAccount): Promise { const ticket = ++this.loadTicket; this.loading = true; this.resetData(); void ServerLogUtil.info('NavidromeLoad', '开始加载 道理鱼 媒体库'); void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`); try { this.songNextStart = 0; this.artistNextStart = 0; this.albumNextStart = 0; this.playlistNextStart = 0; await Promise.all([ this.loadNextDaoLiYuSongPage(account, ticket), this.loadNextDaoLiYuArtistPage(account, ticket), this.loadNextDaoLiYuAlbumPage(account, ticket), this.loadNextDaoLiYuPlaylistPage(account, ticket) ]); if (ticket === this.loadTicket) { void ServerLogUtil.info('NavidromeLoad', `道理鱼 首屏完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`); } } catch (error) { if (ticket === this.loadTicket) { void ServerLogUtil.error('NavidromeLoad', `道理鱼 数据加载失败: ${(error as Error).message}`); ToastUtil.showToast((error as Error).message ?? '道理鱼 数据加载失败'); } } finally { if (ticket === this.loadTicket) { this.loading = false; } } } private async loadNextSongPage(account: WebDavAccount, ticket?: number): Promise { if (this.songNextStart === null || this.isSongPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isSongPageLoading = true; try { const response = await navidromeRestApi.fetchSongPage(account, this.songNextStart); if (currentTicket !== this.loadTicket) { return; } const chunk = response.data ?? []; if (chunk.length === 0) { this.songNextStart = null; return; } const videos = await this.convertSongsToVideoItems(chunk, account); if (currentTicket !== this.loadTicket) { return; } this.allVideos = [...this.allVideos, ...videos]; this.updateAllDataSources(); // 打印前20条数据 // const previewCount = Math.min(20, this.allVideos.length); // console.info(`onecold allVideos: ${this.allVideos.length}, 打印前${previewCount}条:`); // for (let i = 0; i < previewCount; i++) { // const all = this.allVideos[i]; // console.info(`onecold allVideos[${i}]:`, JSON.stringify({ // id: all.id, // name: all.name, // pixelMapPath: all.pixelMapPath // })); // } this.songNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `歌曲列表追加: 本次 ${videos.length} 首, 总数 ${this.allVideos.length}`); } finally { this.isSongPageLoading = false; } } private async loadNextArtistPage(account: WebDavAccount, ticket?: number): Promise { if (this.artistNextStart === null || this.isArtistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isArtistPageLoading = true; try { const response = await navidromeRestApi.fetchArtistPage(account, this.artistNextStart); if (currentTicket !== this.loadTicket) { return; } const chunk = response.data ?? []; if (chunk.length === 0) { this.artistNextStart = null; return; } const coverMap = await this.buildArtistCoverMap(chunk, account); if (currentTicket !== this.loadTicket) { return; } const processed = chunk.map(artist => { artist.coverUrl = coverMap.get(artist.id); return artist; }); this.artists = [...this.artists, ...processed]; // console.info('onecold 帮我打印这个artists的前20条数据'); // 打印前20条艺术家数据 // const previewCount = Math.min(20, this.artists.length); // console.info(`onecold artists总数: ${this.artists.length}, 打印前${previewCount}条:`); // for (let i = 0; i < previewCount; i++) { // const artist = this.artists[i]; // console.info(`onecold artist[${i}]:`, JSON.stringify({ // id: artist.id, // name: artist.name, // albumCount: artist.albumCount, // songCount: artist.songCount, // coverUrl: artist.coverUrl // })); // } this.artistNextStart = response.nextStart; void ServerLogUtil.info('ArtistCover', `艺术家列表追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`); } finally { this.isArtistPageLoading = false; } } private async loadNextAlbumPage(account: WebDavAccount, ticket?: number): Promise { if (this.albumNextStart === null || this.isAlbumPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isAlbumPageLoading = true; try { const response = await navidromeRestApi.fetchAlbumPage(account, this.albumNextStart); if (currentTicket !== this.loadTicket) { return; } const chunk = response.data ?? []; if (chunk.length === 0) { this.albumNextStart = null; return; } const coverMap = await this.buildAlbumCoverMap(chunk, account); if (currentTicket !== this.loadTicket) { return; } coverMap.forEach((value, key) => { if (value) { this.albumCoverLookup.set(key, value); } }); const processed = chunk.map(album => { album.coverUrl = coverMap.get(album.id); return album; }); this.albums = [...this.albums, ...processed]; this.updateAllDataSources(); this.albumNextStart = response.nextStart; void ServerLogUtil.info('AlbumCover', `专辑列表追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`); } finally { this.isAlbumPageLoading = false; } } private async loadNextJellyfinSongPage(account: WebDavAccount, ticket?: number): Promise { if (this.songNextStart === null || this.isSongPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; const isFirstPage = this.songNextStart === 0; this.isSongPageLoading = true; try { const response = await jellyfinApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } // 首次加载时保存服务端总数 if (isFirstPage && response.total !== undefined && response.total > 0) { this.serverTotalSongCount = response.total; void ServerLogUtil.info('NavidromeLoad', `Jellyfin 服务端歌曲总数: ${this.serverTotalSongCount}`); } const uniqueItems: JellyfinSong[] = []; for (let i = 0; i < response.items.length; i++) { const item = response.items[i]; if (!item.id) { continue; } const key = this.buildSongDedupKey(item.title, item.artist, item.album, item.durationSeconds); if (this.jellyfinSongIdSeen.has(item.id) || this.jellyfinSongKeySeen.has(key)) { continue; } this.jellyfinSongIdSeen.add(item.id); this.jellyfinSongKeySeen.add(key); uniqueItems.push(item); } const restSongs = this.convertJellyfinSongsToRestSongs(uniqueItems); const videoItems = await this.convertSongsToVideoItems(restSongs, account); if (currentTicket !== this.loadTicket) { return; } this.allVideos = this.dedupRemoteSongsByKey([...this.allVideos, ...videoItems]); this.updateAllDataSources(); this.songNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Jellyfin 歌曲追加: 本次 ${videoItems.length} 首, 已加载 ${this.allVideos.length}, 服务端总数 ${this.serverTotalSongCount}`); } finally { this.isSongPageLoading = false; } } private async loadNextJellyfinArtistPage(account: WebDavAccount, ticket?: number): Promise { if (this.artistNextStart === null || this.isArtistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isArtistPageLoading = true; try { const response = await jellyfinApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertJellyfinArtistsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.artists = [...this.artists, ...processed]; this.updateAllDataSources(); this.artistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Jellyfin 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`); } finally { this.isArtistPageLoading = false; } } private async loadNextJellyfinAlbumPage(account: WebDavAccount, ticket?: number): Promise { if (this.albumNextStart === null || this.isAlbumPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isAlbumPageLoading = true; try { const response = await jellyfinApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertJellyfinAlbumsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.albums = [...this.albums, ...processed]; this.updateAllDataSources(); this.albumNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Jellyfin 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`); } finally { this.isAlbumPageLoading = false; } } private async loadNextEmbySongPage(account: WebDavAccount, ticket?: number): Promise { if (this.songNextStart === null || this.isSongPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; const isFirstPage = this.songNextStart === 0; this.isSongPageLoading = true; try { const response = await embyApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } // 首次加载时保存服务端总数 if (isFirstPage && response.total !== undefined && response.total > 0) { this.serverTotalSongCount = response.total; void ServerLogUtil.info('NavidromeLoad', `Emby 服务端歌曲总数: ${this.serverTotalSongCount}`); } const uniqueItems: EmbySong[] = []; for (let i = 0; i < response.items.length; i++) { const item = response.items[i]; if (!item.id) { continue; } const key = this.buildSongDedupKey(item.title, item.artist, item.album, item.durationSeconds); if (this.embySongIdSeen.has(item.id) || this.embySongKeySeen.has(key)) { continue; } this.embySongIdSeen.add(item.id); this.embySongKeySeen.add(key); uniqueItems.push(item); } const restSongs = this.convertEmbySongsToRestSongs(uniqueItems); const videoItems = await this.convertSongsToVideoItems(restSongs, account); if (currentTicket !== this.loadTicket) { return; } this.allVideos = this.dedupRemoteSongsByKey([...this.allVideos, ...videoItems]); this.updateAllDataSources(); this.songNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Emby 歌曲追加: 本次 ${videoItems.length} 首, 已加载 ${this.allVideos.length}, 服务端总数 ${this.serverTotalSongCount}`); } finally { this.isSongPageLoading = false; } } private async loadNextEmbyArtistPage(account: WebDavAccount, ticket?: number): Promise { if (this.artistNextStart === null || this.isArtistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isArtistPageLoading = true; try { const response = await embyApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertEmbyArtistsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.artists = [...this.artists, ...processed]; this.updateAllDataSources(); this.artistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Emby 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`); } finally { this.isArtistPageLoading = false; } } private async loadNextEmbyAlbumPage(account: WebDavAccount, ticket?: number): Promise { if (this.albumNextStart === null || this.isAlbumPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isAlbumPageLoading = true; try { const response = await embyApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertEmbyAlbumsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.albums = [...this.albums, ...processed]; this.updateAllDataSources(); this.albumNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Emby 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`); } finally { this.isAlbumPageLoading = false; } } private async loadNextAudioStationSongPage(account: WebDavAccount, ticket?: number): Promise { if (this.songNextStart === null || this.isSongPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; const isFirstPage = this.songNextStart === 0; // 在调用前保存是否为首页 this.isSongPageLoading = true; try { const response = await audioStationApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } // 首次加载时保存服务端总数 if (isFirstPage && response.total !== undefined && response.total > 0) { this.serverTotalSongCount = response.total; void ServerLogUtil.info('NavidromeLoad', `AudioStation 服务端歌曲总数: ${this.serverTotalSongCount}`); } const restSongs = this.convertAudioStationSongsToRestSongs(response.items); const videoItems = await this.convertSongsToVideoItems(restSongs, account); if (currentTicket !== this.loadTicket) { return; } this.allVideos = this.dedupRemoteSongsByKey([...this.allVideos, ...videoItems]); this.updateAllDataSources(); this.songNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `AudioStation 歌曲追加: 本次 ${videoItems.length} 首, 已加载 ${this.allVideos.length}, 服务端总数 ${this.serverTotalSongCount}`); } finally { this.isSongPageLoading = false; } } private async loadNextAudioStationArtistPage(account: WebDavAccount, ticket?: number): Promise { if (this.artistNextStart === null || this.isArtistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isArtistPageLoading = true; try { const response = await audioStationApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = this.convertAudioStationArtistsToRest(response.items); if (currentTicket !== this.loadTicket) { return; } this.artists = [...this.artists, ...processed]; this.updateAllDataSources(); this.artistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `AudioStation 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`); } finally { this.isArtistPageLoading = false; } } private async loadNextAudioStationAlbumPage(account: WebDavAccount, ticket?: number): Promise { if (this.albumNextStart === null || this.isAlbumPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isAlbumPageLoading = true; try { const response = await audioStationApi.getAlbumsPage(account, undefined, this.albumNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertAudioStationAlbumsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.albums = [...this.albums, ...processed]; this.updateAllDataSources(); this.albumNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `AudioStation 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`); } finally { this.isAlbumPageLoading = false; } } private async loadNextAudioStationPlaylistPage(account: WebDavAccount, ticket?: number): Promise { if (this.playlistNextStart === null || this.isPlaylistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isPlaylistPageLoading = true; try { const response = await audioStationApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = this.convertAudioStationPlaylistsToRest(response.items); if (currentTicket !== this.loadTicket) { return; } this.playlists = [...this.playlists, ...processed]; this.updateAllDataSources(); this.playlistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `AudioStation 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`); } finally { this.isPlaylistPageLoading = false; } } private async loadNextPlexSongPage(account: WebDavAccount, ticket?: number): Promise { if (this.songNextStart === null || this.isSongPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; const isFirstPage = this.songNextStart === 0; this.isSongPageLoading = true; try { const response = await plexApi.getSongsPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } // 首次加载时保存服务端总数 if (isFirstPage && response.total !== undefined && response.total > 0) { this.serverTotalSongCount = response.total; void ServerLogUtil.info('NavidromeLoad', `Plex 服务端歌曲总数: ${this.serverTotalSongCount}`); } const restSongs = this.convertPlexSongsToRestSongs(response.items); const videoItems = await this.convertSongsToVideoItems(restSongs, account); if (currentTicket !== this.loadTicket) { return; } this.allVideos = this.dedupRemoteSongsByKey([...this.allVideos, ...videoItems]); this.updateAllDataSources(); this.songNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Plex 歌曲追加: 本次 ${videoItems.length} 首, 已加载 ${this.allVideos.length}, 服务端总数 ${this.serverTotalSongCount}`); } finally { this.isSongPageLoading = false; } } private async loadNextPlexArtistPage(account: WebDavAccount, ticket?: number): Promise { if (this.artistNextStart === null || this.isArtistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isArtistPageLoading = true; try { const response = await plexApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertPlexArtistsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.artists = [...this.artists, ...processed]; this.updateAllDataSources(); this.artistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Plex 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`); } finally { this.isArtistPageLoading = false; } } private async loadNextPlexAlbumPage(account: WebDavAccount, ticket?: number): Promise { if (this.albumNextStart === null || this.isAlbumPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isAlbumPageLoading = true; try { const response = await plexApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertPlexAlbumsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.albums = [...this.albums, ...processed]; this.updateAllDataSources(); this.albumNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Plex 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`); } finally { this.isAlbumPageLoading = false; } } private async loadNextPlexPlaylistPage(account: WebDavAccount, ticket?: number): Promise { if (this.playlistNextStart === null || this.isPlaylistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isPlaylistPageLoading = true; try { const response = await plexApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = this.convertPlexPlaylistsToRest(response.items); if (currentTicket !== this.loadTicket) { return; } this.playlists = [...this.playlists, ...processed]; this.updateAllDataSources(); this.playlistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `Plex 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`); } finally { this.isPlaylistPageLoading = false; } } private async loadNextDaoLiYuSongPage(account: WebDavAccount, ticket?: number): Promise { if (this.songNextStart === null || this.isSongPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; const isFirstPage = this.songNextStart === 0; this.isSongPageLoading = true; try { const response = await daoLiYuApi.getTracksPage(account, this.songNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } // 首次加载时保存服务端总数 if (isFirstPage && response.total !== undefined && response.total > 0) { this.serverTotalSongCount = response.total; void ServerLogUtil.info('NavidromeLoad', `道理鱼 服务端歌曲总数: ${this.serverTotalSongCount}`); } const restSongs = this.convertDaoLiYuSongsToRestSongs(response.items); const videoItems = await this.convertSongsToVideoItems(restSongs, account); if (currentTicket !== this.loadTicket) { return; } this.allVideos = [...this.allVideos, ...videoItems]; this.updateAllDataSources(); this.songNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `道理鱼 歌曲追加: 本次 ${videoItems.length} 首, 已加载 ${this.allVideos.length}, 服务端总数 ${this.serverTotalSongCount}`); } finally { this.isSongPageLoading = false; } } private async loadNextDaoLiYuArtistPage(account: WebDavAccount, ticket?: number): Promise { if (this.artistNextStart === null || this.isArtistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isArtistPageLoading = true; try { const response = await daoLiYuApi.getArtistsPage(account, this.artistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertDaoLiYuArtistsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.artists = [...this.artists, ...processed]; this.updateAllDataSources(); this.artistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `道理鱼 艺术家追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`); } finally { this.isArtistPageLoading = false; } } private async loadNextDaoLiYuAlbumPage(account: WebDavAccount, ticket?: number): Promise { if (this.albumNextStart === null || this.isAlbumPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isAlbumPageLoading = true; try { const response = await daoLiYuApi.getAlbumsPage(account, this.albumNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertDaoLiYuAlbumsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.albums = [...this.albums, ...processed]; this.updateAllDataSources(); this.albumNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `道理鱼 专辑追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`); } finally { this.isAlbumPageLoading = false; } } private async loadNextDaoLiYuPlaylistPage(account: WebDavAccount, ticket?: number): Promise { if (this.playlistNextStart === null || this.isPlaylistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isPlaylistPageLoading = true; try { const response = await daoLiYuApi.getPlaylistsPage(account, this.playlistNextStart, this.REMOTE_PAGE_SIZE); if (currentTicket !== this.loadTicket) { return; } const processed = await this.convertDaoLiYuPlaylistsToRest(response.items, account); if (currentTicket !== this.loadTicket) { return; } this.playlists = [...this.playlists, ...processed]; this.updateAllDataSources(); this.playlistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `道理鱼 歌单追加: 本次 ${processed.length} 个, 总数 ${this.playlists.length}`); } finally { this.isPlaylistPageLoading = false; } } private async loadNextPlaylistPage(account: WebDavAccount, ticket?: number): Promise { if (this.playlistNextStart === null || this.isPlaylistPageLoading) { return; } const currentTicket = ticket ?? this.loadTicket; this.isPlaylistPageLoading = true; try { const response = await navidromeRestApi.fetchPlaylistPage(account, this.playlistNextStart); if (currentTicket !== this.loadTicket) { return; } const chunk = response.data ?? []; if (chunk.length === 0) { this.playlistNextStart = null; return; } this.playlists = [...this.playlists, ...chunk]; this.playlistNextStart = response.nextStart; void ServerLogUtil.info('NavidromeLoad', `歌单列表追加: 本次 ${chunk.length} 个, 总数 ${this.playlists.length}`); } finally { this.isPlaylistPageLoading = false; } } private async handleReachEnd(): Promise { if (this.loading) { return; } // 搜索结果列表不参与分页,避免滚动时被全量列表覆盖 if (this.selectedTab === 0 && this.isSearchMode && this.searchText.length > 0) { return; } // 详情视图模式下不支持加载更多(筛选数据是一次性加载的) if (this.isDetailView || this.filterType !== NavFilterType.None) { return; } const account = this.selectedAccount; if (!account) { return; } if (this.isNavidromeAccount(account)) { switch (this.selectedTab) { case 0: await this.loadNextSongPage(account); break; case 1: await this.loadNextArtistPage(account); break; case 2: await this.loadNextAlbumPage(account); break; case 3: await this.loadNextPlaylistPage(account); break; default: break; } return; } if (this.isJellyfinAccount(account)) { switch (this.selectedTab) { case 0: await this.loadNextJellyfinSongPage(account); break; case 1: await this.loadNextJellyfinArtistPage(account); break; case 2: await this.loadNextJellyfinAlbumPage(account); break; default: break; } return; } if (this.isEmbyAccount(account)) { switch (this.selectedTab) { case 0: await this.loadNextEmbySongPage(account); break; case 1: await this.loadNextEmbyArtistPage(account); break; case 2: await this.loadNextEmbyAlbumPage(account); break; default: break; } return; } if (this.isAudioStationAccount(account)) { switch (this.selectedTab) { case 0: await this.loadNextAudioStationSongPage(account); break; case 1: await this.loadNextAudioStationArtistPage(account); break; case 2: await this.loadNextAudioStationAlbumPage(account); break; case 3: await this.loadNextAudioStationPlaylistPage(account); break; default: break; } return; } if (this.isPlexAccount(account)) { switch (this.selectedTab) { case 0: await this.loadNextPlexSongPage(account); break; case 1: await this.loadNextPlexArtistPage(account); break; case 2: await this.loadNextPlexAlbumPage(account); break; case 3: await this.loadNextPlexPlaylistPage(account); break; default: break; } return; } if (this.isDaoLiYuAccount(account)) { switch (this.selectedTab) { case 0: await this.loadNextDaoLiYuSongPage(account); break; case 1: await this.loadNextDaoLiYuArtistPage(account); break; case 2: await this.loadNextDaoLiYuAlbumPage(account); break; case 3: await this.loadNextDaoLiYuPlaylistPage(account); break; default: break; } } } private async buildAlbumCoverMap(albums: NavidromeRestAlbum[], account: WebDavAccount): Promise> { const map = new Map(); const tasks: Promise[] = []; let directCoverCount = 0; let generatedCoverCount = 0; let failedCoverCount = 0; void ServerLogUtil.info('RemoteMusicPageCover', `📀 开始处理 ${albums.length} 张专辑的封面 - 账号: ${ServerLogUtil.sanitizeAccount(account)}`); for (let i = 0; i < albums.length; i++) { const album = albums[i]; void ServerLogUtil.debug('RemoteMusicPageCover', `处理专辑 [${i + 1}/${albums.length}] - id: ${album.id}, name: ${album.name}`); tasks.push((async () => { try { // 注意: embedArtPath 和 coverArtPath 可能指向音频文件而非封面图片 // 所以这里不能直接使用,需要通过 buildCoverUrl 生成正确的封面URL // const directUrl = this.resolveEmbedCover(account, album.embedArtPath ?? album.coverArtPath); // if (directUrl) { // map.set(album.id, directUrl); // directCoverCount++; // void ServerLogUtil.info('RemoteMusicPageCover', `✅ 专辑使用直接封面 - name: ${album.name}, url: ${directUrl}`); // return; // } // 生成封面URL const coverId = album.coverArt ?? album.coverArtId ?? (album.id ? `al-${album.id}` : undefined); void ServerLogUtil.debug('RemoteMusicPageCover', `专辑封面ID - name: ${album.name}, coverArt: ${album.coverArt}, coverArtId: ${album.coverArtId}, 最终coverId: ${coverId}`); const url = await this.buildCoverUrl(account, coverId); if (url) { map.set(album.id, url); generatedCoverCount++; void ServerLogUtil.info('RemoteMusicPageCover', `✅ 专辑生成封面成功 - name: ${album.name}, coverId: ${coverId}, url: ${url}`); } else { failedCoverCount++; void ServerLogUtil.error('RemoteMusicPageCover', `❌ 专辑封面失败 - name: ${album.name}, coverId: ${coverId}, 原始数据: ${JSON.stringify({coverArt: album.coverArt, coverArtId: album.coverArtId, id: album.id})}`); } } catch (error) { failedCoverCount++; void ServerLogUtil.error('RemoteMusicPageCover', `❌ 专辑封面解析异常 - name: ${album.name}, error: ${(error as Error).message}`); } })()); } await Promise.all(tasks); void ServerLogUtil.info('RemoteMusicPageCover', `📊 专辑封面处理完成 - 总数: ${albums.length}, 直接嵌入: ${directCoverCount}, 生成URL: ${generatedCoverCount}, 失败: ${failedCoverCount}`); return map; } private async buildArtistCoverMap(artists: NavidromeRestArtist[], account: WebDavAccount): Promise> { const map = new Map(); const tasks: Promise[] = []; let directCoverCount = 0; let generatedCoverCount = 0; let failedCoverCount = 0; void ServerLogUtil.info('RemoteMusicPageCover', `🎤 开始处理 ${artists.length} 位艺术家的封面 - 账号: ${ServerLogUtil.sanitizeAccount(account)}`); for (let i = 0; i < artists.length; i++) { const artist = artists[i]; void ServerLogUtil.debug('RemoteMusicPageCover', `处理艺术家 [${i + 1}/${artists.length}] - id: ${artist.id}, name: ${artist.name}`); tasks.push((async () => { try { // 尝试获取已有图片URL const directUrl = artist.mediumImageUrl ?? artist.largeImageUrl ?? this.resolveEmbedCover(account, artist.coverArtPath); if (directUrl) { map.set(artist.id, directUrl); directCoverCount++; void ServerLogUtil.info('RemoteMusicPageCover', `✅ 艺术家使用已有图片 - name: ${artist.name}, url: ${directUrl}`); return; } // 生成封面URL const coverId = artist.coverArt ?? artist.coverArtId ?? (artist.id ? `ar-${artist.id}` : undefined); void ServerLogUtil.debug('RemoteMusicPageCover', `艺术家封面ID - name: ${artist.name}, coverArt: ${artist.coverArt}, coverArtId: ${artist.coverArtId}, 最终coverId: ${coverId}`); const url = await this.buildCoverUrl(account, coverId, 256); if (url) { map.set(artist.id, url); generatedCoverCount++; void ServerLogUtil.info('RemoteMusicPageCover', `✅ 艺术家生成封面成功 - name: ${artist.name}, coverId: ${coverId}, url: ${url}`); } else { failedCoverCount++; void ServerLogUtil.error('RemoteMusicPageCover', `❌ 艺术家封面失败 - name: ${artist.name}, coverId: ${coverId}, 原始数据: ${JSON.stringify({coverArt: artist.coverArt, coverArtId: artist.coverArtId, id: artist.id})}`); } } catch (error) { failedCoverCount++; void ServerLogUtil.error('RemoteMusicPageCover', `❌ 艺术家封面解析异常 - name: ${artist.name}, error: ${(error as Error).message}`); } })()); } await Promise.all(tasks); void ServerLogUtil.info('RemoteMusicPageCover', `📊 艺术家封面处理完成 - 总数: ${artists.length}, 直接链接: ${directCoverCount}, 生成URL: ${generatedCoverCount}, 失败: ${failedCoverCount}`); return map; } private async buildRemoteCoverMap(ids: string[], account: WebDavAccount): Promise> { const map = new Map(); const tasks = ids.map(async (id) => { if (!id) { return; } try { const url = await this.buildCoverUrl(account, id, 300); if (url) { map.set(id, url); } } catch (error) { void ServerLogUtil.warn('CoverCache', `远程封面生成失败: ${id} ${(error as Error).message}`); } }); await Promise.all(tasks); return map; } private buildAudioStationAlbumKey(name?: string, artist?: string): string { const safeName = name ?? ''; const safeArtist = artist ?? ''; return `as:${safeName}|||${safeArtist}`; } private parseAudioStationAlbumKey(value: string): AudioStationAlbumKey { let raw = value ?? ''; if (raw.startsWith('as:')) { raw = raw.slice(3); } const parts = raw.split('|||'); const name = parts.length > 0 ? parts[0] : ''; const artist = parts.length > 1 ? parts.slice(1).join('|||') : ''; return new AudioStationAlbumKey(name, artist); } private async convertJellyfinArtistsToRest(artists: JellyfinArtist[], account: WebDavAccount): Promise { const results: NavidromeRestArtist[] = []; for (let i = 0; i < artists.length; i++) { const artist = artists[i]; const coverUrl = await this.buildCoverUrl(account, artist.id, 300); const restArtist: NavidromeRestArtist = { id: artist.id, name: artist.name, albumCount: artist.albumCount, songCount: artist.songCount, coverUrl: coverUrl }; results.push(restArtist); } return results; } private async convertEmbyArtistsToRest(artists: EmbyArtist[], account: WebDavAccount): Promise { const results: NavidromeRestArtist[] = []; for (let i = 0; i < artists.length; i++) { const artist = artists[i]; const coverUrl = await this.buildCoverUrl(account, artist.id, 300); const restArtist: NavidromeRestArtist = { id: artist.id, name: artist.name, albumCount: artist.albumCount, songCount: artist.songCount, coverUrl: coverUrl }; results.push(restArtist); } return results; } private async convertJellyfinAlbumsToRest(albums: JellyfinAlbum[], account: WebDavAccount): Promise { const results: NavidromeRestAlbum[] = []; for (let i = 0; i < albums.length; i++) { const album = albums[i]; const coverUrl = await this.buildCoverUrl(account, album.id, 300); const restAlbum: NavidromeRestAlbum = { id: album.id, name: album.name, artist: album.artist, minYear: album.year, coverUrl: coverUrl }; results.push(restAlbum); } return results; } private async convertEmbyAlbumsToRest(albums: EmbyAlbum[], account: WebDavAccount): Promise { const results: NavidromeRestAlbum[] = []; for (let i = 0; i < albums.length; i++) { const album = albums[i]; const coverUrl = await this.buildCoverUrl(account, album.id, 300); const restAlbum: NavidromeRestAlbum = { id: album.id, name: album.name, artist: album.artist, minYear: album.year, coverUrl: coverUrl }; results.push(restAlbum); } return results; } private convertAudioStationArtistsToRest(artists: AudioStationArtist[]): NavidromeRestArtist[] { return artists.map(artist => { const name = artist.name ?? ''; return { id: name, name: name } as NavidromeRestArtist; }); } private async convertAudioStationAlbumsToRest(albums: AudioStationAlbum[], account: WebDavAccount): Promise { const results: NavidromeRestAlbum[] = []; for (let i = 0; i < albums.length; i++) { const album = albums[i]; const artistName = album.albumArtist ?? album.displayArtist ?? ''; const albumKey = this.buildAudioStationAlbumKey(album.name, artistName); const coverUrl = await this.buildCoverUrl(account, albumKey, 300); const restAlbum: NavidromeRestAlbum = { id: albumKey, name: album.name, artist: artistName, minYear: album.year, coverArtId: albumKey, coverUrl: coverUrl }; results.push(restAlbum); } return results; } private async convertPlexArtistsToRest(artists: PlexArtist[], account: WebDavAccount): Promise { const results: NavidromeRestArtist[] = []; for (let i = 0; i < artists.length; i++) { const artist = artists[i]; const coverId = artist.thumb ?? (artist.id ? `ar-${artist.id}` : ''); const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined; const restArtist: NavidromeRestArtist = { id: artist.id, name: artist.name, coverArtId: coverId, coverUrl: coverUrl }; results.push(restArtist); } return results; } private async convertPlexAlbumsToRest(albums: PlexAlbum[], account: WebDavAccount): Promise { const results: NavidromeRestAlbum[] = []; for (let i = 0; i < albums.length; i++) { const album = albums[i]; const coverId = album.thumb ?? (album.id ? `al-${album.id}` : ''); const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined; const restAlbum: NavidromeRestAlbum = { id: album.id, name: album.name, artist: album.artist, minYear: album.year, coverArtId: coverId, coverUrl: coverUrl }; results.push(restAlbum); } return results; } private async convertDaoLiYuArtistsToRest(artists: DaoLiYuArtist[], account: WebDavAccount): Promise { const results: NavidromeRestArtist[] = []; for (let i = 0; i < artists.length; i++) { const artist = artists[i]; const coverId = artist.coverArtUrl ?? ''; const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined; const restArtist: NavidromeRestArtist = { id: artist.id, name: artist.name, albumCount: artist.albumCount, songCount: artist.trackCount, coverArtId: coverId, coverUrl: coverUrl }; results.push(restArtist); } return results; } private async convertDaoLiYuAlbumsToRest(albums: DaoLiYuAlbum[], account: WebDavAccount): Promise { const results: NavidromeRestAlbum[] = []; for (let i = 0; i < albums.length; i++) { const album = albums[i]; const coverId = album.coverArtUrl ?? ''; const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined; const restAlbum: NavidromeRestAlbum = { id: album.id, name: album.title, artist: album.artist, minYear: album.year, coverArtId: coverId, coverUrl: coverUrl }; results.push(restAlbum); } return results; } private async convertDaoLiYuPlaylistsToRest(playlists: DaoLiYuPlaylist[], account: WebDavAccount): Promise { const results: NavidromeRestPlaylist[] = []; for (let i = 0; i < playlists.length; i++) { const playlist = playlists[i]; const coverId = playlist.coverArtUrl ?? ''; const coverUrl = coverId ? await this.buildCoverUrl(account, coverId, 300) : undefined; const restPlaylist: NavidromeRestPlaylist = { id: playlist.id, name: playlist.name, comment: playlist.description ?? undefined, songCount: playlist.songCount, coverArtId: coverId, coverUrl: coverUrl }; results.push(restPlaylist); } return results; } private convertJellyfinSongsToRestSongs(songs: JellyfinSong[]): NavidromeRestSong[] { return songs.map(song => { const restSong: NavidromeRestSong = { id: song.id, title: song.title, album: song.album, albumId: song.albumId, artist: song.artist, artistId: song.artistId, duration: song.durationSeconds, bitRate: song.bitRate, sampleRate: song.sampleRate, suffix: song.suffix, size: song.size, track: song.track, year: song.year, coverArt: song.albumId ?? song.id }; return restSong; }); } private convertEmbySongsToRestSongs(songs: EmbySong[]): NavidromeRestSong[] { return songs.map(song => { const restSong: NavidromeRestSong = { id: song.id, title: song.title, album: song.album, albumId: song.albumId, artist: song.artist, artistId: song.artistId, duration: song.durationSeconds, bitRate: song.bitRate, sampleRate: song.sampleRate, suffix: song.suffix, size: song.size, track: song.track, year: song.year, coverArt: song.albumId ?? song.id }; return restSong; }); } private convertAudioStationSongsToRestSongs(songs: AudioStationSong[]): NavidromeRestSong[] { return songs.map(song => { const artistName = song.artist ?? song.albumArtist ?? ''; const albumKey = this.buildAudioStationAlbumKey(song.album, song.albumArtist ?? song.artist); const songCoverId = song.id ? `as-song:${song.id}` : albumKey; const restSong: NavidromeRestSong = { id: song.id, title: song.title, album: song.album, albumId: albumKey, artist: artistName, artistId: artistName, duration: song.durationSeconds, bitRate: song.bitRate, sampleRate: song.sampleRate, suffix: song.container, size: song.size, track: song.track, year: song.year, contentType: song.container ? `audio/${song.container}` : undefined, coverArtId: songCoverId }; return restSong; }); } private convertPlexSongsToRestSongs(songs: PlexSong[]): NavidromeRestSong[] { return songs.map(song => { const coverId = song.albumThumb ?? song.thumb ?? song.albumId ?? song.id; const restSong: NavidromeRestSong = { id: song.id, title: song.title, album: song.album, albumId: song.albumId, artist: song.artist, artistId: song.artistId, duration: song.durationSeconds, bitRate: song.bitRate, sampleRate: song.sampleRate, suffix: song.suffix, size: song.size, track: song.track, year: song.year, contentType: song.mimeType, coverArtId: coverId }; return restSong; }); } private convertDaoLiYuSongsToRestSongs(songs: DaoLiYuTrack[]): NavidromeRestSong[] { return songs.map(song => { const coverId = song.coverArtUrl ?? song.albumId ?? song.id; const restSong: NavidromeRestSong = { id: song.id, title: song.title, album: song.album, albumId: song.albumId, artist: song.artist, artistId: song.artistId, duration: song.durationSeconds, bitRate: song.bitRate, sampleRate: song.sampleRate ?? undefined, suffix: song.suffix, size: song.size, track: song.track, year: song.year, contentType: song.mimeType, coverArtId: coverId, lyrics: song.lyrics, createdAt: song.createdAt }; return restSong; }); } private convertAudioStationPlaylistsToRest(playlists: AudioStationPlaylist[]): NavidromeRestPlaylist[] { return playlists.map(playlist => { const restPlaylist: NavidromeRestPlaylist = { id: playlist.id, name: playlist.name, path: playlist.path }; return restPlaylist; }); } private convertPlexPlaylistsToRest(playlists: PlexPlaylist[]): NavidromeRestPlaylist[] { return playlists.map(playlist => { const restPlaylist: NavidromeRestPlaylist = { id: playlist.id, name: playlist.title, comment: playlist.summary, duration: playlist.duration, songCount: playlist.leafCount }; return restPlaylist; }); } private async fetchAllJellyfinArtistSongs(account: WebDavAccount, artistId: string): Promise { const results: JellyfinSong[] = []; let startIndex = 0; while (true) { const response = await jellyfinApi.getArtistSongs(account, artistId, startIndex, this.REMOTE_PAGE_SIZE); results.push(...response.items); if (response.nextStart === null) { break; } startIndex = response.nextStart; } return results; } private async fetchAllEmbyArtistSongs(account: WebDavAccount, artistId: string): Promise { const results: EmbySong[] = []; let startIndex = 0; while (true) { const response = await embyApi.getArtistSongs(account, artistId, startIndex, this.REMOTE_PAGE_SIZE); results.push(...response.items); if (response.nextStart === null) { break; } startIndex = response.nextStart; } return results; } private async fetchAllAudioStationArtistSongs(account: WebDavAccount, artistName: string): Promise { if (!artistName || artistName.trim().length === 0) { return []; } const keyword = artistName.trim(); const songs = await audioStationApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT); return songs.filter(song => { const artist = song.artist ?? song.albumArtist ?? ''; return artist.trim() === keyword; }); } private async fetchAllAudioStationPlaylistSongs(account: WebDavAccount, playlistId: string): Promise { const results: AudioStationSong[] = []; let startIndex = 0; while (true) { const response = await audioStationApi.getPlaylistSongsPage(account, playlistId, startIndex, this.REMOTE_PAGE_SIZE); results.push(...response.items); if (response.nextStart === null) { break; } startIndex = response.nextStart; } return results; } private async fetchAllPlexArtistSongs(account: WebDavAccount, artistId: string): Promise { const results: PlexSong[] = []; const albums = await plexApi.getArtistAlbums(account, artistId); for (let i = 0; i < albums.length; i++) { const album = albums[i]; if (!album.id) { continue; } const songs = await plexApi.getAlbumSongs(account, album.id); results.push(...songs); } return results; } private async fetchAllPlexPlaylistSongs(account: WebDavAccount, playlistId: string): Promise { const results: PlexSong[] = []; let startIndex = 0; while (true) { const response = await plexApi.getPlaylistSongsPage(account, playlistId, startIndex, this.REMOTE_PAGE_SIZE); results.push(...response.items); if (response.nextStart === null) { break; } startIndex = response.nextStart; } return results; } private buildSongDedupKey(title?: string, artist?: string, album?: string, durationSeconds?: number): string { const safeTitle = title ?? ''; const safeArtist = artist ?? ''; const safeAlbum = album ?? ''; const safeDuration = durationSeconds !== undefined && durationSeconds !== null ? durationSeconds.toString() : ''; return `${safeTitle}__${safeArtist}__${safeAlbum}__${safeDuration}`.toLowerCase(); } private buildSongKeyFromItem(item: VideoItem): string { const safeTitle = item.name ?? ''; const safeArtist = item.artist ?? ''; const safeAlbum = item.album ?? ''; const safeDuration = item.duration ?? ''; return `${safeTitle}__${safeArtist}__${safeAlbum}__${safeDuration}`.toLowerCase(); } private dedupRemoteSongsByKey(items: VideoItem[]): VideoItem[] { const map = new Map(); for (let i = 0; i < items.length; i++) { const item = items[i]; const key = this.buildSongKeyFromItem(item); const existing = map.get(key); if (!existing) { map.set(key, item); continue; } const existingHasAlbumId = StrUtil.isNotEmpty(existing.navAlbumId); const currentHasAlbumId = StrUtil.isNotEmpty(item.navAlbumId); if (!existingHasAlbumId && currentHasAlbumId) { map.set(key, item); } } return Array.from(map.values()); } private convertApiSongsToRestSongs(songs: NavidromeSong[]): NavidromeRestSong[] { return songs.map((song: NavidromeSong): NavidromeRestSong => ({ id: song.id, title: song.title, artist: song.artist, artistId: song.artistId, album: song.album, albumId: song.albumId, duration: song.duration, bitRate: song.bitRate, suffix: song.suffix, size: song.size, createdAt: song.created, genre: song.genre, track: song.track, year: song.year, contentType: song.contentType, coverArt: song.coverArt, coverArtId: song.coverArt, lyrics: song.lyrics, })); } private async convertSongsToVideoItems(songs: NavidromeRestSong[], account: WebDavAccount): Promise { const tasks: Promise[] = []; let successCount = 0; let failedCount = 0; void ServerLogUtil.info('SongConvert', `开始转换 ${songs.length} 首歌曲为 VideoItem`); for (let i = 0; i < songs.length; i++) { const song = songs[i]; tasks.push((async () => { try { let coverUrl: string | undefined; try { coverUrl = await this.resolveSongCover(song, account); } catch (error) { void ServerLogUtil.warn('SongConvert', `歌曲 ${song.title} 封面解析失败: ${(error as Error).message}`); } const videoItem = this.convertSongToVideoItem(song, account, coverUrl); successCount++; // 记录前几首歌的详细信息用于调试 if (i < 3) { void ServerLogUtil.debug('SongConvert', `歌曲 ${i + 1}: ${videoItem.name}, 封面: ${coverUrl ? '有' : '无'}, 路径: ${videoItem.filePath}`); } return videoItem; } catch (error) { failedCount++; void ServerLogUtil.error('SongConvert', `歌曲 ${song.title} 转换失败: ${(error as Error).message}`); throw new Error(`歌曲 ${song.title} 转换失败: ${(error as Error).message}`); } })()); } const results = await Promise.all(tasks); void ServerLogUtil.info('SongConvert', `歌曲转换完成: 成功 ${successCount}, 失败 ${failedCount}`); return results; } private async resolveSongCover(song: NavidromeRestSong, account: WebDavAccount): Promise { void ServerLogUtil.debug('RemoteMusicPageCover', `🎵 解析歌曲封面 - title: ${song.title}, albumId: ${song.albumId}, id: ${song.id}`); // 注释掉这个代码可以解决NavidRome部分账号没有封面问题 // if (this.isNavidromeAccount(account) && song.albumId) { // const albumCover = song.albumId ? this.albumCoverLookup.get(song.albumId) : undefined; // if (albumCover) { // void ServerLogUtil.debug('RemoteMusicPageCover', `✅ 歌曲使用专辑封面缓存 - title: ${song.title}, albumCover: ${albumCover}`); // return albumCover; // } // } if (!this.isNavidromeAccount(account)) { const fallbackId = song.coverArtId ?? song.albumId ?? song.id; void ServerLogUtil.debug('RemoteMusicPageCover', `歌曲使用非Navidrome账号封面 - title: ${song.title}, fallbackId: ${fallbackId}`); return this.buildCoverUrl(account, fallbackId); } const directUrl = this.resolveEmbedCover(account, song.embedArtPath ?? song.coverArtPath); if (directUrl) { void ServerLogUtil.info('RemoteMusicPageCover', `✅ 歌曲使用直接封面 - title: ${song.title}, url: ${directUrl}`); return directUrl; } const coverId = song.coverArt ?? song.coverArtId ?? song.id; void ServerLogUtil.debug('RemoteMusicPageCover', `歌曲生成封面URL - title: ${song.title}, coverArt: ${song.coverArt}, coverArtId: ${song.coverArtId}, 最终coverId: ${coverId}`); return this.buildCoverUrl(account, coverId); } private async buildCoverUrl(account: WebDavAccount, coverId?: string, size: number = 300): Promise { void ServerLogUtil.info('RemoteMusicPageCover', `开始构建封面URL - coverId: ${coverId}, size: ${size}, 账号: ${ServerLogUtil.sanitizeAccount(account)}`); if (!coverId || coverId.trim().length === 0) { void ServerLogUtil.warn('RemoteMusicPageCover', `封面ID为空,跳过构建 - coverId: "${coverId}"`); return undefined; } const normalizedId = coverId.trim(); const cacheKey = `${normalizedId}_${size}`; if (this.isNavidromeAccount(account)) { const cached = this.coverUrlCache.get(cacheKey); if (cached) { void ServerLogUtil.info('RemoteMusicPageCover', `✅ 缓存命中 - coverId: ${coverId}, size: ${size}, url: ${cached}`); return cached; } } void ServerLogUtil.info('RemoteMusicPageCover', `⚡ 调用API生成封面URL - coverId: ${coverId}, size: ${size}`); let url: string | undefined = undefined; try { if (this.isNavidromeAccount(account)) { void ServerLogUtil.debug('RemoteMusicPageCover', `[开始] 调用 navidromeApi.buildCoverArtUrl - coverId: ${normalizedId}, size: ${size}`); void ServerLogUtil.debug('RemoteMusicPageCover', `[账号信息] host=${account.host}, port=${account.port}, enableHttps=${account.enableHttps}`); void ServerLogUtil.debug('RemoteMusicPageCover', `[账号信息] basePath=${account.navidromeBasePath}`); url = await navidromeApi.buildCoverArtUrl(account, normalizedId, size); void ServerLogUtil.debug('RemoteMusicPageCover', `[完成] navidromeApi.buildCoverArtUrl 返回 - coverId: ${normalizedId}, 返回值: "${url}"`); if (url) { void ServerLogUtil.info('RemoteMusicPageCover', `✅ API返回URL成功 - coverId: ${coverId}, url: ${url}`); } else { void ServerLogUtil.warn('RemoteMusicPageCover', `⚠️ API返回空值 - coverId: ${coverId}`); } } else if (this.isJellyfinAccount(account)) { void ServerLogUtil.debug('RemoteMusicPageCover', `调用 jellyfinApi.buildPrimaryImageUrl - coverId: ${normalizedId}, size: ${size}`); url = await jellyfinApi.buildPrimaryImageUrl(account, normalizedId, size, size); } else if (this.isEmbyAccount(account)) { void ServerLogUtil.debug('RemoteMusicPageCover', `调用 embyApi.buildPrimaryImageUrl - coverId: ${normalizedId}, size: ${size}`); url = await embyApi.buildPrimaryImageUrl(account, normalizedId, size, size); } else if (this.isAudioStationAccount(account)) { if (this.isAudioStationSongCoverId(normalizedId)) { const songId = this.stripAudioStationSongCoverId(normalizedId); void ServerLogUtil.debug('RemoteMusicPageCover', `调用 audioStationApi.buildSongCoverUrl - songId=${songId}`); url = await audioStationApi.buildSongCoverUrl(account, songId); } else { const key = this.parseAudioStationAlbumKey(normalizedId); void ServerLogUtil.debug('RemoteMusicPageCover', `调用 audioStationApi.buildAlbumCoverUrl - album=${key.name}, artist=${key.artist}`); url = await audioStationApi.buildAlbumCoverUrl(account, key.name, key.artist); } } else if (this.isPlexAccount(account)) { void ServerLogUtil.debug('RemoteMusicPageCover', `调用 plexApi.buildImageUrl - coverId: ${normalizedId}`); url = plexApi.buildImageUrl(account, normalizedId); } else if (this.isDaoLiYuAccount(account)) { void ServerLogUtil.debug('RemoteMusicPageCover', `调用 daoLiYuApi.buildImageUrl - coverId: ${normalizedId}`); url = daoLiYuApi.buildImageUrl(account, normalizedId); } if (url && this.isNavidromeAccount(account)) { this.coverUrlCache.set(cacheKey, url); void ServerLogUtil.info('RemoteMusicPageCover', `✅ 封面URL生成成功并已缓存 - coverId: ${coverId}, url: ${url}`); void ServerLogUtil.debug('RemoteMusicPageCover', `缓存键值 - cacheKey: ${cacheKey}`); } else if (!url) { void ServerLogUtil.error('RemoteMusicPageCover', `❌ 封面URL生成失败(返回空) - coverId: ${coverId}, size: ${size}`); } else { void ServerLogUtil.info('RemoteMusicPageCover', `✅ 封面URL生成成功(非Navidrome账号) - coverId: ${coverId}, url: ${url}`); } } catch (error) { const err = error as Error; void ServerLogUtil.error('RemoteMusicPageCover', `❌ 封面URL生成异常 - coverId: ${coverId}, error: ${err.message}`); void ServerLogUtil.error('RemoteMusicPageCover', `错误类型: ${err.name || 'Unknown'}`); void ServerLogUtil.error('RemoteMusicPageCover', `错误堆栈: ${err.stack || '无'}`); } return url; } private isAudioStationSongCoverId(value: string): boolean { return value.startsWith('as-song:'); } private stripAudioStationSongCoverId(value: string): string { return value.replace(/^as-song:/, ''); } /** * 记录账号缓存信息 */ private async logAccountCacheInfo(account: WebDavAccount): Promise { try { void ServerLogUtil.info('AccountInfo', `账号信息: ${ServerLogUtil.sanitizeAccount(account)}`); // 记录缓存路径信息 const cachePreview = this.getCachePreviewInfo(account); if (cachePreview) { void ServerLogUtil.info('AccountInfo', `Navidrome缓存目录结构:`); void ServerLogUtil.info('AccountInfo', `- 缓存根目录: ${cachePreview.cacheRoot}`); void ServerLogUtil.info('AccountInfo', `- 示例缓存路径: ${cachePreview.examplePath}`); } else { void ServerLogUtil.warn('AccountInfo', '无法预览缓存目录:上下文或根目录不可用'); } // 记录封面缓存状态 void ServerLogUtil.info('AccountInfo', `当前封面URL缓存数量: ${this.coverUrlCache.size}`); if (this.coverUrlCache.size > 0) { const cacheKeys = Array.from(this.coverUrlCache.keys()).slice(0, 10); void ServerLogUtil.debug('AccountInfo', `封面缓存示例: ${cacheKeys.join(', ')}`); } } catch (error) { void ServerLogUtil.error('AccountInfo', `记录账号信息失败: ${(error as Error).message}`); } } /** * 记录缓存统计信息 */ private async logCacheStatistics(account: WebDavAccount): Promise { try { void ServerLogUtil.info('CacheStats', '=== Navidrome 缓存统计 ==='); void ServerLogUtil.info('CacheStats', `封面URL缓存: ${this.coverUrlCache.size} 个条目`); void ServerLogUtil.info('CacheStats', `已加载歌曲: ${this.allVideos.length} 首`); void ServerLogUtil.info('CacheStats', `已处理艺术家: ${this.artists.length} 位`); void ServerLogUtil.info('CacheStats', `已处理专辑: ${this.albums.length} 张`); // 计算封面统计 const songsWithCover = this.allVideos.filter(song => song.pixelMapPath && song.pixelMapPath.length > 0).length; const albumsWithCover = this.albums.filter(album => album.coverUrl && album.coverUrl.length > 0).length; const artistsWithCover = this.artists.filter(artist => artist.coverUrl && artist.coverUrl.length > 0).length; void ServerLogUtil.info('CacheStats', `封面统计:`); void ServerLogUtil.info('CacheStats', `- 歌曲有封面: ${songsWithCover}/${this.allVideos.length} (${Math.round(songsWithCover / this.allVideos.length * 100)}%)`); void ServerLogUtil.info('CacheStats', `- 专辑有封面: ${albumsWithCover}/${this.albums.length} (${Math.round(albumsWithCover / this.albums.length * 100)}%)`); void ServerLogUtil.info('CacheStats', `- 艺术家有封面: ${artistsWithCover}/${this.artists.length} (${Math.round(artistsWithCover / this.artists.length * 100)}%)`); } catch (error) { void ServerLogUtil.error('CacheStats', `记录缓存统计失败: ${(error as Error).message}`); } } private resolveEmbedCover(account: WebDavAccount, path?: string): string | undefined { if (!this.isNavidromeAccount(account)) { return undefined; } return navidromeRestApi.resolveResourceUrl(account, path); } private resolveLibraryInfo(account: WebDavAccount): LibraryInfo { if (this.isJellyfinAccount(account)) { return { type: CommonConstants.TYPE_JELLYFIN, scheme: 'jellyfin' } as LibraryInfo; } if (this.isEmbyAccount(account)) { return { type: CommonConstants.TYPE_EMBY, scheme: 'emby' } as LibraryInfo; } if (this.isAudioStationAccount(account)) { return { type: CommonConstants.TYPE_AUDIOSTATION, scheme: 'audiostation' } as LibraryInfo; } if (this.isPlexAccount(account)) { return { type: CommonConstants.TYPE_PLEX, scheme: 'plex' } as LibraryInfo; } if (this.isDaoLiYuAccount(account)) { return { type: CommonConstants.TYPE_DAOLIYU, scheme: 'daoliyu' } as LibraryInfo; } return { type: CommonConstants.TYPE_NAVIDROME, scheme: 'navidrome' } as LibraryInfo; } private convertSongToVideoItem(song: NavidromeRestSong, account: WebDavAccount, coverUrl?: string): VideoItem { const title = song.title ?? Constants.UNKNOWN_TITLE; const libraryInfo = this.resolveLibraryInfo(account); const fileName = buildRemoteSongFileName(song); // 调试日志:记录从playlist或其他API获取的歌曲的id字段 void ServerLogUtil.debug('SongConvert', `转换歌曲: ${title}`); void ServerLogUtil.debug('SongConvert', `- song.id: ${song.id}`); void ServerLogUtil.debug('SongConvert', `- song.artistId: ${song.artistId}`); void ServerLogUtil.debug('SongConvert', `- song.albumId: ${song.albumId}`); void ServerLogUtil.debug('SongConvert', `- song.lyrics: ${song.lyrics}`); const videoItem = new VideoItem( title, song.id, `${libraryInfo.scheme}://${account.id ?? 0}/${song.id}`, libraryInfo.type, song.size ?? 0, song.createdAt ?? '', Utility.formatFSize(song.size ?? 0), undefined, song.artist ?? Constants.UNKNOWN_ARTIST, song.album ?? '', fileName ); const durationStr = this.formatSongDuration(song.duration); if (durationStr) { videoItem.duration = durationStr; } videoItem.size = Utility.formatFSize(song.size ?? 0); videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined; videoItem.genre = song.genre; videoItem.webdav_account_id = account.id?.toString(); videoItem.remote_rel_path = song.id; videoItem.navArtistId = song.artistId; videoItem.navAlbumId = song.albumId; videoItem.pixelMapPath = coverUrl; videoItem.lyricContent = song.lyrics; // 调试日志:检查歌曲的 albumId 和 artistId // if (this.allVideos.length < 3) { // Logger.info('heanup', `歌曲 ${title}: artistId=${song.artistId}, albumId=${song.albumId}, artist=${song.artist}, album=${song.album}`); // } if (song.track !== undefined && song.track !== null) { videoItem.track = song.track.toString(); } if (song.year !== undefined && song.year !== null) { videoItem.year = song.year.toString(); } if (song.contentType) { videoItem.mimeType = song.contentType; } applyInitialRemoteSongQuality(videoItem, song, fileName); // 记录最终生成的VideoItem路径 void ServerLogUtil.debug('SongConvert', `- VideoItem.filePath: ${videoItem.filePath}`); void ServerLogUtil.debug('SongConvert', `- VideoItem.remote_rel_path: ${videoItem.remote_rel_path}`); return videoItem; } private formatSongDuration(durationSeconds?: number): string | undefined { if (durationSeconds === undefined || durationSeconds === null || durationSeconds < 0) { return undefined; } const totalSeconds = Math.floor(durationSeconds); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; const pad = (value: number) => value.toString().padStart(2, '0'); return `${pad(minutes)}:${pad(seconds)}`; } @Builder topTitleBar() { Column() { Row({ space: 6 }) { // 标题或搜索框 if (!this.isSearchMode) { // 详情视图模式下显示返回按钮 if (this.isDetailView) { Button({ type: ButtonType.Circle, stateEffect: true }) { SymbolGlyph($r('sys.symbol.chevron_left')) .attributeModifier(new SymbolGlyphFancyModifier(24, '', '')) } .attributeModifier(new ButtonFancyModifier(40, 40)) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .animation({ duration: 300, curve: Curve.Ease }) .onClick(() => { // 返回到之前的标签页 this.isDetailView = false; this.clearFilter(); }) .attributeModifier(new ShadowModifier()) .zIndex(0) Text(this.filterLabel) .margin({ left: 3, right: 10 }) .fontColor($r('app.color.text_color')) .fontSize(18) .maxLines(1) .textOverflow({ overflow: TextOverflow.MARQUEE }) .layoutWeight(1) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .animation({ duration: 300, curve: Curve.Ease }) } else { // 正常模式:侧边栏按钮和账号名 Button({ type: ButtonType.Circle, stateEffect: true }) { SymbolGlyph($r('sys.symbol.sort')) .attributeModifier(new SymbolGlyphFancyModifier(25, '', '')) } .attributeModifier(new ButtonFancyModifier(40, 40)) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .animation({ duration: 300, curve: Curve.Ease }) .onClick(() => { this.getUIContext().animateTo({ duration: 555 }, () => { // 动画闭包内控制Image组件的出现和消失 this.isShowDrawer = !this.isShowDrawer this.offsetX = 0 }) }) .attributeModifier(new ShadowModifier()) .zIndex(0) Text(this.selectedAccount.name) .margin({ left: 3, right: 10 }) .fontColor($r('app.color.text_color')) .fontSize(18) .maxLines(1) .textOverflow({ overflow: TextOverflow.MARQUEE }) .layoutWeight(1) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .onClick(() => { // 可以添加标题点击事件 }) .animation({ duration: 300, curve: Curve.Ease }) } } else { Button({ type: ButtonType.Circle, stateEffect: true }) { SymbolGlyph($r('sys.symbol.chevron_left')) .attributeModifier(new SymbolGlyphFancyModifier(24, '', '')) } .attributeModifier(new ButtonFancyModifier(40, 40)) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .animation({ duration: 300, curve: Curve.Ease }) .onClick(() => { this.isSearchMode = false; void this.onSearchInput(''); }) .attributeModifier(new ShadowModifier()) .zIndex(0) } // 搜索框 Search({ value: this.searchText, placeholder: '搜索标题、艺术家...' }) .searchButton('搜索', { fontColor: this.themeColor }) .searchIcon({ src: $r('sys.media.ohos_ic_public_search_filled') }) .cancelButton({ style: CancelButtonStyle.CONSTANT, icon: { src: $r('sys.media.ohos_ic_public_cancel_filled') } }) .layoutWeight(1) .height(35) .maxLength(20) .backgroundColor(this.isDarkMode?Color.Black:'#F5F5F5') .placeholderColor(Color.Grey) .placeholderFont({ size: 14, weight: 400 }) .textFont({ size: 14, weight: 400 }) .onSubmit((value: string) => { this.commitSearchHistory(value); void this.onSearchInput(value); }) .onChange((value: string) => { void this.onSearchInput(value); }) .visibility(this.isSearchMode?Visibility.Visible:Visibility.None) .animation({ duration: 300, curve: Curve.Ease }) // 搜索/排序按钮 if (!this.isSearchMode) { // 搜索按钮 Button({ type: ButtonType.Circle, stateEffect: true }) { SymbolGlyph($r('sys.symbol.magnifyingglass')) .attributeModifier(new SymbolGlyphFancyModifier(25, '', '')) } .attributeModifier(new ButtonFancyModifier(40, 40)) .animation({ duration: 300, curve: Curve.Ease }) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .attributeModifier(new ShadowModifier()) .zIndex(0) .visibility(this.selectedTab==0?Visibility.Visible:Visibility.None) .onClick(() => { this.isSearchMode = true; this.loadSearchHistory(); if (!this.isSearchLoading && this.searchText.length === 0) { this.filteredList = []; } }) // 排序按钮 Button({ type: ButtonType.Circle, stateEffect: true }) { SymbolGlyph($r('sys.symbol.list_number')) .attributeModifier(new SymbolGlyphFancyModifier(25, '', '')) } .attributeModifier(new ButtonFancyModifier(40, 40)) .animation({ duration: 300, curve: Curve.Ease }) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 }) .bindMenu(this.SortMenuBuilder) .attributeModifier(new ShadowModifier()) .zIndex(0) } } .width('100%') .height(55) .padding({ left: 15, right: 15 }) .justifyContent(FlexAlign.SpaceBetween) .alignItems(VerticalAlign.Center) // 分段按钮 SegmentButton({ options: this.tabOptions, selectedIndexes: $tabSelectedIndexes }) .width('100%') .padding({ left: 25, right: 25, top: 5, bottom: 5 }) } .width('100%') .padding({ top: this.topSafeHeight + 5 }) .translate({ x: 0, y: (this.isShowTitleBar || !this.autoHideTitle) ? 0 : -80 }) .opacity((this.isShowTitleBar || !this.autoHideTitle) ? 1 : 0) .animation({ duration: 240, curve: Curve.EaseInOut }) // .backgroundColor($r('app.color.start_window_background')) } @Builder SortMenuBuilder() { Menu() { MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')), content: $r('app.string.sort_by_name'), symbolEndIcon: this.sortType === 0 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined }) .backgroundColor(this.getSortItemBackground(0)) .onClick(async () => { this.doSortType(0); PreferencesUtil.put("navidromeSortType", 0); }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')), content: '按名称降序', symbolEndIcon: this.sortType === 1 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined }) .backgroundColor(this.getSortItemBackground(1)) .onClick(async () => { this.doSortType(1); PreferencesUtil.put("navidromeSortType", 1); }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), content: '按艺术家升序', symbolEndIcon: this.sortType === 2 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined }) .backgroundColor(this.getSortItemBackground(2)) .onClick(async () => { this.doSortType(2); PreferencesUtil.put("navidromeSortType", 2); }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.timer')), content: '按艺术家降序', symbolEndIcon: this.sortType === 3 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined }) .backgroundColor(this.getSortItemBackground(3)) .onClick(async () => { this.doSortType(3); PreferencesUtil.put("navidromeSortType", 3); }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')), content: '按专辑升序', symbolEndIcon: this.sortType === 4 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined }) .backgroundColor(this.getSortItemBackground(4)) .onClick(async () => { this.doSortType(4); PreferencesUtil.put("navidromeSortType", 4); }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')), content: '按专辑降序', symbolEndIcon: this.sortType === 5 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined }) .backgroundColor(this.getSortItemBackground(5)) .onClick(async () => { this.doSortType(5); PreferencesUtil.put("navidromeSortType", 5); }) }.attributeModifier(new MenuModifier()) } private getSortItemBackground(sortType: number): ResourceColor { if (this.sortType !== sortType) { return Color.Transparent; } const isAsc: boolean = sortType % 2 === 0; if (isAsc) { return this.isDarkMode ? '#295B8A' : '#DCEEFF'; } return this.isDarkMode ? '#7A4D22' : '#FFE9D5'; } doSortType(index: number) { this.sortType = index; const songs = this.isSearchMode && this.searchText.length > 0 ? this.filteredList : this.allVideos; const sortTypeNames = ['名称升序', '名称降序', '艺术家升序', '艺术家降序', '专辑升序', '专辑降序']; const sortTypeName = sortTypeNames[index] || '未知排序'; // 记录排序操作 void ServerLogUtil.info('NavidromeSort', `应用排序: ${sortTypeName} (${index})`); void ServerLogUtil.info('NavidromeSort', `排序范围: ${songs.length} 首歌曲 (${this.isSearchMode ? '搜索结果' : '全部歌曲'})`); const startTime = Date.now(); // 对歌曲列表进行排序 switch (index) { case 0: // 名称升序 Utility.doSortListAscending(songs,false) break; case 1: // 名称降序 Utility.doSortListDescending(songs,true) break; case 2: // 艺术家升序 songs.sort((a, b) => { // 处理艺术家可能为undefined的字符串比较 const artistA = a.artist?.trim() || ''; // 可选添加 trim() 处理空格 const artistB = b.artist?.trim() || ''; return artistA.localeCompare(artistB); }); break; case 3: // 艺术家降序 songs.sort((a, b) => { // 处理艺术家可能为undefined的字符串比较 const artistA = a.artist?.trim() || ''; // 可选添加 trim() 处理空格 const artistB = b.artist?.trim() || ''; return artistB.localeCompare(artistA); }); break; case 4: // 专辑升序 songs.sort((a, b) => { const albumA = a.album?.trim() || ''; // 可选添加 trim() 处理空格 const albumB = b.album?.trim() || ''; return albumA.localeCompare(albumB); }); break; case 5: // 专辑降序 songs.sort((a, b) => { const albumA = a.album?.trim() || ''; // 可选添加 trim() 处理空格 const albumB = b.album?.trim() || ''; return albumB.localeCompare(albumA); }); break; } const sortTime = Date.now() - startTime; // 更新显示列表 if (this.isSearchMode && this.searchText.length > 0) { this.filteredList = [...songs]; } else { this.allVideos = [...songs]; } this.songDataSource.pushArrayData(this.getVisibleSongs()); // 记录排序结果 void ServerLogUtil.info('NavidromeSort', `排序完成: ${sortTypeName}`); void ServerLogUtil.info('NavidromeSort', `- 排序耗时: ${sortTime}ms`); void ServerLogUtil.debug('NavidromeSort', `排序结果示例: ${songs.slice(0, 3).map(s => `${s.name} (${s.artist})`).join(', ')}`); } // 实时搜索逻辑(改为远程API搜索) private async onSearchInput(value: string): Promise { const keyword = value.trim(); this.searchText = keyword; const ticket = ++this.searchTicket; void ServerLogUtil.debug('NavidromeSearch', `搜索输入: "${value}" -> "${keyword}"`); if (keyword.length === 0) { this.loadSearchHistory(); this.filteredList = []; this.isSearchLoading = false; this.songDataSource.pushArrayData(this.getVisibleSongs()); void ServerLogUtil.info('NavidromeSearch', '搜索已清空,显示所有歌曲'); return; } if (!this.isSearchMode) { this.isSearchMode = true; } const account = this.resolveActiveAccount(); if (!account) { this.filteredList = []; this.isSearchLoading = false; ToastUtil.showToast('媒体库账号不可用'); return; } this.isSearchLoading = true; this.filteredList = []; const startTime = Date.now(); void ServerLogUtil.info('NavidromeSearch', `开始远程搜索: "${keyword}"`); try { let restSongs: NavidromeRestSong[] = []; if (this.isNavidromeAccount(account)) { const songs = await navidromeApi.searchSongs(account, keyword, NAVIDROME_SEARCH_LIMIT); restSongs = this.convertApiSongsToRestSongs(songs); } else if (this.isJellyfinAccount(account)) { const response = await jellyfinApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT); restSongs = this.convertJellyfinSongsToRestSongs(response.items); } else if (this.isEmbyAccount(account)) { const response = await embyApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT); restSongs = this.convertEmbySongsToRestSongs(response.items); } else if (this.isAudioStationAccount(account)) { const songs = await audioStationApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT); restSongs = this.convertAudioStationSongsToRestSongs(songs); } else if (this.isPlexAccount(account)) { const response = await plexApi.searchSongs(account, keyword, 0, this.REMOTE_SEARCH_LIMIT); restSongs = this.convertPlexSongsToRestSongs(response.items); } else if (this.isDaoLiYuAccount(account)) { const tracks = await daoLiYuApi.searchTracks(account, keyword, this.REMOTE_SEARCH_LIMIT); restSongs = this.convertDaoLiYuSongsToRestSongs(tracks); } if (ticket !== this.searchTicket) { return; } const searchTime = Date.now() - startTime; if (!restSongs || restSongs.length === 0) { this.filteredList = []; this.songDataSource.pushArrayData(this.getVisibleSongs()); void ServerLogUtil.warn('NavidromeSearch', `搜索无结果: "${keyword}"`); void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`); return; } const videoItems = await this.convertSongsToVideoItems(restSongs, account); if (ticket !== this.searchTicket) { return; } this.filteredList = videoItems; this.doSortType(this.sortType); void ServerLogUtil.info('NavidromeSearch', `搜索完成: "${keyword}"`); void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`); void ServerLogUtil.info('NavidromeSearch', `- 匹配结果: ${videoItems.length}`); void ServerLogUtil.debug('NavidromeSearch', `搜索结果示例: ${videoItems.slice(0, 3).map(s => `${s.name} (${s.artist})`).join(', ')}`); } catch (error) { if (ticket !== this.searchTicket) { return; } this.filteredList = []; this.songDataSource.pushArrayData(this.getVisibleSongs()); const message = (error as Error).message ?? 'Navidrome 搜索失败'; ToastUtil.showToast(message); void ServerLogUtil.error('NavidromeSearch', `搜索失败: ${message}`); void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${Date.now() - startTime}ms`); } finally { if (ticket === this.searchTicket) { this.isSearchLoading = false; } } } private getCurrentCount(): number { switch (this.selectedTab) { case 1: return this.artists.length; case 2: return this.albums.length; case 3: return this.playlists.length; default: return this.getVisibleSongs().length; } } private getEmptyTitle(): string { switch (this.selectedTab) { case 1: return '暂无艺术家'; case 2: return '暂无专辑'; case 3: return '暂无歌单'; default: if (this.isSearchMode && this.searchText.length > 0) { return this.isSearchLoading ? '正在搜索远程歌曲' : '没有匹配的远程歌曲'; } if (this.filterType !== NavFilterType.None && this.isFilterLoading) { return '正在加载筛选结果'; } return this.filterType === NavFilterType.None ? '暂无音乐' : '该筛选下暂无歌曲'; } } private getEmptySubtitle(): string { switch (this.selectedTab) { case 1: return '当前筛选没有找到艺术家'; case 2: return '当前筛选没有找到专辑'; case 3: return '当前筛选没有找到歌单'; default: if (this.isSearchMode && this.searchText.length > 0) { return this.isSearchLoading ? '请稍候,正在通过 API 搜索' : '换个关键词再试试吧'; } if (this.filterType !== NavFilterType.None && this.isFilterLoading) { return '请稍候...'; } return this.filterType === NavFilterType.None ? '当前分类下没有找到音乐文件' : '请尝试调整筛选条件'; } } @Builder buildSongItem(song: VideoItem, index: number) { Button({ type: ButtonType.Capsule, stateEffect: true }) { Row({ space: 12 }) { // 歌曲封面 Image(song.pixelMapPath ? song.pixelMapPath : $r('app.media.nocover')) .height(this.twoFingerType == 3 ? 55 : this.twoFingerType == 2 ? 50 : 40) .width(this.twoFingerType == 3 ? 55 : this.twoFingerType == 2 ? 50 : 40) .borderRadius(9) .sourceSize({ width: 38, height: 38 }) .draggable(false) .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿 .autoResize(true) // 重采样,可减少内存占用 .alt($r('app.media.nocover')) .fillColor(song.pixelMapPath ? undefined : this.themeColor) .objectFit(ImageFit.Cover) .margin({ left: 20 }) .animation({ duration: 500, curve: Curve.Friction // 可选动画曲线 }) .shadow({ radius: StrUtil.isEmpty(song.pixelMapPath) ?6:14, type: ShadowType.BLUR, color: 'on_primary' }) .onClick(() => { this.playSong(song, index, true); }) // 歌曲信息 Column({ space: 4 }) { Text(song.name) .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18) .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color')) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row() { if (StrUtil.isNotEmpty(song.md5Str)) { Text(this.buildSongQualityLabel(song)) .fontSize(this.twoFingerType == 1 ? 8 : 10) .fontColor($r('app.color.index_tab_font_color')) .fontWeight(500) .padding({ top: 2, right: 6, left: 6, bottom: 2 }) .borderRadius(4) .backgroundColor('#FFC107') .opacity(0.92) .margin({ right: 6 }) } Text((song.artist ?? '') + " ") .fontSize(this.twoFingerType == 1 ? 11 : this.twoFingerType == 2 ? 12 : 14) .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color')) .opacity(0.6) .maxLines(1) .visibility(song.artist ? Visibility.Visible : Visibility.None) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.buildSongMetaLine(song)) .fontSize(this.twoFingerType == 1 ? 11 : this.twoFingerType == 2 ? 12 : 14) .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color')) .opacity(0.6) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('90%') } .alignItems(HorizontalAlign.Start) .layoutWeight(1) .padding({ right: 20 }) Column() { PlayingIndicator({ isActive: this.currentSong?.filePath == song.filePath, indicatorSize: 18, marginRight: 12, marginTop: 8, marginBottom: 8 }) } } } .width('100%') .padding(12) .height(this.twoFingerType == 3 ? ITEM_HEIGHT_BIG : this.twoFingerType == 2 ? ITEM_HEIGHT : ITEM_HEIGHT_SMALL) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 }) .backgroundColor(Color.Transparent) .onClick(() => { this.playSong(song, index); }) } @Builder buildArtistItem(artist: NavidromeRestArtist) { Button({ type: ButtonType.Capsule, stateEffect: true }) { Row({ space: 12 }) { Image(artist.coverUrl ?? $r('app.media.nocover')) .width(48) .height(48) .borderRadius(10) .draggable(false) .alt($r('app.media.nocover')) // .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿 // .autoResize(true) // 重采样,可减少内存占用 .sourceSize({ width: 38, height: 38 }) .fillColor(artist.coverUrl ? undefined : this.themeColor) .objectFit(ImageFit.Cover) .margin({ left: 20 }) Column({ space: 4 }) { Text(artist.name ?? Constants.UNKNOWN_ARTIST) .fontSize(15) .fontColor($r('app.color.index_tab_font_color')) .maxLines(1) .textAlign(TextAlign.Start) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.buildArtistMetaLine(artist)) .fontSize(13) .fontColor($r('app.color.index_tab_font_color')) .opacity(0.6) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .visibility((artist.albumCount ?? 0) === 0 && (artist.songCount ?? 0) === 0 ? Visibility.None : Visibility.Visible) } .alignItems(HorizontalAlign.Start) .padding({ right: 20 }) } .width('100%') } .width('100%') .padding(12) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 }) .backgroundColor(Color.Transparent) .onClick(() => { this.onArtistSelected(artist); }) } @Builder buildAlbumItem(album: NavidromeRestAlbum) { Button({ type: ButtonType.Capsule, stateEffect: true }) { Row({ space: 12 }) { Image(album.coverUrl ?? $r('app.media.nocover')) .width(48) .height(48) .borderRadius(10) .clip(true) .alt($r('app.media.nocover')) .draggable(false) // .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿 // .autoResize(true) // 重采样,可减少内存占用 .sourceSize({ width: 38, height: 38 }) .fillColor(album.coverUrl ? undefined : this.themeColor) .objectFit(ImageFit.Cover) .margin({ left: 20 }) Column({ space: 4 }) { Text(album.name ?? '未知专辑') .fontSize(15) .fontColor($r('app.color.index_tab_font_color')) .maxLines(1) .textAlign(TextAlign.Start) .textOverflow({ overflow: TextOverflow.Ellipsis }) Row() { Text(album.artist ?? Constants.UNKNOWN_ARTIST) .fontSize(13) .fontColor($r('app.color.index_tab_font_color')) .opacity(0.6) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.buildAlbumMetaLine(album)) .fontSize(13) .fontColor($r('app.color.index_tab_font_color')) .opacity(0.6) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } } .alignItems(HorizontalAlign.Start) .padding({ right: 20 }) } .width('100%') } .width('100%') .padding(12) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 }) .backgroundColor(Color.Transparent) .onClick(() => { this.onAlbumSelected(album); }) } @Builder buildPlaylistItem(playlist: NavidromeRestPlaylist) { Button({ type: ButtonType.Capsule, stateEffect: true }) { Row({ space: 12 }) { Image(playlist.coverUrl ?? $r('app.media.nocover')) .width(48) .height(48) .borderRadius(10) .alt($r('app.media.nocover')) .draggable(false) .sourceSize({ width: 38, height: 38 }) .fillColor(playlist.coverUrl ? undefined : this.themeColor) .objectFit(ImageFit.Cover) .margin({ left: 20 }) Column({ space: 4 }) { Text(playlist.name ?? '未知歌单') .fontSize(15) .fontColor($r('app.color.index_tab_font_color')) .maxLines(1) .textAlign(TextAlign.Start) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.buildPlaylistMetaLine(playlist)) .fontSize(13) .fontColor($r('app.color.index_tab_font_color')) .opacity(0.6) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) // if (playlist.ownerName) { // Text(playlist.ownerName) // .fontSize(12) // .fontColor($r('app.color.index_tab_font_color')) // .opacity(0.5) // .maxLines(1) // .textOverflow({ overflow: TextOverflow.Ellipsis }) // } } .alignItems(HorizontalAlign.Start) .padding({ right: 20 }) } .width('100%') } .width('100%') .padding(12) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 }) .backgroundColor(Color.Transparent) .onClick(() => { this.onPlaylistSelected(playlist); }) } @Builder private SearchHistoryView(): void { Column({ space: 10 }) { Row() { Text('搜索历史') .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_color')) Blank() Button('清空') .fontSize(12) .fontColor(this.themeColor) .backgroundColor(Color.Transparent) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .onClick(() => { SearchHistoryUtil.clear(this.searchHistoryScope); this.searchHistoryItems = []; }) } .width('100%') Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.searchHistoryItems, (keyword: string) => { Button(keyword) .fontSize(12) .fontColor($r('app.color.text_color')) .backgroundColor(this.isDarkMode ? '#222222' : '#F2F3F5') .borderRadius(16) .margin({ right: 8, bottom: 8 }) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .onClick(() => { this.applySearchHistory(keyword); }) }) } .width('100%') } .width('100%') .padding({ left: 20, right: 20, top: this.topSafeHeight + 110, bottom: 10 }) } private hydrateSongQuality(items: VideoItem[]): void { if (!items || items.length === 0) { return; } for (let i = 0; i < items.length; i++) { applyInitialQualityToExistingVideoItem(items[i]); } } private buildSongQualityLabel(song: VideoItem): string { return buildSongQualityLabelText(song.md5Str); } private buildSongMetaLine(song: VideoItem): string { const parts: string[] = []; if (song.duration) { parts.push(song.duration as string); } // if (song.size) { // parts.push(song.size as string); // } // if (parts.length === 0 && song.cTime) { // parts.push(song.cTime as string); // } return parts.join(' · '); } private buildArtistMetaLine(artist: NavidromeRestArtist): string { const albumCount = artist.albumCount ?? 0; const songCount = artist.songCount ?? 0; const playCount = artist.playCount ?? 0; // 如果专辑和歌曲数都为0,不显示元数据信息 if (albumCount === 0 && songCount === 0) { return ''; } return `专辑 ${albumCount} · 歌曲 ${songCount} `; } private buildAlbumMetaLine(album: NavidromeRestAlbum): string { const parts: string[] = []; if (album.duration !== undefined) { const duration = this.formatSongDuration(album.duration); if (duration) { parts.push(' '+duration); } } if (album.minYear) { parts.push(`发行 ${album.minYear}`); } return parts.join(' · '); } private buildPlaylistMetaLine(playlist: NavidromeRestPlaylist): string { const parts: string[] = []; if (playlist.songCount !== undefined) { parts.push(`${playlist.songCount} 首歌`); } if (playlist.duration !== undefined) { const duration = this.formatSongDuration(playlist.duration); if (duration) { parts.push(duration); } } return parts.join(' · '); } private getVisibleSongs(): VideoItem[] { if (this.isSearchMode) { return this.searchText.length > 0 ? this.filteredList : this.allVideos; } // 详情视图模式或有筛选时,返回筛选后的歌曲 if (this.isDetailView || this.filterType !== NavFilterType.None) { return this.filterSongs; } return this.allVideos; } private onArtistSelected(artist: NavidromeRestArtist): void { if (!artist || !artist.id) { return; } this.isShowTitleBar = true; // 保存当前标签页,进入详情视图 this.previousTab = this.selectedTab; this.isDetailView = true; // 应用筛选但不切换标签页 void this.applyFilter(NavFilterType.Artist, artist.id, artist.name ?? Constants.UNKNOWN_ARTIST); } private onAlbumSelected(album: NavidromeRestAlbum): void { if (!album || !album.id) { return; } this.isShowTitleBar = true; // 保存当前标签页,进入详情视图 this.previousTab = this.selectedTab; this.isDetailView = true; // 应用筛选但不切换标签页 void this.applyFilter(NavFilterType.Album, album.id, album.name ?? '未知专辑'); } private onPlaylistSelected(playlist: NavidromeRestPlaylist): void { if (!playlist || !playlist.id) { return; } this.isShowTitleBar = true; // 保存当前标签页,进入详情视图 this.previousTab = this.selectedTab; this.isDetailView = true; // 应用筛选但不切换标签页 void this.applyFilter(NavFilterType.Playlist, playlist.id, playlist.name ?? '未知歌单'); } private async applyFilter(type: NavFilterType, id: string, label: string): Promise { // 记录筛选操作 void ServerLogUtil.info('NavidromeFilter', `应用筛选:`); const typeText = type === NavFilterType.Artist ? '艺术家' : type === NavFilterType.Album ? '专辑' : type === NavFilterType.Playlist ? '歌单' : '无'; void ServerLogUtil.info('NavidromeFilter', `- 类型: ${type} (${typeText})`); void ServerLogUtil.info('NavidromeFilter', `- ID: ${id}`); void ServerLogUtil.info('NavidromeFilter', `- 标签: ${label}`); void ServerLogUtil.info('NavidromeFilter', `- 筛选前总歌曲数: ${this.allVideos.length}`); // 先更新状态 this.filterType = type; this.filterId = id; this.filterLabel = label; // 退出搜索模式,确保显示筛选结果 this.isSearchMode = false; this.searchText = ''; this.filteredList = []; this.isSearchLoading = false; this.searchTicket++; this.filterSongs = []; this.songDataSource.pushArrayData([]); // 立即清空数据源,避免闪现旧数据 this.isFilterLoading = true; // 调试日志:检查筛选结果 void this.loadSongsForFilter(label).catch((error: Error) => { void ServerLogUtil.error('NavidromeFilter', `筛选歌曲加载失败: ${error.message}`); ToastUtil.showToast(error.message || '筛选数据加载失败'); }); // 不再自动切换标签页,保持在详情视图模式 } private async loadSongsForFilter(label:string): Promise { if (this.filterType === NavFilterType.None || !this.filterId) { this.filterSongs = []; this.isFilterLoading = false; return; } const account = this.resolveActiveAccount(); if (!account) { this.filterSongs = []; this.isFilterLoading = false; throw new Error('媒体库账号不可用'); } if (!this.isNavidromeAccount(account)) { let restSongs: NavidromeRestSong[] = []; if (this.filterType === NavFilterType.Artist) { if (this.isJellyfinAccount(account)) { const songs = await this.fetchAllJellyfinArtistSongs(account, this.filterId); restSongs = this.convertJellyfinSongsToRestSongs(songs); } else if (this.isEmbyAccount(account)) { const songs = await this.fetchAllEmbyArtistSongs(account, this.filterId); restSongs = this.convertEmbySongsToRestSongs(songs); } else if (this.isAudioStationAccount(account)) { const songs = await this.fetchAllAudioStationArtistSongs(account, this.filterId); restSongs = this.convertAudioStationSongsToRestSongs(songs); } else if (this.isPlexAccount(account)) { const songs = await this.fetchAllPlexArtistSongs(account, this.filterId); restSongs = this.convertPlexSongsToRestSongs(songs); } else if (this.isDaoLiYuAccount(account)) { // 同时传递 artistId 和 artistName,支持按名称匹配作为后备 const songs = await daoLiYuApi.getTracksByArtistId(account, this.filterId, label); restSongs = this.convertDaoLiYuSongsToRestSongs(songs); } } else if (this.filterType === NavFilterType.Album) { if (this.isJellyfinAccount(account)) { const songs = await jellyfinApi.getAlbumSongs(account, this.filterId); restSongs = this.convertJellyfinSongsToRestSongs(songs); } else if (this.isEmbyAccount(account)) { const songs = await embyApi.getAlbumSongs(account, this.filterId); restSongs = this.convertEmbySongsToRestSongs(songs); } else if (this.isAudioStationAccount(account)) { const albumKey = this.parseAudioStationAlbumKey(this.filterId); const songs = await audioStationApi.getAlbumSongs(account, albumKey.name, albumKey.artist); restSongs = this.convertAudioStationSongsToRestSongs(songs); } else if (this.isPlexAccount(account)) { const songs = await plexApi.getAlbumSongs(account, this.filterId); restSongs = this.convertPlexSongsToRestSongs(songs); } else if (this.isDaoLiYuAccount(account)) { // 同时传递 albumId 和 albumName,支持按名称匹配作为后备 const songs = await daoLiYuApi.getTracksByAlbumId(account, this.filterId, label); restSongs = this.convertDaoLiYuSongsToRestSongs(songs); } } else if (this.filterType === NavFilterType.Playlist) { if (this.isAudioStationAccount(account)) { const songs = await this.fetchAllAudioStationPlaylistSongs(account, this.filterId); restSongs = this.convertAudioStationSongsToRestSongs(songs); } else if (this.isPlexAccount(account)) { const songs = await this.fetchAllPlexPlaylistSongs(account, this.filterId); restSongs = this.convertPlexSongsToRestSongs(songs); } else if (this.isDaoLiYuAccount(account)) { const songs = await daoLiYuApi.getPlaylistTracks(account, this.filterId); restSongs = this.convertDaoLiYuSongsToRestSongs(songs); } else { this.filterSongs = []; this.isFilterLoading = false; ToastUtil.showToast('当前媒体库不支持歌单'); return; } } const videoItems = await this.convertSongsToVideoItems(restSongs, account); this.filterSongs = videoItems; this.songDataSource.pushArrayData(this.filterSongs); this.isFilterLoading = false; return; } const expectedFilterId = this.filterId; const expectedFilterType = this.filterType; try { let songs: NavidromeRestSong[] = []; if (this.filterType === NavFilterType.Artist) { songs = await navidromeRestApi.fetchSongsByArtist(account, this.filterId,label); } else if (this.filterType === NavFilterType.Album) { songs = await navidromeRestApi.fetchSongsByAlbum(account, this.filterId,label); } else if (this.filterType === NavFilterType.Playlist) { songs = await navidromeRestApi.fetchSongsByPlaylist(account, this.filterId); } const videoItems = await this.convertSongsToVideoItems(songs, account); if (expectedFilterId !== this.filterId || expectedFilterType !== this.filterType) { return; } this.filterSongs = videoItems; this.songDataSource.pushArrayData(this.filterSongs); if (videoItems.length === 0) { void ServerLogUtil.warn('NavidromeFilter', '筛选下暂无歌曲'); } else { void ServerLogUtil.info('NavidromeFilter', `筛选歌曲加载完成: ${videoItems.length} 首`); } } finally { this.isFilterLoading = false; } } private clearFilter(): void { this.filterType = NavFilterType.None; this.filterId = ''; this.filterLabel = ''; this.filterSongs = []; this.isFilterLoading = false; // 恢复完整列表的数据源 this.songDataSource.pushArrayData(this.allVideos); // 退出详情视图 this.isDetailView = false; } private playSong(song: VideoItem, index: number, isJump: boolean = false): void { try { if (!this.allVideos || this.allVideos.length === 0) { ToastUtil.showToast('暂无可播放的歌曲'); return; } const account = this.resolveActiveAccount(); if (!account || !account.id) { ToastUtil.showToast('媒体库账号信息不完整,无法播放'); return; } // 记录播放详细信息 void ServerLogUtil.info('StreamingPlay', `开始播放歌曲: ${song.name} (#${index})`); void ServerLogUtil.info('StreamingPlay', `歌曲信息:`); void ServerLogUtil.info('StreamingPlay', `- ID: ${song.id}`); void ServerLogUtil.info('StreamingPlay', `- 艺术家: ${song.artist || '未知'}`); void ServerLogUtil.info('StreamingPlay', `- 专辑: ${song.album || '未知'}`); void ServerLogUtil.info('StreamingPlay', `- 文件大小: ${song.size || '未知'}`); void ServerLogUtil.info('StreamingPlay', `- 时长: ${song.duration || '未知'}`); void ServerLogUtil.info('StreamingPlay', `- 封面: ${song.pixelMapPath ? '有' : '无'}`); void ServerLogUtil.info('StreamingPlay', `- 播放路径: ${song.filePath}`); const playlistSource = this.getVisibleSongs().length > 0 ? this.getVisibleSongs() : this.allVideos; const targetIndex = playlistSource.findIndex(item => item.id === song.id); const startIndex = targetIndex >= 0 ? targetIndex : Math.min(index, Math.max(playlistSource.length - 1, 0)); void ServerLogUtil.info('StreamingPlay', `播放列表设置: 起始索引 ${startIndex}, 已加载 ${playlistSource.length}, 服务端总数 ${this.serverTotalSongCount}`); setNavidromePlaylist(playlistSource, startIndex); // 保存服务端总数到全局存储 if (this.serverTotalSongCount > 0) { setNavidromeTotalCount(this.serverTotalSongCount); } const playlistData = new PlaylistPlayRequest( NAVIDROME_PLAYLIST_ID, `${getRemoteDriveDisplayLabel(account.webType)} - ${account.name ?? '未知账户'}`, this.serverTotalSongCount > 0 ? this.serverTotalSongCount : playlistSource.length, startIndex, playlistSource.map(item => item.filePath), isJump ); const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }; savePendingPlaylistPlay(playlistData); emitter.emit(eventPlaylistPlay, { data: playlistData }); void ServerLogUtil.info('StreamingPlay', `播放事件已发送`); void ServerLogUtil.debug('StreamingPlay', `播放事件详情: 列表ID=${playlistData.playlistId}, 列表名称=${playlistData.playlistName}, 歌曲数=${playlistData.songCount}, 起始索引=${playlistData.startIndex}, 是否跳转=${playlistData.isJump}`); } catch (error) { const err = error as Error; void ServerLogUtil.error('StreamingPlay', `播放失败: ${err.message}`); void ServerLogUtil.error('StreamingPlay', `失败歌曲信息: ${song.name} (${song.id})`); ToastUtil.showToast('播放失败'); } } private getPointLightItemKey(prefix: string, value: string): string { return `${prefix}_${value}` } private handlePointLightTouch(itemKey: string, event: TouchEvent): void { if (deviceInfo.sdkApiVersion < 20) { return } if (event.type === TouchType.Down) { this.activePointLightItemKey = itemKey this.pointLightOptions = { color: this.themeColor, intensity: 1, height: 100 } return } if ((event.type === TouchType.Up || event.type === TouchType.Cancel) && this.activePointLightItemKey === itemKey) { this.activePointLightItemKey = '' this.pointLightOptions = undefined } } private getPointLightOptions(itemKey: string): hdsEffect.PointLightOptions | undefined { if (this.activePointLightItemKey !== itemKey) { return undefined } return this.pointLightOptions } @Builder buildContentView(){ // 主内容区域 if (this.loading) { Column() { LoadingProgress() .width(50) .height(50) .color($r('app.color.title_bar_bg')) Text('加载中...') .margin({ top: 10 }) .fontSize(14) .fontColor($r('app.color.index_tab_unselected_font_color')) } .width('100%') .layoutWeight(1) .padding({top:this.topSafeHeight+98}) .justifyContent(FlexAlign.Center) } else { if (this.isSearchMode && this.selectedTab === 0 && this.searchText.length > 0 && this.isSearchLoading) { Column() { LoadingProgress() .width(40) .height(40) .color(this.themeColor) Text('正在通过 API 搜索歌曲...') .margin({ top: 12 }) .fontSize(14) .fontColor($r('app.color.index_tab_unselected_font_color')) } .width('100%') .layoutWeight(1) } else if (this.isDetailView && this.filterType !== NavFilterType.None && this.isFilterLoading) { Column() { LoadingProgress() .width(40) .height(40) .color(this.themeColor) Text('正在加载') .margin({ top: 12 }) .fontSize(14) .fontColor($r('app.color.index_tab_unselected_font_color')) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) } else { if(this.twoFingerType==4){ this.getWaterView() }else{ if(this.isGridMusic){ this.getGridView() }else { this.getListView() } } } if (this.isSearchMode && this.searchText.length === 0 && this.searchHistoryItems.length > 0) { this.SearchHistoryView() } // 空状态 if (this.getCurrentCount() === 0 && !(this.isDetailView && this.filterType !== NavFilterType.None && this.isFilterLoading) && !(this.isSearchMode && this.searchText.length > 0 && this.isSearchLoading)) { Column() { SymbolGlyph($r('sys.symbol.music_fill')) .fontSize(50) .opacity(0.3) Text(this.getEmptyTitle()) .margin({ top: 16 }) .fontSize(16) .fontColor($r('app.color.index_tab_unselected_font_color')) Text(this.getEmptySubtitle()) .margin({ top: 8 }) .fontSize(14) .fontColor($r('app.color.index_tab_unselected_font_color')) } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } } } @State startIndex: number = 0 @State endIndex: number = 0 @State pinchValue: number = 1 layoutOptionsForHeader: GridLayoutOptions = { regularSize: [1, 1], // 只支持[1, 1] irregularIndexes: [0], // 索引为0的GridItem占用一行 }; @Builder getGridView() { Grid(this.scroller, this.layoutOptionsForHeader) { GridItem() { Column(){ Blank().height(this.topSafeHeight + 98) } } if (this.selectedTab === 0||this.isDetailView) { LazyForEach(this.songDataSource, (item: VideoItem, index: number) => { GridItem() { this.buildSongItemGrid(item, index) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_grid_song', item.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_grid_song', item.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (item: VideoItem) => item.id) } else if (this.selectedTab === 1) { LazyForEach(this.artistDataSource, (artist: NavidromeRestArtist) => { GridItem() { this.buildArtistItemGrid(artist) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_grid_artist', artist.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_grid_artist', artist.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (artist: NavidromeRestArtist) => artist.id) } else if (this.selectedTab === 2) { LazyForEach(this.albumDataSource, (album: NavidromeRestAlbum) => { GridItem() { this.buildAlbumItemGrid(album) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_grid_album', album.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_grid_album', album.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (album: NavidromeRestAlbum) => album.id) } else if (this.selectedTab === 3) { LazyForEach(this.playlistDataSource, (playlist: NavidromeRestPlaylist) => { GridItem() { this.buildPlaylistItemGrid(playlist) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_grid_playlist', playlist.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_grid_playlist', playlist.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (playlist: NavidromeRestPlaylist) => playlist.id) } } .width('94%') .height('100%') .editMode(true) .padding({bottom:this.bottomSafeHeight+this.bottomSafeHeight+52}) .reuseId('grid_item') .layoutWeight(1) .cachedCount(this.twoFingerType==1?5:this.twoFingerType==2?4:3) .scrollBar(BarState.Off) .supportAnimation(true) .onReachEnd(() => { void this.handleReachEnd(); }) .columnsTemplate( this.twoFingerType == 3 ? 'repeat(auto-fit, 160)' : this.twoFingerType == 2 ? 'repeat(auto-fit, 110)' : 'repeat(auto-fit, 80)' ) .rowsGap(this.isShowDrawer ? 25 : (this.twoFingerType == 3 ? 10 : this.twoFingerType == 2 ? 5 : 1)) .visibility(this.isGridMusic ? Visibility.Visible : Visibility.None) .scale({ x: this.scaleValue, y: this.scaleValue, z: 1 }) .gesture(PinchGesture({ fingers: 2 }) .onActionEnd((event: GestureEvent) => { this.setGridTwoFingers() }) .onActionUpdate(e => { this.scaleValue = this.pinchValue * e.scale }) .onActionStart(e => { })) .enableScrollInteraction(true) .onScrollIndex((start: number, end: number) => { this.startIndex = start this.endIndex = end }) .onScrollFrameBegin((offset: number) => { if(this.autoHideTitle){ const currentOffsetY = this.scroller.currentOffset().yOffset; // 判断滚动方向 if (currentOffsetY > this.prevOffsetY) { // console.log("onecold 向上滚动"); this.isShowTitleBar = false } else if (currentOffsetY < this.prevOffsetY) { // console.log("onecold 向下滚动"); this.isShowTitleBar = true } // 更新前一次偏移量 this.prevOffsetY = currentOffsetY; } return { offsetRemain: offset }; }) } setGridTwoFingers() { this.pinchValue = this.scaleValue; Logger.info('this.pinchValue = ' + this.pinchValue); if (this.pinchValue >= 1) { this.twoFingerType++ Logger.info('this.twoFingerType1 = ' + this.twoFingerType); if(this.twoFingerType >= 4){ this.twoFingerType = 4//切换成瀑布流 this.columns = 4 } } else { this.twoFingerType-- Logger.info('this.twoFingerType2 = ' + this.twoFingerType); if (this.twoFingerType < 1) { this.twoFingerType = 3 this.isGridMusic = false PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) } } PreferencesUtil.put(SettingPage.TWO_FINGER_TYPE, this.twoFingerType) PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) this.pinchValue = 1; this.scaleValue = 1; } @Builder buildSongItemGrid(item: VideoItem, index: number) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(item.pixelMapPath ? item.pixelMapPath : $r('app.media.nocover')) .height(this.getGridHeight()) .width(this.getGridWight()) .draggable(false) .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿 .autoResize(true) // 重采样,可减少内存占用 .alt(item.type==CommonConstants.TYPE_IS_DIR?$r('app.media.dir_alt'):$r('app.media.alt')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .draggable(false) .animation({ duration: 666, curve: 'ease-in-out' // 可选动画曲线 }) .margin({ left: 25, right:25 }) Column() { Text(item.name) .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18) .maxLines(1) .fontWeight(FontWeight.Bold) .animation({ duration: 555, curve: 'Linear', }) .fontColor(this.currentSong?.filePath === item.filePath ? this.themeColor : $r('app.color.text_color')) Column() { // Text(`${this.getArtistSongCount(item.name)}首`) // .fontSize(11) // .fontSize(this.twoFingerType == 1 ? 10 : this.twoFingerType == 2 ? 12 : 14) // .fontColor($r('app.color.text_color')) // Text(`${this.getAlbumSongCount(item.name)}首`) // .fontSize(11) // .fontSize(this.twoFingerType == 1 ? 10 : this.twoFingerType == 2 ? 12 : 14) // .fontColor($r('app.color.text_color')) Row(){ if (StrUtil.isNotEmpty(item.md5Str)) { Text(this.buildSongQualityLabel(item)) .fontSize(this.twoFingerType == 1 ? 8 : 9) .fontColor($r('app.color.index_tab_font_color')) .fontWeight(500) .padding({ top: 2, right: 5, left: 5, bottom: 2 }) .borderRadius(4) .backgroundColor('#FFC107') .opacity(0.92) .margin({ top: 2, right: 6 }) .visibility(StrUtil.isEmpty(item.md5Str) ? Visibility.None : Visibility.Visible) } Text((item.artist ?? '') + " ") .fontSize(this.twoFingerType == 1 ? 10 : this.twoFingerType == 2 ? 12 : 13) .maxLines(1) .margin({ top: 2}) .visibility(item.type == CommonConstants.TYPE_IS_ARTIST || item.type == CommonConstants.TYPE_IS_ALBUM || item.type == CommonConstants.TYPE_IS_DIR ? Visibility.None : Visibility.Visible) .fontWeight(FontWeight.Medium) .fontColor(this.currentSong?.filePath === item.filePath ? this.themeColor : $r('app.color.text_color')) } } .alignItems(HorizontalAlign.Center) // 关键:使内容水平居中 } .height(this.twoFingerType == 3 ? 60 : this.twoFingerType == 2 ? 48 : 35) .width(this.getGridWight()) .justifyContent(FlexAlign.Center) .margin({ left: 25, right: 25 }) } .width(this.getGridWight()) .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .backgroundColor(Color.Transparent) .width('100%') .padding({ top: 15 }) .height(this.getGridAllHeight()) .onClick(() => { this.playSong(item, index); }) } getGridAllHeight() { let heightG = 222 switch (this.twoFingerType) { case 1: heightG = 143 break; case 2: heightG = 178 break; case 3: heightG = 222 break; } return heightG } getGridHeight() { let heightG = 150 switch (this.twoFingerType) { case 1: heightG = 80 break; case 2: heightG = 110 break; case 3: heightG = 150 break; } return heightG } getGridWight() { let wightG = 158 switch (this.twoFingerType) { case 1: wightG = 80 break; case 2: wightG = 110 break; case 3: wightG = 158 break; } return wightG } @Builder buildArtistItemGrid(artist: NavidromeRestArtist) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(artist.coverUrl ?? $r('app.media.nocover')) .height(this.getGridHeight()) .width(this.getGridWight()) .draggable(false) .interpolation(ImageInterpolation.High) .autoResize(true) .alt($r('app.media.nocover')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .draggable(false) .animation({ duration: 666, curve: 'ease-in-out' }) .margin({ left: 25, right: 25 }) .sourceSize({ width: 200, height: 200 }) .fillColor(artist.coverUrl ? undefined : this.themeColor) .objectFit(ImageFit.Cover) Column() { Text(artist.name ?? Constants.UNKNOWN_ARTIST) .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18) .maxLines(1) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.text_color')) .textOverflow({ overflow: TextOverflow.Ellipsis }) .animation({ duration: 555, curve: 'Linear', }) Column() { Row() { Text(this.buildArtistMetaLine(artist)) .fontSize(this.twoFingerType == 1 ? 10 : this.twoFingerType == 2 ? 12 : 13) .maxLines(1) .margin({ top: 2 }) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_color')) .opacity(0.6) .textOverflow({ overflow: TextOverflow.Ellipsis }) .visibility((artist.albumCount ?? 0) === 0 && (artist.songCount ?? 0) === 0 ? Visibility.None : Visibility.Visible) } } .alignItems(HorizontalAlign.Center) } .height(this.twoFingerType == 3 ? 60 : this.twoFingerType == 2 ? 48 : 35) .width(this.getGridWight()) .justifyContent(FlexAlign.Center) .margin({ left: 25, right: 25 }) } .width(this.getGridWight()) .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .backgroundColor(Color.Transparent) .width('100%') .padding({ top: 15 }) .height(this.getGridAllHeight()) .onClick(() => { this.onArtistSelected(artist); }) } @Builder buildAlbumItemGrid(album: NavidromeRestAlbum) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(album.coverUrl ?? $r('app.media.nocover')) .height(this.getGridHeight()) .width(this.getGridWight()) .draggable(false) .interpolation(ImageInterpolation.High) .autoResize(true) .alt($r('app.media.nocover')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .draggable(false) .animation({ duration: 666, curve: 'ease-in-out' }) .margin({ left: 25, right: 25 }) .sourceSize({ width: 200, height: 200 }) .fillColor(album.coverUrl ? undefined : this.themeColor) .objectFit(ImageFit.Cover) Column() { Text(album.name ?? '未知专辑') .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18) .maxLines(1) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.text_color')) .textOverflow({ overflow: TextOverflow.Ellipsis }) .animation({ duration: 555, curve: 'Linear', }) Column() { Row() { Text(album.artist ?? Constants.UNKNOWN_ARTIST) .fontSize(this.twoFingerType == 1 ? 10 : this.twoFingerType == 2 ? 12 : 13) .maxLines(1) .margin({ top: 2 }) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_color')) .opacity(0.6) .textOverflow({ overflow: TextOverflow.Ellipsis }) } } .alignItems(HorizontalAlign.Center) } .height(this.twoFingerType == 3 ? 60 : this.twoFingerType == 2 ? 48 : 35) .width(this.getGridWight()) .justifyContent(FlexAlign.Center) .margin({ left: 25, right: 25 }) } .width(this.getGridWight()) .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .backgroundColor(Color.Transparent) .width('100%') .padding({ top: 15 }) .height(this.getGridAllHeight()) .onClick(() => { this.onAlbumSelected(album); }) } @Builder buildPlaylistItemGrid(playlist: NavidromeRestPlaylist) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(playlist.coverUrl ?? $r('app.media.nocover')) .height(this.getGridHeight()) .width(this.getGridWight()) .draggable(false) .interpolation(ImageInterpolation.High) .autoResize(true) .alt($r('app.media.nocover')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .draggable(false) .animation({ duration: 666, curve: 'ease-in-out' }) .margin({ left: 25, right: 25 }) .sourceSize({ width: 200, height: 200 }) .fillColor(playlist.coverUrl ? undefined : this.themeColor) .objectFit(ImageFit.Cover) Column() { Text(playlist.name ?? '未知歌单') .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18) .maxLines(1) .fontWeight(FontWeight.Bold) .fontColor($r('app.color.text_color')) .textOverflow({ overflow: TextOverflow.Ellipsis }) .animation({ duration: 555, curve: 'Linear', }) Column() { Row() { Text(this.buildPlaylistMetaLine(playlist)) .fontSize(this.twoFingerType == 1 ? 10 : this.twoFingerType == 2 ? 12 : 13) .maxLines(1) .margin({ top: 2 }) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_color')) .opacity(0.6) .textOverflow({ overflow: TextOverflow.Ellipsis }) } } .alignItems(HorizontalAlign.Center) } .height(this.twoFingerType == 3 ? 60 : this.twoFingerType == 2 ? 48 : 35) .width(this.getGridWight()) .justifyContent(FlexAlign.Center) .margin({ left: 25, right: 25 }) } .width(this.getGridWight()) .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .backgroundColor(Color.Transparent) .width('100%') .padding({ top: 15 }) .height(this.getGridAllHeight()) .onClick(() => { this.onPlaylistSelected(playlist); }) } @Builder getListView() { List({scroller:this.scroller, space: 8 }) { // 详情视图模式下显示筛选后的歌曲,否则根据标签页显示对应内容 if (this.selectedTab === 0||this.isDetailView) { LazyForEach(this.songDataSource, (item: VideoItem, index: number) => { ListItem() { this.buildSongItem(item, index) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_list_song', item.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_list_song', item.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (item: VideoItem) => item.id) } else if (this.selectedTab === 1) { LazyForEach(this.artistDataSource, (artist: NavidromeRestArtist) => { ListItem() { this.buildArtistItem(artist) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_list_artist', artist.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_list_artist', artist.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (artist: NavidromeRestArtist) => artist.id) } else if (this.selectedTab === 2) { LazyForEach(this.albumDataSource, (album: NavidromeRestAlbum) => { ListItem() { this.buildAlbumItem(album) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_list_album', album.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_list_album', album.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (album: NavidromeRestAlbum) => album.id) } else if (this.selectedTab === 3) { LazyForEach(this.playlistDataSource, (playlist: NavidromeRestPlaylist) => { ListItem() { this.buildPlaylistItem(playlist) } .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_list_playlist', playlist.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_list_playlist', playlist.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (playlist: NavidromeRestPlaylist) => playlist.id) } } .onScrollFrameBegin((offset: number) => { // 获取当前滚动偏移量 if(this.autoHideTitle){ const currentOffsetY = this.scroller.currentOffset().yOffset; // 判断滚动方向 if (currentOffsetY > this.prevOffsetY) { this.isShowTitleBar = false } else if (currentOffsetY < this.prevOffsetY) { this.isShowTitleBar = true } // 更新前一次偏移量 this.prevOffsetY = currentOffsetY; } return { offsetRemain: offset }; }) .width('100%') .height('100%') .layoutWeight(1) .cachedCount(3) .padding({ top: 5, bottom: 5 }) .listDirection(Axis.Vertical) .contentEndOffset(this.bottomSafeHeight+70) .contentStartOffset(this.topSafeHeight + 98) .scrollBar(BarState.Auto) .scale({ x: this.scaleValue, y: this.scaleValue, z: 1 }) .edgeEffect(EdgeEffect.Spring) .gesture(PinchGesture({ fingers: 2 }) .onActionEnd((event: GestureEvent) => { this.setlistTwoFingers() }) .onActionUpdate(e => { this.scaleValue = this.pinchValue * e.scale }) ) .onReachEnd(() => { void this.handleReachEnd(); }) } // ==================== 双指缩放切换视图功能 ==================== // 双指缩放切换 List/Grid/WaterFlow setlistTwoFingers() { this.pinchValue = this.scaleValue; Logger.info('this.pinchValue = ' + this.pinchValue); if (this.pinchValue >= 1) { this.twoFingerType++ Logger.info('this.twoFingerType1 = ' + this.twoFingerType); if (this.twoFingerType > 3) { this.twoFingerType = 1 this.isGridMusic = true PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) } } else { this.twoFingerType-- Logger.info('this.twoFingerType2 = ' + this.twoFingerType); if (this.twoFingerType < 1) { this.twoFingerType = 4 this.columns = 1 } } PreferencesUtil.put(SettingPage.TWO_FINGER_TYPE, this.twoFingerType) PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) this.pinchValue = 1; this.scaleValue = 1; } // WaterFlow 列数切换 private columnChanged: boolean = false; private oldColumn: number = 4; changeColumns(scale: number): void { if (scale > (this.columns / (this.columns - 0.5)) && this.columns > 1) { this.columns--; this.columnChanged = true; } else if (scale < ((this.columns - 1) / this.columns) && this.columns < 6) { this.columns++; this.columnChanged = true; } } build() { Refresh({ refreshing: $$this.isRefreshing, refreshingContent: this.contentNode }) { Stack() { Column() { this.buildContentView() } .height('100%') Column() { this.topTitleBar() } } .alignContent(Alignment.Top) .width('100%') .height('100%') .backgroundImage(this.isCustomizeBg?this.customizeBgPath:$r('app.color.start_window_background')) .backgroundImageSize(this.isLandscape?{width:'100%'}:{ height: '100%'}) .backgroundImagePosition(Alignment.Center) .backdropBlur(this.blurValue) .backgroundBrightness({rate:this.isCustomizeBg?0.1:0,lightUpDegree:this.bgBrightness}) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) } .pullDownRatio(this.refreshPullRatio) .pullToRefresh(true) .refreshOffset(0) .onOffsetChange((offset: number) => { this.refreshPullRatio = 1 - Math.pow((offset / this.maxRefreshingHeight), 3) }) .onRefreshing(async () => { await this.handlePullRefresh() }) } @Builder getWaterView(){ Scroll(this.scroller) { Column() { if(!this.isWaterFlowScrolling&&(this.isShowTitleBar || !this.autoHideTitle)){ Blank().height(this.topSafeHeight + 115) } WaterFlow({ scroller:this.waterScroller, layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW }) { if (this.selectedTab === 0) { // 歌曲瀑布流 LazyForEach(this.songDataSource, (item: VideoItem, index: number) => { FlowItem() { this.buildSongWaterCardItem(item, index) } .width('100%') .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_water_song', item.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_water_song', item.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (item: VideoItem) => item.id) } else if (this.selectedTab === 1) { // 艺术家瀑布流 LazyForEach(this.artistDataSource, (artist: NavidromeRestArtist) => { FlowItem() { this.buildArtistWaterCardItem(artist) } .width('100%') .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_water_artist', artist.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_water_artist', artist.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (artist: NavidromeRestArtist) => artist.id) } else if (this.selectedTab === 2) { // 专辑瀑布流 LazyForEach(this.albumDataSource, (album: NavidromeRestAlbum) => { FlowItem() { this.buildAlbumWaterCardItem(album) } .width('100%') .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_water_album', album.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_water_album', album.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (album: NavidromeRestAlbum) => album.id) } else if (this.selectedTab === 3) { // 歌单瀑布流 LazyForEach(this.playlistDataSource, (playlist: NavidromeRestPlaylist) => { FlowItem() { this.buildPlaylistWaterCardItem(playlist) } .width('100%') .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) .onTouch((event: TouchEvent) => { this.handlePointLightTouch(this.getPointLightItemKey('remote_water_playlist', playlist.id), event) }) .visualEffect(deviceInfo.sdkApiVersion >= 20 ? new hdsEffect.HdsEffectBuilder() .pointLight({ options: this.getPointLightOptions(this.getPointLightItemKey('remote_water_playlist', playlist.id)), illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT }) .buildEffect() : undefined) }, (playlist: NavidromeRestPlaylist) => playlist.id) } } .id('waterflow') // 设置id用于截图 .columnsTemplate('1fr '.repeat(this.columns)) // 动态生成列模板,如:'1fr 1fr 1fr'表示3列等宽 .columnsGap(12) // 列间距 .rowsGap(16) // 行间距 .cachedCount(6) .padding({bottom:this.bottomSafeHeight+52}) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) .width('90%') .height('100%') .onReachEnd(() => { // 触底加载下一页 Logger.info('heanup RemoteMusicPage', `WaterFlow onReachEnd`); void this.handleReachEnd(); }) .onAppear(() => { this.scroller.scrollEdge(Edge.Top) }) .nestedScroll({ scrollForward: NestedScrollMode.PARENT_FIRST, scrollBackward: NestedScrollMode.SELF_FIRST }) .onScrollFrameBegin((offset: number) => { // 获取当前滚动偏移量 this.isWaterFlowScrolling = true if(this.autoHideTitle){ const currentOffsetY = this.waterScroller.currentOffset().yOffset; // 判断滚动方向 if (currentOffsetY > this.prevOffsetY) { // console.log("onecold 向上滚动"); this.isShowTitleBar = false } else if (currentOffsetY < this.prevOffsetY) { // console.log("onecold 向下滚动"); this.isShowTitleBar = true } // 更新前一次偏移量 this.prevOffsetY = currentOffsetY; } return { offsetRemain: offset }; }) .onScrollStop(() => { // 滚动停止 this.isWaterFlowScrolling = false }) .priorityGesture( PinchGesture() .onActionStart((event: GestureEvent) => { // 双指捏合手势识别成功时截图 this.pinchValue = this.scaleValue; this.columnChanged = false; this.oldColumn = this.columns; this.getUIContext().getComponentSnapshot().get('waterflow', (error: Error, pixmap: PixelMap) => { if (error) { console.info('error:' + JSON.stringify(error)); return; } if ((this.oldColumn === 1 ) && event.scale > 1|| (this.oldColumn === 4 && event.scale < 1)) { console.info("onecold onActionStart oldColumn:" + this.oldColumn) if (this.oldColumn ===4) { this.twoFingerType =3 this.isGridMusic = true }else if (this.oldColumn ===1) { this.twoFingerType =1 this.isGridMusic = false } PreferencesUtil.put(SettingPage.TWO_FINGER_TYPE, this.twoFingerType) PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) return; } }) }) .onActionUpdate((event: GestureEvent) => { // 缩放比例小于1时(缩小),增加列数;大于1时(放大),减少列数 if (event.scale > 1) { // 放大,减少列数 if (this.columns > 1 && this.columnChanged === false) { if (event.scale > 1.25) { this.columns = Math.max(1, this.columns - 1); this.columnChanged = true; console.info("onecold onActionUpdate columns111:" + this.columns) } } } else { // 缩小,增加列数 if (this.columns < 6 && this.columnChanged === false) { if (event.scale < 0.8) { this.columns = Math.min(6, this.columns + 1); this.columnChanged = true; console.info("onecold onActionUpdate columns222:" + this.columns) } } } }) .onActionEnd((event: GestureEvent) => { const currentTimestamp: number = event.timestamp; if (currentTimestamp - this.pinchValue < 300) { console.info("onecold onActionEnd 快速操作,不保存"); this.columnChanged = false; this.columns = this.oldColumn; return; } if (this.columnChanged) { if (this.columns === 1) { this.twoFingerType = 1 this.isGridMusic = false PreferencesUtil.put(SettingPage.TWO_FINGER_TYPE, this.twoFingerType) PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) } else if (this.columns === 6) { this.twoFingerType = 3 this.isGridMusic = true PreferencesUtil.put(SettingPage.TWO_FINGER_TYPE, this.twoFingerType) PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) } else { this.twoFingerType = 4 this.isGridMusic = true PreferencesUtil.put(SettingPage.TWO_FINGER_TYPE, this.twoFingerType) PreferencesUtil.put(SettingPage.IS_GRID_MUSIC, this.isGridMusic) } Logger.info('heanup RemoteMusicPage', '列数已切换为:' + this.columns) } this.columnChanged = false; }) ) } .width('100%') .height('100%') } .width('100%') .height('100%') .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .visibility(this.twoFingerType == 4 ? Visibility.Visible : Visibility.None) } // 歌曲瀑布流卡片项 @Builder buildSongWaterCardItem(item: VideoItem, index: number) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(item.pixelMapPath ? item.pixelMapPath : $r('app.media.alt')) .backgroundImageSize(ImageSize.Auto) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .draggable(false) .autoResize(true) .interpolation(ImageInterpolation.High) .width('100%') .height('auto') .alt($r('app.media.alt')) .objectFit(ImageFit.Auto) .animation({ duration: 666, curve: 'ease-in-out' }) .margin({ left: 20, right: 20 }) Column() { Text(item.name.startsWith('.') ? item.name.replace(/\./g, '') : item.name) .fontSize(this.columns == 4 || this.columns == 3 ? 12 : this.columns == 2 ? 15 : 18) .maxLines(1) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) .animation({ duration: 555, curve: 'Linear', }) .fontColor(this.currentSong?.filePath === item.filePath ? this.themeColor : $r('app.color.text_color')) Row() { if (StrUtil.isNotEmpty(item.md5Str)) { Text(this.buildSongQualityLabel(item)) .fontSize(this.columns == 4 || this.columns == 3 ? 8 : 9) .fontColor($r('app.color.index_tab_font_color')) .fontWeight(500) .padding({ top: 2, right: 5, left: 5, bottom: 2 }) .borderRadius(4) .backgroundColor('#FFC107') .opacity(0.92) .margin({ top: 2, right: 6 }) } Text(item.artist || '') .fontSize(this.columns == 4 || this.columns == 3 ? 10 : this.columns == 2 ? 12 : 13) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ top: 2 }) .fontWeight(FontWeight.Medium) .fontColor(this.currentSong?.filePath === item.filePath ? this.themeColor : $r('app.color.text_color')) } .alignItems(VerticalAlign.Center) } .height(this.columns == 4 || this.columns == 3 ? 40 : this.columns == 2 ? 48 : 58) .width('100%') .alignItems(HorizontalAlign.Center) // 关键:使内容水平居中 .justifyContent(FlexAlign.Center) .margin({ left: 20, right: 20 }) } .width('100%') .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .reuseId('card_item') .width('100%') .shadow({ radius: 10, type: ShadowType.BLUR, color: 'on_primary' }) .height('auto') .onClick(() => { this.playSong(item, index); }) .backgroundColor(Color.Transparent) } // 艺术家瀑布流卡片项 @Builder buildArtistWaterCardItem(artist: NavidromeRestArtist) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(artist.coverUrl ?? $r('app.media.dir_alt')) .objectFit(ImageFit.Auto) .width('100%') .draggable(false) .interpolation(ImageInterpolation.High) .autoResize(true) .alt($r('app.media.dir_alt')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .animation({ duration: 666, curve: 'ease-in-out' }) .sourceSize({ width: 200, height: 200 }) .fillColor(artist.coverUrl ? undefined : this.themeColor) Column() { Text(artist.name) .fontSize(14) .maxLines(1) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) .fontColor($r('app.color.text_color')) if (artist.albumCount !== undefined) { Text(`${artist.albumCount} 张专辑`) .fontSize(11) .fontColor($r('app.color.text_color')) } } .padding(12) .width('100%') .height(48) .alignItems(HorizontalAlign.Center) } .width('100%') .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .width('100%') .shadow({ radius: 10, type: ShadowType.BLUR, color: 'on_primary' }) .onClick(() => { this.onArtistSelected(artist); }) .backgroundColor(Color.Transparent) } // 专辑瀑布流卡片项 @Builder buildAlbumWaterCardItem(album: NavidromeRestAlbum) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(album.coverUrl ?? $r('app.media.dir_alt')) .width('100%') .height('auto') .objectFit(ImageFit.Auto) .draggable(false) .interpolation(ImageInterpolation.High) .autoResize(true) .alt($r('app.media.dir_alt')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .animation({ duration: 666, curve: 'ease-in-out' }) .sourceSize({ width: 200, height: 200 }) .fillColor(album.coverUrl ? undefined : this.themeColor) Column() { Text(album.name) .fontSize(14) .maxLines(1) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) .fontColor($r('app.color.text_color')) Text(album.artist ?? '') .fontSize(11) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .fontColor($r('app.color.text_color')) } .height(48) .padding(12) .width('100%') .alignItems(HorizontalAlign.Center) } .width('100%') .height('auto') .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .width('100%') .shadow({ radius: 10, type: ShadowType.BLUR, color: 'on_primary' }) .backgroundColor(Color.Transparent) .onClick(() => { this.onAlbumSelected(album); }) } // 歌单瀑布流卡片项 @Builder buildPlaylistWaterCardItem(playlist: NavidromeRestPlaylist) { Button({ type: ButtonType.Normal, stateEffect: false }) { Stack() { Column() { Image(playlist.coverUrl ?? $r('app.media.dir_alt')) .width('100%') .objectFit(ImageFit.Auto) .draggable(false) .interpolation(ImageInterpolation.High) .autoResize(true) .alt($r('app.media.dir_alt')) .clip(true) .borderRadius({ topLeft: 12, topRight: 12, bottomLeft: 0, bottomRight: 0 }) .animation({ duration: 666, curve: 'ease-in-out' }) .sourceSize({ width: 200, height: 200 }) .fillColor(playlist.coverUrl ? undefined : this.themeColor) Column() { Text(playlist.name) .fontSize(14) .maxLines(1) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) .fontColor($r('app.color.text_color')) if (playlist.songCount !== undefined) { Text(`${playlist.songCount} 首歌曲`) .fontSize(11) .fontColor($r('app.color.text_color')) } } .padding(12) .height(48) .width('100%') .alignItems(HorizontalAlign.Center) } .width('100%') .borderRadius(12) .backgroundColor($r('app.color.com_bg')) .animation({ curve: Curve.Sharp, duration: 300 }) } } .width('100%') .shadow({ radius: 10, type: ShadowType.BLUR, color: 'on_primary' }) .onClick(() => { this.onPlaylistSelected(playlist); }) .backgroundColor(Color.Transparent) } } /** * Navidrome 数据加载任务 (使用 taskpool 后台执行) * 该函数在 worker 线程中运行,避免阻塞 UI 线程 */ @Concurrent async function loadNavidromeDataTask(accountData: AccountData, ticket: number): Promise { const result: TaskResult = { songs: [], artists: [], albums: [], playlists: [] }; try { // 从 AccountData 重建 WebDavAccount 对象 (用于 API 调用) const account: WebDavAccount = new WebDavAccount(); account.id = accountData.id; account.webType = accountData.webType; account.host = accountData.host; account.port = accountData.port; account.account = accountData.account; account.password = accountData.password; account.enableHttps = accountData.enableHttps; account.navidromeBasePath = accountData.navidromeBasePath; account.jellyfinBasePath = accountData.jellyfinBasePath; account.embyBasePath = accountData.embyBasePath; account.name = accountData.name; // 加载歌曲并转换成 VideoItem(包含封面) let songNextStart: number | null = 0; while (songNextStart !== null) { const response: PagedResponse = await navidromeRestApi.fetchSongPage(account, songNextStart); const chunk: NavidromeRestSong[] = response.data ?? []; if (chunk.length === 0) { break; } // 转换歌曲为 VideoItem 并处理封面 for (let i = 0; i < chunk.length; i++) { const song: NavidromeRestSong = chunk[i]; const title: string = song.title ?? Constants.UNKNOWN_TITLE; const fileName: string = `${title}${song.suffix ? '.' + song.suffix : ''}`; // 获取库类型 let libraryType = CommonConstants.TYPE_NAVIDROME; let libraryScheme = 'navidrome'; if (account.webType === RemoteDriveType.Jellyfin) { libraryType = CommonConstants.TYPE_JELLYFIN; libraryScheme = 'jellyfin'; } else if (account.webType === RemoteDriveType.Emby) { libraryType = CommonConstants.TYPE_EMBY; libraryScheme = 'emby'; } const videoItem = new VideoItem( title, song.id, `${libraryScheme}://${account.id ?? 0}/${song.id}`, libraryType, song.size ?? 0, song.createdAt ?? '', Utility.formatFSize(song.size ?? 0), undefined, song.artist ?? Constants.UNKNOWN_ARTIST, song.album ?? '', fileName ); // 设置时长 if (song.duration !== undefined && song.duration !== null && song.duration >= 0) { const totalSeconds = Math.floor(song.duration); const minutes = Math.floor(totalSeconds / 60); const seconds = totalSeconds % 60; const pad = (value: number) => value.toString().padStart(2, '0'); videoItem.duration = `${pad(minutes)}:${pad(seconds)}`; } videoItem.size = Utility.formatFSize(song.size ?? 0); videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined; videoItem.genre = song.genre; videoItem.webdav_account_id = account.id?.toString(); videoItem.remote_rel_path = song.id; videoItem.navArtistId = song.artistId; videoItem.navAlbumId = song.albumId; videoItem.lyricContent = song.lyrics; if (song.track !== undefined && song.track !== null) { videoItem.track = song.track.toString(); } if (song.year !== undefined && song.year !== null) { videoItem.year = song.year.toString(); } if (song.contentType) { videoItem.mimeType = song.contentType; } let initialQuality: string = resolveAudioQualityTag(song.suffix ?? song.contentType ?? '', song.bitRate, song.sampleRate); if (initialQuality.length === 0) { const lastDotIndex = fileName.lastIndexOf('.'); if (lastDotIndex >= 0 && lastDotIndex < fileName.length - 1) { const fallbackExtension = fileName.substring(lastDotIndex); initialQuality = resolveAudioQualityTag(fallbackExtension, song.bitRate, song.sampleRate); } } if (initialQuality.length > 0) { videoItem.md5Str = initialQuality; } if (song.sampleRate !== undefined && song.sampleRate !== null && song.sampleRate > 0) { videoItem.sampleRate = song.sampleRate.toString(); } // 处理封面(在 worker 线程中调用 API) let coverUrl: string | undefined = undefined; try { // 获取封面ID const coverId = song.coverArt ?? song.coverArtId ?? song.id; // 判断是否是 Navidrome 账号 const isNavidrome = account.webType === RemoteDriveType.Navidrome; if (!isNavidrome) { // Jellyfin/Emby 直接使用 albumId 或 id const fallbackId = song.albumId ?? song.id; coverUrl = await navidromeApi.buildCoverArtUrl(account, fallbackId, 300); } else { // Navidrome 优先使用直接封面路径 const embedArtPath = song.embedArtPath ?? song.coverArtPath; if (embedArtPath) { // 构建直接封面URL const protocol = account.enableHttps ? 'https' : 'http'; const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host; const portPart = account.port && account.port !== 80 && account.port !== 443 ? `:${account.port}` : ''; const basePath = account.navidromeBasePath ?? '/rest'; // 移除 /rest 后缀 const apiBase = basePath.endsWith('/rest') ? basePath.slice(0, -5) : basePath; coverUrl = `${protocol}://${host}${portPart}${apiBase}/embed/${embedArtPath}?${account.account}:${account.password}`; } else { // 使用封面API coverUrl = await navidromeApi.buildCoverArtUrl(account, coverId, 300); } } } catch (error) { // 封面获取失败,忽略 } videoItem.pixelMapPath = coverUrl; result.songs.push(videoItem); } songNextStart = response.nextStart; } // 加载艺术家并处理封面 let artistNextStart: number | null = 0; while (artistNextStart !== null) { const response: PagedResponse = await navidromeRestApi.fetchArtistPage(account, artistNextStart); const chunk: NavidromeRestArtist[] = response.data ?? []; if (chunk.length === 0) { break; } // 处理艺术家封面 for (let i = 0; i < chunk.length; i++) { const artist = chunk[i]; // 尝试获取已有图片URL let coverUrl: string | undefined = artist.mediumImageUrl ?? artist.largeImageUrl; // 如果没有直接URL,生成封面URL if (!coverUrl) { const coverId = artist.coverArt ?? artist.coverArtId ?? (artist.id ? `ar-${artist.id}` : undefined); try { coverUrl = await navidromeApi.buildCoverArtUrl(account, coverId, 256); } catch (error) { // 封面生成失败,忽略 } } artist.coverUrl = coverUrl; result.artists.push(artist); } artistNextStart = response.nextStart; } // 加载专辑并处理封面 let albumNextStart: number | null = 0; while (albumNextStart !== null) { const response: PagedResponse = await navidromeRestApi.fetchAlbumPage(account, albumNextStart); const chunk: NavidromeRestAlbum[] = response.data ?? []; if (chunk.length === 0) { break; } // 处理专辑封面 for (let i = 0; i < chunk.length; i++) { const album = chunk[i]; // 专辑没有直接的 imageUrl 属性,需要生成封面URL let coverUrl: string | undefined = undefined; // 优先使用 embed 路径 if (album.embedArtPath) { const protocol = account.enableHttps ? 'https' : 'http'; const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host; const portPart = account.port && account.port !== 80 && account.port !== 443 ? `:${account.port}` : ''; const basePath = account.navidromeBasePath ?? '/rest'; const apiBase = basePath.endsWith('/rest') ? basePath.slice(0, -5) : basePath; coverUrl = `${protocol}://${host}${portPart}${apiBase}/embed/${album.embedArtPath}?${account.account}:${account.password}`; } // 如果没有 embed 路径,使用封面API if (!coverUrl) { const coverId = album.coverArt ?? album.coverArtId ?? album.id; try { coverUrl = await navidromeApi.buildCoverArtUrl(account, coverId, 300); } catch (error) { // 封面生成失败,忽略 } } album.coverUrl = coverUrl; result.albums.push(album); } albumNextStart = response.nextStart; } // 加载歌单 let playlistNextStart: number | null = 0; while (playlistNextStart !== null) { const response: PagedResponse = await navidromeRestApi.fetchPlaylistPage(account, playlistNextStart); const chunk: NavidromeRestPlaylist[] = response.data ?? []; if (chunk.length === 0) { break; } // 使用循环代替展开运算符 for (let i = 0; i < chunk.length; i++) { result.playlists.push(chunk[i]); } playlistNextStart = response.nextStart; } } catch (error) { Logger.error('NavidromeTask', `后台加载数据失败: ${(error as Error).message}`); } return result; }