NavidromePage.ets 57 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566
  1. import { VideoItem } from '../viewmodel/VideoItem';
  2. import { LengthMetrics, SegmentButton, SegmentButtonItemTuple, SegmentButtonOptions,
  3. SymbolGlyphModifier } from '@kit.ArkUI';
  4. import {
  5. PreferencesUtil, ToastUtil, StrUtil
  6. } from '@pura/harmony-utils';
  7. import {
  8. ButtonFancyModifier,
  9. SymbolGlyphFancyModifier,
  10. ShadowModifier
  11. } from '../common/util/AttributeModifierUtil';
  12. import { CommonConstants } from '../common/constants/CommonConstants';
  13. import { WebDavAccount } from '../viewmodel/WebDavAccount';
  14. import Logger from '../common/util/Logger';
  15. import { RemoteDriveType } from '../common/enums/RemoteDriveType';
  16. import { navidromeRestApi, NavidromeRestAlbum, NavidromeRestArtist, NavidromeRestSong } from '../common/network/NavidromeRestApi';
  17. import { Utility } from '../common/util/Utility';
  18. import { Constants } from '../Constants';
  19. import { EventConstants } from '../common/constants/EventConstants';
  20. import { emitter } from '@kit.BasicServicesKit';
  21. import { setNavidromePlaylist } from '../common/util/NavidromePlaylistStore';
  22. import { navidromeApi, NavidromeSong } from '../common/network/NavidromeApi';
  23. import { ServerLogUtil } from '../common/util/ServerLogUtil';
  24. import { RemoteDriveManager } from '../common/util/RemoteDriveManager';
  25. const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
  26. const NAVIDROME_SEARCH_LIMIT = 500;
  27. interface PlaylistEventData {
  28. playlistId: string;
  29. playlistName: string;
  30. songCount: number;
  31. startIndex: number;
  32. isJump: boolean;
  33. songFilePaths: string[];
  34. }
  35. enum NavFilterType {
  36. None = 0,
  37. Artist = 1,
  38. Album = 2
  39. }
  40. interface CachePreviewInfo {
  41. cacheRoot: string;
  42. examplePath: string;
  43. }
  44. @Component
  45. export struct NavidromePage {
  46. @Link mType: number;
  47. @Link offsetX: number;
  48. @Link isShowDrawer: boolean;
  49. @State isNoJumpToHome: boolean = false //网盘播放不跳转首页
  50. @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0;
  51. @Prop @Watch('onSwitchAccount') selectedAccount: WebDavAccount;
  52. @StorageProp('currentTheme') currentTheme: number = 0;
  53. @State selectedTab: number = 0; // 0: 全部, 1: 艺术家, 2: 专辑
  54. @State allVideos: VideoItem[] = [];
  55. @State artists: NavidromeRestArtist[] = [];
  56. @State albums: NavidromeRestAlbum[] = [];
  57. @State loading: boolean = false;
  58. @State songNextStart: number | null = 0;
  59. @State artistNextStart: number | null = 0;
  60. @State albumNextStart: number | null = 0;
  61. @State isSongPageLoading: boolean = false;
  62. @State isArtistPageLoading: boolean = false;
  63. @State isAlbumPageLoading: boolean = false;
  64. @StorageProp('isDarkMode') isDarkMode: boolean = false;
  65. @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
  66. @StorageProp('topRectHeight') topRectHeight: number = 0;
  67. @State @Watch('onTabSelectedIndexesChanged') tabSelectedIndexes: number[] = [0];
  68. @StorageProp('themeColor') themeColor: string =
  69. PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR);
  70. private tabs: string[] = ['全部', '艺术家', '专辑'];
  71. private loadTicket: number = 0;
  72. @State filterType: NavFilterType = NavFilterType.None;
  73. @State filterLabel: string = '';
  74. @State filterId: string = '';
  75. @State filterSongs: VideoItem[] = [];
  76. @State isFilterLoading: boolean = false;
  77. private coverUrlCache: Map<string, string> = new Map();
  78. private albumCoverLookup: Map<string, string> = new Map();
  79. private searchTicket: number = 0;
  80. private createTabOptions(): SegmentButtonOptions {
  81. const buttons = [
  82. { text: '全部' },
  83. { text: '艺术家' },
  84. { text: '专辑' }
  85. ] as SegmentButtonItemTuple;
  86. return SegmentButtonOptions.capsule({
  87. buttons,
  88. direction: Direction.Ltr,
  89. buttonPadding: { top: 10, bottom: 10 },
  90. backgroundColor: $r('app.color.index_background'),
  91. selectedBackgroundColor: $r('app.color.start_window_background'),
  92. selectedFontColor: $r('app.color.text_color'),
  93. fontSize: 14,
  94. selectedFontSize: 15,
  95. localizedTextPadding: {
  96. end: LengthMetrics.vp(20),
  97. start: LengthMetrics.vp(20)
  98. }
  99. });
  100. }
  101. private getCachePreviewInfo(account: WebDavAccount): CachePreviewInfo | null {
  102. try {
  103. const manager = RemoteDriveManager.getInstance();
  104. const context = manager.context;
  105. if (!context) {
  106. return null;
  107. }
  108. const baseDir = context.filesDir ?? context.cacheDir;
  109. if (!baseDir) {
  110. return null;
  111. }
  112. const accountId = account.id?.toString() || 'default';
  113. const cacheRoot = `${baseDir}/remote_cache/webdav/${accountId}`;
  114. const examplePath = `${cacheRoot}/navidrome_preview.cache`;
  115. return { cacheRoot, examplePath };
  116. } catch (error) {
  117. Logger.error('NavidromePage', `获取缓存预览路径失败: ${(error as Error).message}`);
  118. return null;
  119. }
  120. }
  121. // 搜索和排序相关状态
  122. @State isSearchMode: boolean = false;
  123. @State searchText: string = ''; // 用户输入内容
  124. @State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
  125. @State sortType: number = 0; // 排序类型 0:名称升序 1:名称降序 2:时间升序 3:时间降序 4:大小升序 5:大小降序
  126. @State isSearchLoading: boolean = false;
  127. // SegmentButton选项
  128. @State tabOptions: SegmentButtonOptions = this.createTabOptions();
  129. //当胶囊按钮的选择发生变化时调用此函数
  130. onTabSelectedIndexesChanged() {
  131. this.selectedTab = this.tabSelectedIndexes[0];
  132. console.info('heanup', `Selected tab: ${this.tabs[this.selectedTab]}`);
  133. // 从艺术家或专辑切换回全部时,清除筛选状态
  134. if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
  135. // 不清除筛选,保持筛选状态
  136. } else if (this.selectedTab !== 0) {
  137. // 切换到艺术家或专辑标签页时,清除筛选和搜索状态
  138. this.clearFilter();
  139. this.isSearchMode = false;
  140. this.searchText = '';
  141. this.filteredList = [];
  142. this.isSearchLoading = false;
  143. this.searchTicket++;
  144. }
  145. }
  146. //切换不同的NavidromePage
  147. async onSwitchAccount() {
  148. await this.refreshNavidromeData(true);
  149. }
  150. aboutToAppear() {
  151. this.isNoJumpToHome = PreferencesUtil.getBooleanSync('isNoJumpToHome', true)
  152. this.sortType = PreferencesUtil.getNumberSync('navidromeSortType', 0);
  153. this.refreshNavidromeData();
  154. }
  155. private async refreshNavidromeData(showToastWhenMissing: boolean = false): Promise<void> {
  156. const account = this.resolveActiveAccount();
  157. if (!account) {
  158. this.resetData();
  159. if (showToastWhenMissing) {
  160. ToastUtil.showToast('请先选择 Navidrome 账号');
  161. }
  162. return;
  163. }
  164. // 记录日志开关状态
  165. void ServerLogUtil.info('NavidromeLoad', `日志状态: ${ServerLogUtil.getLogUploadStatus()}`);
  166. // 记录账号信息和缓存路径
  167. await this.logAccountCacheInfo(account);
  168. await this.loadNavidromeLibrary(account);
  169. this.doSortType(this.sortType)
  170. }
  171. private resolveActiveAccount(): WebDavAccount | undefined {
  172. if (!this.selectedAccount) {
  173. return undefined;
  174. }
  175. if (this.selectedAccount.webType !== RemoteDriveType.Navidrome) {
  176. return undefined;
  177. }
  178. if (!this.selectedAccount.host || this.selectedAccount.host.length === 0) {
  179. return undefined;
  180. }
  181. return this.selectedAccount;
  182. }
  183. private resetData(): void {
  184. this.allVideos = [];
  185. this.artists = [];
  186. this.albums = [];
  187. this.clearFilter();
  188. this.coverUrlCache.clear();
  189. this.albumCoverLookup.clear();
  190. this.songNextStart = 0;
  191. this.artistNextStart = 0;
  192. this.albumNextStart = 0;
  193. this.isSongPageLoading = false;
  194. this.isArtistPageLoading = false;
  195. this.isAlbumPageLoading = false;
  196. }
  197. private async loadNavidromeLibrary(account: WebDavAccount): Promise<void> {
  198. const ticket = ++this.loadTicket;
  199. this.loading = true;
  200. this.resetData();
  201. void ServerLogUtil.info('NavidromeLoad', '开始加载 Navidrome 数据库');
  202. void ServerLogUtil.info('NavidromeLoad', `服务器: ${ServerLogUtil.sanitizeAccount(account)}`);
  203. void ServerLogUtil.info('NavidromeLoad', `封面URL缓存已清空,当前缓存数量: ${this.coverUrlCache.size}`);
  204. try {
  205. await this.logAccountCacheInfo(account);
  206. await Promise.all([
  207. this.loadNextSongPage(account, ticket),
  208. this.loadNextArtistPage(account, ticket),
  209. this.loadNextAlbumPage(account, ticket)
  210. ]);
  211. if (ticket !== this.loadTicket) {
  212. return;
  213. }
  214. void ServerLogUtil.info('NavidromeLoad', `首次加载完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
  215. await this.logCacheStatistics(account);
  216. } catch (error) {
  217. if (ticket === this.loadTicket) {
  218. void ServerLogUtil.error('NavidromeLoad', `数据加载失败: ${(error as Error).message}`);
  219. void ServerLogUtil.error('NavidromeLoad', `失败时服务器信息: ${ServerLogUtil.sanitizeAccount(account)}`);
  220. ToastUtil.showToast((error as Error).message ?? 'Navidrome 数据加载失败');
  221. }
  222. } finally {
  223. if (ticket === this.loadTicket) {
  224. this.loading = false;
  225. void ServerLogUtil.info('NavidromeLoad', '加载流程结束');
  226. }
  227. }
  228. }
  229. private async loadNextSongPage(account: WebDavAccount, ticket?: number): Promise<void> {
  230. if (this.songNextStart === null || this.isSongPageLoading) {
  231. return;
  232. }
  233. const currentTicket = ticket ?? this.loadTicket;
  234. this.isSongPageLoading = true;
  235. try {
  236. const response = await navidromeRestApi.fetchSongPage(account, this.songNextStart);
  237. if (currentTicket !== this.loadTicket) {
  238. return;
  239. }
  240. const chunk = response.data ?? [];
  241. if (chunk.length === 0) {
  242. this.songNextStart = null;
  243. return;
  244. }
  245. const videos = await this.convertSongsToVideoItems(chunk, account);
  246. if (currentTicket !== this.loadTicket) {
  247. return;
  248. }
  249. this.allVideos = [...this.allVideos, ...videos];
  250. this.songNextStart = response.nextStart;
  251. void ServerLogUtil.info('NavidromeLoad', `歌曲列表追加: 本次 ${videos.length} 首, 总数 ${this.allVideos.length}`);
  252. } finally {
  253. this.isSongPageLoading = false;
  254. }
  255. }
  256. private async loadNextArtistPage(account: WebDavAccount, ticket?: number): Promise<void> {
  257. if (this.artistNextStart === null || this.isArtistPageLoading) {
  258. return;
  259. }
  260. const currentTicket = ticket ?? this.loadTicket;
  261. this.isArtistPageLoading = true;
  262. try {
  263. const response = await navidromeRestApi.fetchArtistPage(account, this.artistNextStart);
  264. if (currentTicket !== this.loadTicket) {
  265. return;
  266. }
  267. const chunk = response.data ?? [];
  268. if (chunk.length === 0) {
  269. this.artistNextStart = null;
  270. return;
  271. }
  272. const coverMap = await this.buildArtistCoverMap(chunk, account);
  273. if (currentTicket !== this.loadTicket) {
  274. return;
  275. }
  276. const processed = chunk.map(artist => {
  277. artist.coverUrl = coverMap.get(artist.id);
  278. return artist;
  279. });
  280. this.artists = [...this.artists, ...processed];
  281. this.artistNextStart = response.nextStart;
  282. void ServerLogUtil.info('ArtistCover', `艺术家列表追加: 本次 ${processed.length} 位, 总数 ${this.artists.length}`);
  283. } finally {
  284. this.isArtistPageLoading = false;
  285. }
  286. }
  287. private async loadNextAlbumPage(account: WebDavAccount, ticket?: number): Promise<void> {
  288. if (this.albumNextStart === null || this.isAlbumPageLoading) {
  289. return;
  290. }
  291. const currentTicket = ticket ?? this.loadTicket;
  292. this.isAlbumPageLoading = true;
  293. try {
  294. const response = await navidromeRestApi.fetchAlbumPage(account, this.albumNextStart);
  295. if (currentTicket !== this.loadTicket) {
  296. return;
  297. }
  298. const chunk = response.data ?? [];
  299. if (chunk.length === 0) {
  300. this.albumNextStart = null;
  301. return;
  302. }
  303. const coverMap = await this.buildAlbumCoverMap(chunk, account);
  304. if (currentTicket !== this.loadTicket) {
  305. return;
  306. }
  307. coverMap.forEach((value, key) => {
  308. if (value) {
  309. this.albumCoverLookup.set(key, value);
  310. }
  311. });
  312. const processed = chunk.map(album => {
  313. album.coverUrl = coverMap.get(album.id);
  314. return album;
  315. });
  316. this.albums = [...this.albums, ...processed];
  317. this.albumNextStart = response.nextStart;
  318. void ServerLogUtil.info('AlbumCover', `专辑列表追加: 本次 ${processed.length} 张, 总数 ${this.albums.length}`);
  319. } finally {
  320. this.isAlbumPageLoading = false;
  321. }
  322. }
  323. private async handleReachEnd(): Promise<void> {
  324. if (this.loading) {
  325. return;
  326. }
  327. if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
  328. return;
  329. }
  330. const account = this.selectedAccount;
  331. if (!account) {
  332. return;
  333. }
  334. switch (this.selectedTab) {
  335. case 0:
  336. await this.loadNextSongPage(account);
  337. break;
  338. case 1:
  339. await this.loadNextArtistPage(account);
  340. break;
  341. case 2:
  342. await this.loadNextAlbumPage(account);
  343. break;
  344. default:
  345. break;
  346. }
  347. }
  348. private async buildAlbumCoverMap(albums: NavidromeRestAlbum[], account: WebDavAccount): Promise<Map<string, string>> {
  349. const map = new Map<string, string>();
  350. const tasks: Promise<void>[] = [];
  351. let directCoverCount = 0;
  352. let generatedCoverCount = 0;
  353. let failedCoverCount = 0;
  354. void ServerLogUtil.info('AlbumCover', `开始处理 ${albums.length} 张专辑的封面`);
  355. for (let i = 0; i < albums.length; i++) {
  356. const album = albums[i];
  357. tasks.push((async () => {
  358. try {
  359. // 尝试获取直接嵌入的封面路径
  360. const directUrl = this.resolveEmbedCover(account, album.embedArtPath ?? album.coverArtPath);
  361. if (directUrl) {
  362. map.set(album.id, directUrl);
  363. directCoverCount++;
  364. void ServerLogUtil.debug('AlbumCover', `专辑 ${album.name} 使用直接封面: ${directUrl}`);
  365. return;
  366. }
  367. // 生成封面URL
  368. const coverId = album.coverArt ?? album.coverArtId ?? (album.id ? `al-${album.id}` : undefined);
  369. const url = await this.buildCoverUrl(account, coverId);
  370. if (url) {
  371. map.set(album.id, url);
  372. generatedCoverCount++;
  373. void ServerLogUtil.debug('AlbumCover', `专辑 ${album.name} 生成封面URL: ${coverId} -> ${url}`);
  374. } else {
  375. failedCoverCount++;
  376. void ServerLogUtil.warn('AlbumCover', `专辑 ${album.name} 无可用封面: ${JSON.stringify({coverArt: album.coverArt, coverArtId: album.coverArtId, id: album.id})}`);
  377. }
  378. } catch (error) {
  379. failedCoverCount++;
  380. void ServerLogUtil.warn('AlbumCover', `专辑 ${album.name} 封面解析失败: ${(error as Error).message}`);
  381. }
  382. })());
  383. }
  384. await Promise.all(tasks);
  385. void ServerLogUtil.info('AlbumCover', `专辑封面处理完成: 直接嵌入 ${directCoverCount}, 生成URL ${generatedCoverCount}, 失败 ${failedCoverCount}`);
  386. return map;
  387. }
  388. private async buildArtistCoverMap(artists: NavidromeRestArtist[], account: WebDavAccount): Promise<Map<string, string>> {
  389. const map = new Map<string, string>();
  390. const tasks: Promise<void>[] = [];
  391. let directCoverCount = 0;
  392. let generatedCoverCount = 0;
  393. let failedCoverCount = 0;
  394. void ServerLogUtil.info('ArtistCover', `开始处理 ${artists.length} 位艺术家的封面`);
  395. for (let i = 0; i < artists.length; i++) {
  396. const artist = artists[i];
  397. tasks.push((async () => {
  398. try {
  399. // 尝试获取已有图片URL
  400. const directUrl = artist.mediumImageUrl ?? artist.largeImageUrl ?? this.resolveEmbedCover(account, artist.coverArtPath);
  401. if (directUrl) {
  402. map.set(artist.id, directUrl);
  403. directCoverCount++;
  404. void ServerLogUtil.debug('ArtistCover', `艺术家 ${artist.name} 使用已有图片: ${directUrl}`);
  405. return;
  406. }
  407. // 生成封面URL
  408. const coverId = artist.coverArt ?? artist.coverArtId ?? (artist.id ? `ar-${artist.id}` : undefined);
  409. const url = await this.buildCoverUrl(account, coverId, 256);
  410. if (url) {
  411. map.set(artist.id, url);
  412. generatedCoverCount++;
  413. void ServerLogUtil.debug('ArtistCover', `艺术家 ${artist.name} 生成封面URL: ${coverId} -> ${url}`);
  414. } else {
  415. failedCoverCount++;
  416. void ServerLogUtil.warn('ArtistCover', `艺术家 ${artist.name} 无可用封面: ${JSON.stringify({coverArt: artist.coverArt, coverArtId: artist.coverArtId, id: artist.id})}`);
  417. }
  418. } catch (error) {
  419. failedCoverCount++;
  420. void ServerLogUtil.warn('ArtistCover', `艺术家 ${artist.name} 封面解析失败: ${(error as Error).message}`);
  421. }
  422. })());
  423. }
  424. await Promise.all(tasks);
  425. void ServerLogUtil.info('ArtistCover', `艺术家封面处理完成: 直接链接 ${directCoverCount}, 生成URL ${generatedCoverCount}, 失败 ${failedCoverCount}`);
  426. return map;
  427. }
  428. private convertApiSongsToRestSongs(songs: NavidromeSong[]): NavidromeRestSong[] {
  429. return songs.map((song: NavidromeSong): NavidromeRestSong => ({
  430. id: song.id,
  431. title: song.title,
  432. artist: song.artist,
  433. artistId: song.artistId,
  434. album: song.album,
  435. albumId: song.albumId,
  436. duration: song.duration,
  437. bitRate: song.bitRate,
  438. suffix: song.suffix,
  439. size: song.size,
  440. createdAt: song.created,
  441. genre: song.genre,
  442. track: song.track,
  443. year: song.year,
  444. contentType: song.contentType,
  445. coverArt: song.coverArt,
  446. coverArtId: song.coverArt
  447. }));
  448. }
  449. private async convertSongsToVideoItems(songs: NavidromeRestSong[], account: WebDavAccount): Promise<VideoItem[]> {
  450. const tasks: Promise<VideoItem>[] = [];
  451. let successCount = 0;
  452. let failedCount = 0;
  453. void ServerLogUtil.info('SongConvert', `开始转换 ${songs.length} 首歌曲为 VideoItem`);
  454. for (let i = 0; i < songs.length; i++) {
  455. const song = songs[i];
  456. tasks.push((async () => {
  457. try {
  458. let coverUrl: string | undefined;
  459. try {
  460. coverUrl = await this.resolveSongCover(song, account);
  461. } catch (error) {
  462. void ServerLogUtil.warn('SongConvert', `歌曲 ${song.title} 封面解析失败: ${(error as Error).message}`);
  463. }
  464. const videoItem = this.convertSongToVideoItem(song, account, coverUrl);
  465. successCount++;
  466. // 记录前几首歌的详细信息用于调试
  467. if (i < 3) {
  468. void ServerLogUtil.debug('SongConvert', `歌曲 ${i + 1}: ${videoItem.name}, 封面: ${coverUrl ? '有' : '无'}, 路径: ${videoItem.filePath}`);
  469. }
  470. return videoItem;
  471. } catch (error) {
  472. failedCount++;
  473. void ServerLogUtil.error('SongConvert', `歌曲 ${song.title} 转换失败: ${(error as Error).message}`);
  474. throw new Error(`歌曲 ${song.title} 转换失败: ${(error as Error).message}`);
  475. }
  476. })());
  477. }
  478. const results = await Promise.all(tasks);
  479. void ServerLogUtil.info('SongConvert', `歌曲转换完成: 成功 ${successCount}, 失败 ${failedCount}`);
  480. return results;
  481. }
  482. private async resolveSongCover(song: NavidromeRestSong, account: WebDavAccount): Promise<string | undefined> {
  483. if (song.albumId) {
  484. const albumCover = song.albumId ? this.albumCoverLookup.get(song.albumId) : undefined;
  485. if (albumCover) {
  486. return albumCover;
  487. }
  488. }
  489. const directUrl = this.resolveEmbedCover(account, song.embedArtPath ?? song.coverArtPath);
  490. if (directUrl) {
  491. return directUrl;
  492. }
  493. const coverId = song.coverArt ?? song.coverArtId ?? song.id;
  494. return this.buildCoverUrl(account, coverId);
  495. }
  496. private async buildCoverUrl(account: WebDavAccount, coverId?: string, size: number = 300): Promise<string | undefined> {
  497. if (!coverId || coverId.trim().length === 0) {
  498. return undefined;
  499. }
  500. const normalizedId = coverId.trim();
  501. const cacheKey = `${normalizedId}_${size}`;
  502. const cached = this.coverUrlCache.get(cacheKey);
  503. if (cached) {
  504. void ServerLogUtil.debug('CoverCache', `封面URL缓存命中: ${cacheKey} -> ${cached}`);
  505. return cached;
  506. }
  507. void ServerLogUtil.debug('CoverCache', `生成封面URL: ${coverId} (尺寸: ${size})`);
  508. const url = await navidromeApi.buildCoverArtUrl(account, normalizedId, size);
  509. if (url) {
  510. this.coverUrlCache.set(cacheKey, url);
  511. void ServerLogUtil.debug('CoverCache', `封面URL已缓存: ${cacheKey} -> ${url}`);
  512. } else {
  513. void ServerLogUtil.warn('CoverCache', `封面URL生成失败: ${coverId} (尺寸: ${size})`);
  514. }
  515. return url;
  516. }
  517. /**
  518. * 记录账号缓存信息
  519. */
  520. private async logAccountCacheInfo(account: WebDavAccount): Promise<void> {
  521. try {
  522. void ServerLogUtil.info('AccountInfo', `账号信息: ${ServerLogUtil.sanitizeAccount(account)}`);
  523. // 记录缓存路径信息
  524. const cachePreview = this.getCachePreviewInfo(account);
  525. if (cachePreview) {
  526. void ServerLogUtil.info('AccountInfo', `Navidrome缓存目录结构:`);
  527. void ServerLogUtil.info('AccountInfo', `- 缓存根目录: ${cachePreview.cacheRoot}`);
  528. void ServerLogUtil.info('AccountInfo', `- 示例缓存路径: ${cachePreview.examplePath}`);
  529. } else {
  530. void ServerLogUtil.warn('AccountInfo', '无法预览缓存目录:上下文或根目录不可用');
  531. }
  532. // 记录封面缓存状态
  533. void ServerLogUtil.info('AccountInfo', `当前封面URL缓存数量: ${this.coverUrlCache.size}`);
  534. if (this.coverUrlCache.size > 0) {
  535. const cacheKeys = Array.from(this.coverUrlCache.keys()).slice(0, 10);
  536. void ServerLogUtil.debug('AccountInfo', `封面缓存示例: ${cacheKeys.join(', ')}`);
  537. }
  538. } catch (error) {
  539. void ServerLogUtil.error('AccountInfo', `记录账号信息失败: ${(error as Error).message}`);
  540. }
  541. }
  542. /**
  543. * 记录缓存统计信息
  544. */
  545. private async logCacheStatistics(account: WebDavAccount): Promise<void> {
  546. try {
  547. void ServerLogUtil.info('CacheStats', '=== Navidrome 缓存统计 ===');
  548. void ServerLogUtil.info('CacheStats', `封面URL缓存: ${this.coverUrlCache.size} 个条目`);
  549. void ServerLogUtil.info('CacheStats', `已加载歌曲: ${this.allVideos.length} 首`);
  550. void ServerLogUtil.info('CacheStats', `已处理艺术家: ${this.artists.length} 位`);
  551. void ServerLogUtil.info('CacheStats', `已处理专辑: ${this.albums.length} 张`);
  552. // 计算封面统计
  553. const songsWithCover = this.allVideos.filter(song => song.pixelMapPath && song.pixelMapPath.length > 0).length;
  554. const albumsWithCover = this.albums.filter(album => album.coverUrl && album.coverUrl.length > 0).length;
  555. const artistsWithCover = this.artists.filter(artist => artist.coverUrl && artist.coverUrl.length > 0).length;
  556. void ServerLogUtil.info('CacheStats', `封面统计:`);
  557. void ServerLogUtil.info('CacheStats', `- 歌曲有封面: ${songsWithCover}/${this.allVideos.length} (${Math.round(songsWithCover / this.allVideos.length * 100)}%)`);
  558. void ServerLogUtil.info('CacheStats', `- 专辑有封面: ${albumsWithCover}/${this.albums.length} (${Math.round(albumsWithCover / this.albums.length * 100)}%)`);
  559. void ServerLogUtil.info('CacheStats', `- 艺术家有封面: ${artistsWithCover}/${this.artists.length} (${Math.round(artistsWithCover / this.artists.length * 100)}%)`);
  560. } catch (error) {
  561. void ServerLogUtil.error('CacheStats', `记录缓存统计失败: ${(error as Error).message}`);
  562. }
  563. }
  564. private resolveEmbedCover(account: WebDavAccount, path?: string): string | undefined {
  565. return navidromeRestApi.resolveResourceUrl(account, path);
  566. }
  567. private convertSongToVideoItem(song: NavidromeRestSong, account: WebDavAccount, coverUrl?: string): VideoItem {
  568. const title = song.title ?? Constants.UNKNOWN_TITLE;
  569. const videoItem = new VideoItem(
  570. title,
  571. song.id,
  572. `navidrome://${account.id ?? 0}/${song.id}`,
  573. CommonConstants.TYPE_NAVIDROME,
  574. song.size ?? 0,
  575. song.createdAt ?? '',
  576. Utility.formatFSize(song.size ?? 0),
  577. undefined,
  578. song.artist ?? Constants.UNKNOWN_ARTIST,
  579. song.album ?? '',
  580. `${title}${song.suffix ? '.' + song.suffix : ''}`
  581. );
  582. const durationStr = this.formatSongDuration(song.duration);
  583. if (durationStr) {
  584. videoItem.duration = durationStr;
  585. }
  586. videoItem.size = Utility.formatFSize(song.size ?? 0);
  587. videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
  588. videoItem.genre = song.genre;
  589. videoItem.webdav_account_id = account.id?.toString();
  590. videoItem.remote_rel_path = song.id;
  591. videoItem.navArtistId = song.artistId;
  592. videoItem.navAlbumId = song.albumId;
  593. videoItem.pixelMapPath = coverUrl;
  594. // 调试日志:检查歌曲的 albumId 和 artistId
  595. if (this.allVideos.length < 3) {
  596. Logger.info('heanup', `歌曲 ${title}: artistId=${song.artistId}, albumId=${song.albumId}, artist=${song.artist}, album=${song.album}`);
  597. }
  598. if (song.track !== undefined && song.track !== null) {
  599. videoItem.track = song.track.toString();
  600. }
  601. if (song.year !== undefined && song.year !== null) {
  602. videoItem.year = song.year.toString();
  603. }
  604. if (song.contentType) {
  605. videoItem.mimeType = song.contentType;
  606. }
  607. return videoItem;
  608. }
  609. private formatSongDuration(durationSeconds?: number): string | undefined {
  610. if (durationSeconds === undefined || durationSeconds === null || durationSeconds < 0) {
  611. return undefined;
  612. }
  613. const totalSeconds = Math.floor(durationSeconds);
  614. const minutes = Math.floor(totalSeconds / 60);
  615. const seconds = totalSeconds % 60;
  616. const pad = (value: number) => value.toString().padStart(2, '0');
  617. return `${pad(minutes)}:${pad(seconds)}`;
  618. }
  619. @Builder
  620. topTitleBar() {
  621. Column() {
  622. Row({ space: 6 }) {
  623. // 标题或搜索框
  624. if (!this.isSearchMode) {
  625. // 左侧返回按钮
  626. Button({ type: ButtonType.Circle, stateEffect: true }) {
  627. SymbolGlyph($r('sys.symbol.sort'))
  628. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  629. }
  630. .attributeModifier(new ButtonFancyModifier(40, 40))
  631. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  632. .animation({ duration: 300, curve: Curve.Ease })
  633. .onClick(() => {
  634. this.getUIContext().animateTo({ duration: 555 }, () => {
  635. // 动画闭包内控制Image组件的出现和消失
  636. this.isShowDrawer = !this.isShowDrawer
  637. this.offsetX = 0
  638. })
  639. })
  640. .attributeModifier(new ShadowModifier())
  641. .zIndex(0)
  642. Text(this.selectedAccount.name)
  643. .margin({ left: 3, right: 10 })
  644. .fontColor($r('app.color.text_color'))
  645. .fontSize(18)
  646. .maxLines(1)
  647. .textOverflow({ overflow: TextOverflow.MARQUEE })
  648. .layoutWeight(1)
  649. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  650. .onClick(() => {
  651. // 可以添加标题点击事件
  652. })
  653. .animation({ duration: 300, curve: Curve.Ease })
  654. } else {
  655. Button({ type: ButtonType.Circle, stateEffect: true }) {
  656. SymbolGlyph($r('sys.symbol.chevron_left'))
  657. .attributeModifier(new SymbolGlyphFancyModifier(24, '', ''))
  658. }
  659. .attributeModifier(new ButtonFancyModifier(40, 40))
  660. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  661. .animation({ duration: 300, curve: Curve.Ease })
  662. .onClick(() => {
  663. this.isSearchMode = false;
  664. void this.onSearchInput('');
  665. })
  666. .attributeModifier(new ShadowModifier())
  667. .zIndex(0)
  668. }
  669. // 搜索框
  670. Search({ value: this.searchText, placeholder: '搜索标题、艺术家...' })
  671. .searchButton('搜索', { fontColor: this.themeColor })
  672. .searchIcon({
  673. src: $r('sys.media.ohos_ic_public_search_filled')
  674. })
  675. .cancelButton({
  676. style: CancelButtonStyle.CONSTANT,
  677. icon: {
  678. src: $r('sys.media.ohos_ic_public_cancel_filled')
  679. }
  680. })
  681. .layoutWeight(1)
  682. .height(35)
  683. .maxLength(20)
  684. .backgroundColor(this.isDarkMode?Color.Black:'#F5F5F5')
  685. .placeholderColor(Color.Grey)
  686. .placeholderFont({ size: 14, weight: 400 })
  687. .textFont({ size: 14, weight: 400 })
  688. .onSubmit((value: string) => {
  689. void this.onSearchInput(value);
  690. })
  691. .onChange((value: string) => {
  692. void this.onSearchInput(value);
  693. })
  694. .visibility(this.isSearchMode?Visibility.Visible:Visibility.None)
  695. .animation({ duration: 300, curve: Curve.Ease })
  696. // 搜索/排序按钮
  697. if (!this.isSearchMode) {
  698. // 搜索按钮
  699. Button({ type: ButtonType.Circle, stateEffect: true }) {
  700. SymbolGlyph($r('sys.symbol.magnifyingglass'))
  701. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  702. }
  703. .attributeModifier(new ButtonFancyModifier(40, 40))
  704. .animation({ duration: 300, curve: Curve.Ease })
  705. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  706. .attributeModifier(new ShadowModifier())
  707. .zIndex(0)
  708. .visibility(this.selectedTab==0?Visibility.Visible:Visibility.None)
  709. .onClick(() => {
  710. this.isSearchMode = true;
  711. if (!this.isSearchLoading && this.searchText.length === 0) {
  712. this.filteredList = [];
  713. }
  714. })
  715. // 排序按钮
  716. Button({ type: ButtonType.Circle, stateEffect: true }) {
  717. SymbolGlyph($r('sys.symbol.list_number'))
  718. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  719. }
  720. .attributeModifier(new ButtonFancyModifier(40, 40))
  721. .animation({ duration: 300, curve: Curve.Ease })
  722. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  723. .bindMenu(this.SortMenuBuilder)
  724. .attributeModifier(new ShadowModifier())
  725. .zIndex(0)
  726. }
  727. }
  728. .width('100%')
  729. .height(55)
  730. .padding({ left: 15, right: 15 })
  731. .justifyContent(FlexAlign.SpaceBetween)
  732. .alignItems(VerticalAlign.Center)
  733. // 分段按钮
  734. SegmentButton({
  735. options: this.tabOptions,
  736. selectedIndexes: $tabSelectedIndexes
  737. })
  738. .width('100%')
  739. .padding({ left: 25, right: 25, top: 5, bottom: 5 })
  740. }
  741. .width('100%')
  742. .padding({ top: this.topRectHeight + 5 })
  743. // .backgroundColor($r('app.color.start_window_background'))
  744. }
  745. @Builder
  746. SortMenuBuilder() {
  747. Menu() {
  748. MenuItem({
  749. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  750. content: $r('app.string.sort_by_name')
  751. })
  752. .onClick(async () => {
  753. this.doSortType(0);
  754. PreferencesUtil.put("navidromeSortType", 0);
  755. })
  756. MenuItem({
  757. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  758. content: '按名称降序'
  759. })
  760. .onClick(async () => {
  761. this.doSortType(1);
  762. PreferencesUtil.put("navidromeSortType", 1);
  763. })
  764. MenuItem({
  765. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')),
  766. content: '按艺术家升序'
  767. })
  768. .onClick(async () => {
  769. this.doSortType(2);
  770. PreferencesUtil.put("navidromeSortType", 2);
  771. })
  772. MenuItem({
  773. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.timer')),
  774. content: '按艺术家降序'
  775. })
  776. .onClick(async () => {
  777. this.doSortType(3);
  778. PreferencesUtil.put("navidromeSortType", 3);
  779. })
  780. MenuItem({
  781. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  782. content: '按专辑升序'
  783. })
  784. .onClick(async () => {
  785. this.doSortType(4);
  786. PreferencesUtil.put("navidromeSortType", 4);
  787. })
  788. MenuItem({
  789. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  790. content: '按专辑降序'
  791. })
  792. .onClick(async () => {
  793. this.doSortType(5);
  794. PreferencesUtil.put("navidromeSortType", 5);
  795. })
  796. }
  797. }
  798. doSortType(index: number) {
  799. this.sortType = index;
  800. const songs = this.isSearchMode && this.searchText.length > 0 ? this.filteredList : this.allVideos;
  801. const sortTypeNames = ['名称升序', '名称降序', '艺术家升序', '艺术家降序', '专辑升序', '专辑降序'];
  802. const sortTypeName = sortTypeNames[index] || '未知排序';
  803. // 记录排序操作
  804. void ServerLogUtil.info('NavidromeSort', `应用排序: ${sortTypeName} (${index})`);
  805. void ServerLogUtil.info('NavidromeSort', `排序范围: ${songs.length} 首歌曲 (${this.isSearchMode ? '搜索结果' : '全部歌曲'})`);
  806. const startTime = Date.now();
  807. // 对歌曲列表进行排序
  808. switch (index) {
  809. case 0: // 名称升序
  810. Utility.doSortListAscending(songs,false)
  811. break;
  812. case 1: // 名称降序
  813. Utility.doSortListDescending(songs,true)
  814. break;
  815. case 2: // 艺术家升序
  816. songs.sort((a, b) => {
  817. // 处理艺术家可能为undefined的字符串比较
  818. const artistA = a.artist?.trim() || ''; // 可选添加 trim() 处理空格
  819. const artistB = b.artist?.trim() || '';
  820. return artistA.localeCompare(artistB);
  821. });
  822. break;
  823. case 3: // 艺术家降序
  824. songs.sort((a, b) => {
  825. // 处理艺术家可能为undefined的字符串比较
  826. const artistA = a.artist?.trim() || ''; // 可选添加 trim() 处理空格
  827. const artistB = b.artist?.trim() || '';
  828. return artistB.localeCompare(artistA);
  829. });
  830. break;
  831. case 4: // 专辑升序
  832. songs.sort((a, b) => {
  833. const albumA = a.album?.trim() || ''; // 可选添加 trim() 处理空格
  834. const albumB = b.album?.trim() || '';
  835. return albumA.localeCompare(albumB);
  836. });
  837. break;
  838. case 5: // 专辑降序
  839. songs.sort((a, b) => {
  840. const albumA = a.album?.trim() || ''; // 可选添加 trim() 处理空格
  841. const albumB = b.album?.trim() || '';
  842. return albumB.localeCompare(albumA);
  843. });
  844. break;
  845. }
  846. const sortTime = Date.now() - startTime;
  847. // 更新显示列表
  848. if (this.isSearchMode) {
  849. this.filteredList = [...songs];
  850. }
  851. // 记录排序结果
  852. void ServerLogUtil.info('NavidromeSort', `排序完成: ${sortTypeName}`);
  853. void ServerLogUtil.info('NavidromeSort', `- 排序耗时: ${sortTime}ms`);
  854. void ServerLogUtil.debug('NavidromeSort', `排序结果示例: ${songs.slice(0, 3).map(s => `${s.name} (${s.artist})`).join(', ')}`);
  855. }
  856. // 实时搜索逻辑(改为远程API搜索)
  857. private async onSearchInput(value: string): Promise<void> {
  858. const keyword = value.trim();
  859. this.searchText = keyword;
  860. const ticket = ++this.searchTicket;
  861. void ServerLogUtil.debug('NavidromeSearch', `搜索输入: "${value}" -> "${keyword}"`);
  862. if (keyword.length === 0) {
  863. this.filteredList = [];
  864. this.isSearchLoading = false;
  865. void ServerLogUtil.info('NavidromeSearch', '搜索已清空,显示所有歌曲');
  866. return;
  867. }
  868. if (!this.isSearchMode) {
  869. this.isSearchMode = true;
  870. }
  871. const account = this.resolveActiveAccount();
  872. if (!account) {
  873. this.filteredList = [];
  874. this.isSearchLoading = false;
  875. ToastUtil.showToast('Navidrome账号不可用');
  876. return;
  877. }
  878. this.isSearchLoading = true;
  879. this.filteredList = [];
  880. const startTime = Date.now();
  881. void ServerLogUtil.info('NavidromeSearch', `开始远程搜索: "${keyword}"`);
  882. try {
  883. const songs = await navidromeApi.searchSongs(account, keyword, NAVIDROME_SEARCH_LIMIT);
  884. if (ticket !== this.searchTicket) {
  885. return;
  886. }
  887. const searchTime = Date.now() - startTime;
  888. if (!songs || songs.length === 0) {
  889. this.filteredList = [];
  890. void ServerLogUtil.warn('NavidromeSearch', `搜索无结果: "${keyword}"`);
  891. void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`);
  892. return;
  893. }
  894. const restSongs = this.convertApiSongsToRestSongs(songs);
  895. const videoItems = await this.convertSongsToVideoItems(restSongs, account);
  896. if (ticket !== this.searchTicket) {
  897. return;
  898. }
  899. this.filteredList = videoItems;
  900. this.doSortType(this.sortType);
  901. void ServerLogUtil.info('NavidromeSearch', `搜索完成: "${keyword}"`);
  902. void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${searchTime}ms`);
  903. void ServerLogUtil.info('NavidromeSearch', `- 匹配结果: ${videoItems.length}`);
  904. void ServerLogUtil.debug('NavidromeSearch', `搜索结果示例: ${videoItems.slice(0, 3).map(s => `${s.name} (${s.artist})`).join(', ')}`);
  905. } catch (error) {
  906. if (ticket !== this.searchTicket) {
  907. return;
  908. }
  909. this.filteredList = [];
  910. const message = (error as Error).message ?? 'Navidrome 搜索失败';
  911. ToastUtil.showToast(message);
  912. void ServerLogUtil.error('NavidromeSearch', `搜索失败: ${message}`);
  913. void ServerLogUtil.info('NavidromeSearch', `- 搜索耗时: ${Date.now() - startTime}ms`);
  914. } finally {
  915. if (ticket === this.searchTicket) {
  916. this.isSearchLoading = false;
  917. }
  918. }
  919. }
  920. private getCurrentCount(): number {
  921. switch (this.selectedTab) {
  922. case 1:
  923. return this.artists.length;
  924. case 2:
  925. return this.albums.length;
  926. default:
  927. return this.getVisibleSongs().length;
  928. }
  929. }
  930. private getEmptyTitle(): string {
  931. switch (this.selectedTab) {
  932. case 1:
  933. return '暂无艺术家';
  934. case 2:
  935. return '暂无专辑';
  936. default:
  937. if (this.isSearchMode && this.searchText.length > 0) {
  938. return this.isSearchLoading ? '正在搜索远程歌曲' : '没有匹配的远程歌曲';
  939. }
  940. if (this.filterType !== NavFilterType.None && this.isFilterLoading) {
  941. return '正在加载筛选结果';
  942. }
  943. return this.filterType === NavFilterType.None ? '暂无音乐' : '该筛选下暂无歌曲';
  944. }
  945. }
  946. private getEmptySubtitle(): string {
  947. switch (this.selectedTab) {
  948. case 1:
  949. return '当前筛选没有找到艺术家';
  950. case 2:
  951. return '当前筛选没有找到专辑';
  952. default:
  953. if (this.isSearchMode && this.searchText.length > 0) {
  954. return this.isSearchLoading ? '请稍候,正在通过 API 搜索' : '换个关键词再试试吧';
  955. }
  956. if (this.filterType !== NavFilterType.None && this.isFilterLoading) {
  957. return '请稍候...';
  958. }
  959. return this.filterType === NavFilterType.None ? '当前分类下没有找到音乐文件' : '请尝试调整筛选条件';
  960. }
  961. }
  962. @Builder
  963. buildSongItem(song: VideoItem, index: number) {
  964. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  965. Row({ space: 12 }) {
  966. // 歌曲封面
  967. Image(song.pixelMapPath ? song.pixelMapPath : $r('app.media.music_red'))
  968. .width(52)
  969. .height(52)
  970. .borderRadius(9)
  971. .sourceSize({ width: 38, height: 38 })
  972. .draggable(false)
  973. .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿
  974. .autoResize(true) // 重采样,可减少内存占用
  975. .alt($r('app.media.music_red'))
  976. .fillColor(song.pixelMapPath ? undefined : this.themeColor)
  977. .objectFit(ImageFit.Cover)
  978. .margin({ left: 20 })
  979. .shadow({
  980. radius: StrUtil.isEmpty(song.pixelMapPath) ?6:14,
  981. type: ShadowType.BLUR,
  982. color: 'on_primary'
  983. })
  984. .onClick(() => {
  985. this.playSong(song, index, true);
  986. })
  987. // 歌曲信息
  988. Column({ space: 4 }) {
  989. Text(song.name)
  990. .fontSize(15)
  991. .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color'))
  992. .maxLines(1)
  993. .textOverflow({ overflow: TextOverflow.Ellipsis })
  994. Row() {
  995. Text((song.artist ?? '') + " ")
  996. .fontSize(13)
  997. .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color'))
  998. .opacity(0.6)
  999. .maxLines(1)
  1000. .visibility(song.artist ? Visibility.Visible : Visibility.None)
  1001. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1002. Text(this.buildSongMetaLine(song))
  1003. .fontSize(13)
  1004. .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color'))
  1005. .opacity(0.6)
  1006. .maxLines(1)
  1007. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1008. }
  1009. .width('90%')
  1010. }
  1011. .alignItems(HorizontalAlign.Start)
  1012. .layoutWeight(1)
  1013. .padding({ right: 20 })
  1014. Column() {
  1015. ImageAnimator()
  1016. .images(CommonConstants.IMAGE_FRAME_INFO)// 动画数组
  1017. .duration(1000)// 持续
  1018. .state(AnimationStatus.Running)// 动画状态
  1019. .fillMode(FillMode.Forwards)
  1020. .visibility(this.currentSong?.filePath == song.filePath ? Visibility.Visible :
  1021. Visibility.None)
  1022. .width(18)
  1023. .margin({ right: 12, top: 8, bottom: 8 })
  1024. .height(18)
  1025. .iterations(-1) // 播放次数
  1026. }
  1027. }
  1028. }
  1029. .width('100%')
  1030. .padding(12)
  1031. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  1032. .backgroundColor(Color.Transparent)
  1033. .onClick(() => {
  1034. this.playSong(song, index);
  1035. })
  1036. }
  1037. @Builder
  1038. buildArtistItem(artist: NavidromeRestArtist) {
  1039. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  1040. Row({ space: 12 }) {
  1041. Image(artist.coverUrl ?? $r('app.media.music_red'))
  1042. .width(48)
  1043. .height(48)
  1044. .borderRadius(10)
  1045. .draggable(false)
  1046. .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿
  1047. .autoResize(true) // 重采样,可减少内存占用
  1048. // .sourceSize({ width: 38, height: 38 })
  1049. .fillColor(artist.coverUrl ? undefined : this.themeColor)
  1050. .objectFit(ImageFit.Cover)
  1051. .margin({ left: 20 })
  1052. Column({ space: 4 }) {
  1053. Text(artist.name ?? Constants.UNKNOWN_ARTIST)
  1054. .fontSize(15)
  1055. .fontColor($r('app.color.index_tab_font_color'))
  1056. .maxLines(1)
  1057. .textAlign(TextAlign.Start)
  1058. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1059. Text(this.buildArtistMetaLine(artist))
  1060. .fontSize(13)
  1061. .fontColor($r('app.color.index_tab_font_color'))
  1062. .opacity(0.6)
  1063. .maxLines(1)
  1064. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1065. }
  1066. .alignItems(HorizontalAlign.Start)
  1067. .padding({ right: 20 })
  1068. }
  1069. .width('100%')
  1070. }
  1071. .width('100%')
  1072. .padding(12)
  1073. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
  1074. .backgroundColor(Color.Transparent)
  1075. .onClick(() => {
  1076. this.onArtistSelected(artist);
  1077. })
  1078. }
  1079. @Builder
  1080. buildAlbumItem(album: NavidromeRestAlbum) {
  1081. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  1082. Row({ space: 12 }) {
  1083. Image(album.coverUrl ?? $r('app.media.music_red'))
  1084. .width(48)
  1085. .height(48)
  1086. .borderRadius(10)
  1087. .clip(true)
  1088. .draggable(false)
  1089. // .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿
  1090. // .autoResize(true) // 重采样,可减少内存占用
  1091. .sourceSize({ width: 38, height: 38 })
  1092. .fillColor(album.coverUrl ? undefined : this.themeColor)
  1093. .objectFit(ImageFit.Cover)
  1094. .margin({ left: 20 })
  1095. Column({ space: 4 }) {
  1096. Text(album.name ?? '未知专辑')
  1097. .fontSize(15)
  1098. .fontColor($r('app.color.index_tab_font_color'))
  1099. .maxLines(1)
  1100. .textAlign(TextAlign.Start)
  1101. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1102. Row() {
  1103. Text(album.artist ?? Constants.UNKNOWN_ARTIST)
  1104. .fontSize(13)
  1105. .fontColor($r('app.color.index_tab_font_color'))
  1106. .opacity(0.6)
  1107. .maxLines(1)
  1108. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1109. Text(this.buildAlbumMetaLine(album))
  1110. .fontSize(13)
  1111. .fontColor($r('app.color.index_tab_font_color'))
  1112. .opacity(0.6)
  1113. .maxLines(1)
  1114. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1115. }
  1116. }
  1117. .alignItems(HorizontalAlign.Start)
  1118. .padding({ right: 20 })
  1119. }
  1120. .width('100%')
  1121. }
  1122. .width('100%')
  1123. .padding(12)
  1124. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
  1125. .backgroundColor(Color.Transparent)
  1126. .onClick(() => {
  1127. this.onAlbumSelected(album);
  1128. })
  1129. }
  1130. private buildSongMetaLine(song: VideoItem): string {
  1131. const parts: string[] = [];
  1132. if (song.duration) {
  1133. parts.push(song.duration as string);
  1134. }
  1135. // if (song.size) {
  1136. // parts.push(song.size as string);
  1137. // }
  1138. // if (parts.length === 0 && song.cTime) {
  1139. // parts.push(song.cTime as string);
  1140. // }
  1141. return parts.join(' · ');
  1142. }
  1143. private buildArtistMetaLine(artist: NavidromeRestArtist): string {
  1144. const albumCount = artist.albumCount ?? 0;
  1145. const songCount = artist.songCount ?? 0;
  1146. const playCount = artist.playCount ?? 0;
  1147. return `专辑 ${albumCount} · 歌曲 ${songCount} `;
  1148. }
  1149. private buildAlbumMetaLine(album: NavidromeRestAlbum): string {
  1150. const parts: string[] = [];
  1151. if (album.duration !== undefined) {
  1152. const duration = this.formatSongDuration(album.duration);
  1153. if (duration) {
  1154. parts.push(' '+duration);
  1155. }
  1156. }
  1157. if (album.minYear) {
  1158. parts.push(`发行 ${album.minYear}`);
  1159. }
  1160. return parts.join(' · ');
  1161. }
  1162. private getVisibleSongs(): VideoItem[] {
  1163. if (this.isSearchMode) {
  1164. return this.searchText.length > 0 ? this.filteredList : this.allVideos;
  1165. }
  1166. if (this.filterType !== NavFilterType.None) {
  1167. return this.filterSongs;
  1168. }
  1169. return this.allVideos;
  1170. }
  1171. private onArtistSelected(artist: NavidromeRestArtist): void {
  1172. if (!artist || !artist.id) {
  1173. return;
  1174. }
  1175. void this.applyFilter(NavFilterType.Artist, artist.id, artist.name ?? Constants.UNKNOWN_ARTIST);
  1176. }
  1177. private onAlbumSelected(album: NavidromeRestAlbum): void {
  1178. if (!album || !album.id) {
  1179. return;
  1180. }
  1181. void this.applyFilter(NavFilterType.Album, album.id, album.name ?? '未知专辑');
  1182. }
  1183. private async applyFilter(type: NavFilterType, id: string, label: string): Promise<void> {
  1184. // 记录筛选操作
  1185. void ServerLogUtil.info('NavidromeFilter', `应用筛选:`);
  1186. void ServerLogUtil.info('NavidromeFilter', `- 类型: ${type} (${type === NavFilterType.Artist ? '艺术家' : type === NavFilterType.Album ? '专辑' : '无'})`);
  1187. void ServerLogUtil.info('NavidromeFilter', `- ID: ${id}`);
  1188. void ServerLogUtil.info('NavidromeFilter', `- 标签: ${label}`);
  1189. void ServerLogUtil.info('NavidromeFilter', `- 筛选前总歌曲数: ${this.allVideos.length}`);
  1190. // 先更新状态
  1191. this.filterType = type;
  1192. this.filterId = id;
  1193. this.filterLabel = label;
  1194. // 退出搜索模式,确保显示筛选结果
  1195. this.isSearchMode = false;
  1196. this.searchText = '';
  1197. this.filteredList = [];
  1198. this.isSearchLoading = false;
  1199. this.searchTicket++;
  1200. this.filterSongs = [];
  1201. this.isFilterLoading = true;
  1202. // 调试日志:检查筛选结果
  1203. void this.loadSongsForFilter().catch((error: Error) => {
  1204. void ServerLogUtil.error('NavidromeFilter', `筛选歌曲加载失败: ${error.message}`);
  1205. ToastUtil.showToast(error.message || '筛选数据加载失败');
  1206. });
  1207. // 然后执行动画切换标签页
  1208. this.getUIContext().animateTo({ duration: 555 }, () => {
  1209. this.selectedTab = 0;
  1210. this.tabSelectedIndexes = [0];
  1211. })
  1212. }
  1213. private async loadSongsForFilter(): Promise<void> {
  1214. if (this.filterType === NavFilterType.None || !this.filterId) {
  1215. this.filterSongs = [];
  1216. this.isFilterLoading = false;
  1217. return;
  1218. }
  1219. const account = this.resolveActiveAccount();
  1220. if (!account) {
  1221. this.filterSongs = [];
  1222. this.isFilterLoading = false;
  1223. throw new Error('Navidrome账号不可用');
  1224. }
  1225. const expectedFilterId = this.filterId;
  1226. const expectedFilterType = this.filterType;
  1227. try {
  1228. let songs: NavidromeRestSong[] = [];
  1229. if (this.filterType === NavFilterType.Artist) {
  1230. songs = await navidromeRestApi.fetchSongsByArtist(account, this.filterId);
  1231. } else if (this.filterType === NavFilterType.Album) {
  1232. songs = await navidromeRestApi.fetchSongsByAlbum(account, this.filterId);
  1233. }
  1234. const videoItems = await this.convertSongsToVideoItems(songs, account);
  1235. if (expectedFilterId !== this.filterId || expectedFilterType !== this.filterType) {
  1236. return;
  1237. }
  1238. this.filterSongs = videoItems;
  1239. if (videoItems.length === 0) {
  1240. void ServerLogUtil.warn('NavidromeFilter', '筛选下暂无歌曲');
  1241. } else {
  1242. void ServerLogUtil.info('NavidromeFilter', `筛选歌曲加载完成: ${videoItems.length} 首`);
  1243. }
  1244. } finally {
  1245. this.isFilterLoading = false;
  1246. }
  1247. }
  1248. private clearFilter(): void {
  1249. this.filterType = NavFilterType.None;
  1250. this.filterId = '';
  1251. this.filterLabel = '';
  1252. this.filterSongs = [];
  1253. this.isFilterLoading = false;
  1254. }
  1255. private playSong(song: VideoItem, index: number, isJump: boolean = false): void {
  1256. try {
  1257. if (!this.allVideos || this.allVideos.length === 0) {
  1258. ToastUtil.showToast('暂无可播放的歌曲');
  1259. return;
  1260. }
  1261. const account = this.resolveActiveAccount();
  1262. if (!account || !account.id) {
  1263. ToastUtil.showToast('Navidrome账号信息不完整,无法播放');
  1264. return;
  1265. }
  1266. // 记录播放详细信息
  1267. void ServerLogUtil.info('NavidromePlay', `开始播放歌曲: ${song.name} (#${index})`);
  1268. void ServerLogUtil.info('NavidromePlay', `歌曲信息:`);
  1269. void ServerLogUtil.info('NavidromePlay', `- ID: ${song.id}`);
  1270. void ServerLogUtil.info('NavidromePlay', `- 艺术家: ${song.artist || '未知'}`);
  1271. void ServerLogUtil.info('NavidromePlay', `- 专辑: ${song.album || '未知'}`);
  1272. void ServerLogUtil.info('NavidromePlay', `- 文件大小: ${song.size || '未知'}`);
  1273. void ServerLogUtil.info('NavidromePlay', `- 时长: ${song.duration || '未知'}`);
  1274. void ServerLogUtil.info('NavidromePlay', `- 封面: ${song.pixelMapPath ? '有' : '无'}`);
  1275. void ServerLogUtil.info('NavidromePlay', `- 播放路径: ${song.filePath}`);
  1276. const playlistSource = this.getVisibleSongs().length > 0 ? this.getVisibleSongs() : this.allVideos;
  1277. const targetIndex = playlistSource.findIndex(item => item.id === song.id);
  1278. const startIndex = targetIndex >= 0 ? targetIndex : Math.min(index, Math.max(playlistSource.length - 1, 0));
  1279. void ServerLogUtil.info('NavidromePlay', `播放列表设置: 起始索引 ${startIndex}, 总数 ${playlistSource.length}`);
  1280. setNavidromePlaylist(playlistSource, startIndex);
  1281. const playlistData: PlaylistEventData = {
  1282. playlistId: NAVIDROME_PLAYLIST_ID,
  1283. playlistName: `Navidrome - ${account.name ?? '未知账户'}`,
  1284. songCount: playlistSource.length,
  1285. startIndex,
  1286. isJump: isJump,//设置true会弹出播放页
  1287. songFilePaths: playlistSource.map(item => item.filePath)
  1288. };
  1289. const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
  1290. emitter.emit(eventPlaylistPlay, { data: playlistData });
  1291. void ServerLogUtil.info('NavidromePlay', `播放事件已发送`);
  1292. void ServerLogUtil.debug('NavidromePlay', `播放事件详情: 列表ID=${playlistData.playlistId}, 列表名称=${playlistData.playlistName}, 歌曲数=${playlistData.songCount}, 起始索引=${playlistData.startIndex}, 是否跳转=${playlistData.isJump}`);
  1293. if(!this.isNoJumpToHome){
  1294. void ServerLogUtil.debug('NavidromePlay', '跳转到首页播放器');
  1295. // 跳转到首页播放器
  1296. this.getUIContext()?.animateTo({ duration: 555 }, () => {
  1297. this.mType = 0
  1298. })
  1299. }
  1300. } catch (error) {
  1301. const err = error as Error;
  1302. void ServerLogUtil.error('NavidromePlay', `播放失败: ${err.message}`);
  1303. void ServerLogUtil.error('NavidromePlay', `失败歌曲信息: ${song.name} (${song.id})`);
  1304. ToastUtil.showToast('播放失败');
  1305. }
  1306. }
  1307. @Builder
  1308. buildContentView(){
  1309. // 主内容区域
  1310. if (this.loading) {
  1311. Column() {
  1312. LoadingProgress()
  1313. .width(50)
  1314. .height(50)
  1315. .color($r('app.color.title_bar_bg'))
  1316. Text('加载中...')
  1317. .margin({ top: 10 })
  1318. .fontSize(14)
  1319. .fontColor($r('app.color.index_tab_unselected_font_color'))
  1320. }
  1321. .width('100%')
  1322. .layoutWeight(1)
  1323. .justifyContent(FlexAlign.Center)
  1324. } else {
  1325. if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
  1326. Row({ space: 8 }) {
  1327. Text(`筛选:${this.filterLabel}`)
  1328. .fontSize(13)
  1329. .fontColor(this.themeColor)
  1330. .layoutWeight(1)
  1331. Button('清除筛选')
  1332. .type(ButtonType.Capsule)
  1333. .backgroundColor(this.themeColor)
  1334. .fontSize(12)
  1335. .onClick(() => this.clearFilter())
  1336. }
  1337. .width('90%')
  1338. .padding({ left: 16, right: 16, top: 12, bottom: 2 })
  1339. }
  1340. if (this.isSearchMode && this.selectedTab === 0 && this.searchText.length > 0 && this.isSearchLoading) {
  1341. Column() {
  1342. LoadingProgress()
  1343. .width(40)
  1344. .height(40)
  1345. .color(this.themeColor)
  1346. Text('正在通过 API 搜索歌曲...')
  1347. .margin({ top: 12 })
  1348. .fontSize(14)
  1349. .fontColor($r('app.color.index_tab_unselected_font_color'))
  1350. }
  1351. .width('100%')
  1352. .layoutWeight(1)
  1353. } else if (this.selectedTab === 0 && this.filterType !== NavFilterType.None && this.isFilterLoading) {
  1354. Column() {
  1355. LoadingProgress()
  1356. .width(40)
  1357. .height(40)
  1358. .color(this.themeColor)
  1359. Text('正在加载筛选歌曲...')
  1360. .margin({ top: 12 })
  1361. .fontSize(14)
  1362. .fontColor($r('app.color.index_tab_unselected_font_color'))
  1363. }
  1364. .width('100%')
  1365. .layoutWeight(1)
  1366. } else {
  1367. List({ space: 8 }) {
  1368. if (this.selectedTab === 0) {
  1369. ForEach(this.getVisibleSongs(), (item: VideoItem, index: number) => {
  1370. ListItem() {
  1371. this.buildSongItem(item, index)
  1372. }
  1373. }, (item: VideoItem) => item.id)
  1374. } else if (this.selectedTab === 1) {
  1375. ForEach(this.artists, (artist: NavidromeRestArtist) => {
  1376. ListItem() {
  1377. this.buildArtistItem(artist)
  1378. }
  1379. }, (artist: NavidromeRestArtist) => artist.id)
  1380. } else {
  1381. ForEach(this.albums, (album: NavidromeRestAlbum) => {
  1382. ListItem() {
  1383. this.buildAlbumItem(album)
  1384. }
  1385. }, (album: NavidromeRestAlbum) => album.id)
  1386. }
  1387. }
  1388. .width('100%')
  1389. .layoutWeight(1)
  1390. .cachedCount(3)
  1391. .padding({ top: 5, bottom: 5 })
  1392. .listDirection(Axis.Vertical)
  1393. .contentEndOffset(this.bottomSafeHeight+70)
  1394. .scrollBar(BarState.Auto)
  1395. .edgeEffect(EdgeEffect.Spring)
  1396. .onReachEnd(() => {
  1397. void this.handleReachEnd();
  1398. })
  1399. }
  1400. // 空状态
  1401. if (this.getCurrentCount() === 0 && !(this.selectedTab === 0 && this.filterType !== NavFilterType.None && this.isFilterLoading) && !(this.isSearchMode && this.searchText.length > 0 && this.isSearchLoading)) {
  1402. Column() {
  1403. Image($r('app.media.music_red'))
  1404. .width(80)
  1405. .height(80)
  1406. .opacity(0.6)
  1407. Text(this.getEmptyTitle())
  1408. .margin({ top: 16 })
  1409. .fontSize(16)
  1410. .fontColor($r('app.color.index_tab_unselected_font_color'))
  1411. Text(this.getEmptySubtitle())
  1412. .margin({ top: 8 })
  1413. .fontSize(14)
  1414. .fontColor($r('app.color.index_tab_unselected_font_color'))
  1415. }
  1416. .width('100%')
  1417. .layoutWeight(1)
  1418. .justifyContent(FlexAlign.Center)
  1419. }
  1420. }
  1421. }
  1422. build() {
  1423. Stack() {
  1424. Column() {
  1425. this.buildContentView();
  1426. }
  1427. .padding({top:this.topRectHeight+98})
  1428. Column() {
  1429. this.topTitleBar()
  1430. }
  1431. }
  1432. .alignContent(Alignment.Top)
  1433. .width('100%')
  1434. .height('100%')
  1435. }
  1436. }