NavidromePage.ets 37 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066
  1. import { VideoItem } from '../viewmodel/VideoItem';
  2. import { LengthMetrics, SegmentButton, SegmentButtonItemTuple, SegmentButtonOptions,
  3. SymbolGlyphModifier } from '@kit.ArkUI';
  4. import {
  5. PreferencesUtil, ToastUtil, StrUtil, ArrayUtil
  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 } from '../common/network/NavidromeApi';
  23. const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
  24. interface PlaylistEventData {
  25. playlistId: string;
  26. playlistName: string;
  27. songCount: number;
  28. startIndex: number;
  29. isJump: boolean;
  30. songFilePaths: string[];
  31. }
  32. enum NavFilterType {
  33. None = 0,
  34. Artist = 1,
  35. Album = 2
  36. }
  37. @Component
  38. export struct NavidromePage {
  39. @Link mType: number;
  40. @Link offsetX: number;
  41. @Link isShowDrawer: boolean;
  42. @State isNoJumpToHome: boolean = false //网盘播放不跳转首页
  43. @Prop @Watch('onSwitchAccount') selectedAccount: WebDavAccount;
  44. @StorageProp('currentTheme') currentTheme: number = 0;
  45. @State selectedTab: number = 0; // 0: 全部, 1: 艺术家, 2: 专辑
  46. @State allVideos: VideoItem[] = [];
  47. @State artists: NavidromeRestArtist[] = [];
  48. @State albums: NavidromeRestAlbum[] = [];
  49. @State loading: boolean = false;
  50. @StorageProp('isDarkMode') isDarkMode: boolean = false;
  51. @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
  52. @StorageProp('topRectHeight') topRectHeight: number = 0;
  53. @State @Watch('onTabSelectedIndexesChanged') tabSelectedIndexes: number[] = [0];
  54. @StorageProp('themeColor') themeColor: string =
  55. PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR);
  56. private tabs: string[] = ['全部', '艺术家', '专辑'];
  57. private loadTicket: number = 0;
  58. @State filterType: NavFilterType = NavFilterType.None;
  59. @State filterLabel: string = '';
  60. @State filterId: string = '';
  61. private coverUrlCache: Map<string, string> = new Map();
  62. // 搜索和排序相关状态
  63. @State isSearchMode: boolean = false;
  64. @State searchText: string = ''; // 用户输入内容
  65. @State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
  66. @State sortType: number = 0; // 排序类型 0:名称升序 1:名称降序 2:时间升序 3:时间降序 4:大小升序 5:大小降序
  67. // SegmentButton选项
  68. @State tabOptions: SegmentButtonOptions = SegmentButtonOptions.capsule({
  69. buttons: [{ text: '全部' }, { text: '艺术家' }, { text: '专辑' }] as SegmentButtonItemTuple,
  70. direction: Direction.Ltr,
  71. buttonPadding: { top: 12, bottom: 12 },
  72. backgroundColor: $r('app.color.index_background'),
  73. selectedBackgroundColor: $r('app.color.start_window_background'),
  74. selectedFontColor: $r('app.color.text_color'),
  75. fontSize: 14,
  76. selectedFontSize: 15,
  77. localizedTextPadding: {
  78. end: LengthMetrics.vp(20),
  79. start: LengthMetrics.vp(20)
  80. }
  81. });
  82. //当胶囊按钮的选择发生变化时调用此函数
  83. onTabSelectedIndexesChanged() {
  84. this.selectedTab = this.tabSelectedIndexes[0];
  85. console.info('heanup', `Selected tab: ${this.tabs[this.selectedTab]}`);
  86. // 从艺术家或专辑切换回全部时,清除筛选状态
  87. if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
  88. // 不清除筛选,保持筛选状态
  89. } else if (this.selectedTab !== 0) {
  90. // 切换到艺术家或专辑标签页时,清除筛选和搜索状态
  91. this.clearFilter();
  92. this.isSearchMode = false;
  93. this.searchText = '';
  94. this.filteredList = [];
  95. }
  96. }
  97. //切换不同的NavidromePage
  98. async onSwitchAccount() {
  99. await this.refreshNavidromeData(true);
  100. }
  101. aboutToAppear() {
  102. this.isNoJumpToHome = PreferencesUtil.getBooleanSync('isNoJumpToHome', true)
  103. this.sortType = PreferencesUtil.getNumberSync('navidromeSortType', 0);
  104. this.refreshNavidromeData();
  105. }
  106. private async refreshNavidromeData(showToastWhenMissing: boolean = false): Promise<void> {
  107. const account = this.resolveActiveAccount();
  108. if (!account) {
  109. this.resetData();
  110. if (showToastWhenMissing) {
  111. ToastUtil.showToast('请先选择 Navidrome 账号');
  112. }
  113. return;
  114. }
  115. await this.loadNavidromeLibrary(account);
  116. this.doSortType(this.sortType)
  117. }
  118. private resolveActiveAccount(): WebDavAccount | undefined {
  119. if (!this.selectedAccount) {
  120. return undefined;
  121. }
  122. if (this.selectedAccount.webType !== RemoteDriveType.Navidrome) {
  123. return undefined;
  124. }
  125. if (!this.selectedAccount.host || this.selectedAccount.host.length === 0) {
  126. return undefined;
  127. }
  128. return this.selectedAccount;
  129. }
  130. private resetData(): void {
  131. this.allVideos = [];
  132. this.artists = [];
  133. this.albums = [];
  134. this.clearFilter();
  135. this.coverUrlCache.clear();
  136. }
  137. private async loadNavidromeLibrary(account: WebDavAccount): Promise<void> {
  138. const ticket = ++this.loadTicket;
  139. this.loading = true;
  140. this.coverUrlCache.clear();
  141. try {
  142. const requestTasks: Promise<object>[] = [
  143. navidromeRestApi.fetchAllSongs(account),
  144. navidromeRestApi.fetchArtists(account),
  145. navidromeRestApi.fetchAlbums(account)
  146. ];
  147. const responses = await Promise.all(requestTasks);
  148. const songs = responses[0] as NavidromeRestSong[];
  149. const artistList = responses[1] as NavidromeRestArtist[];
  150. const albumList = responses[2] as NavidromeRestAlbum[];
  151. if (ticket !== this.loadTicket) {
  152. return;
  153. }
  154. const albumCoverMap = await this.buildAlbumCoverMap(albumList, account);
  155. if (ticket !== this.loadTicket) {
  156. return;
  157. }
  158. const artistCoverMap = await this.buildArtistCoverMap(artistList, account);
  159. if (ticket !== this.loadTicket) {
  160. return;
  161. }
  162. const videos = await this.convertSongsToVideoItems(songs, account, albumCoverMap);
  163. if (ticket !== this.loadTicket) {
  164. return;
  165. }
  166. this.allVideos = videos;
  167. this.artists = artistList.map(artist => {
  168. artist.coverUrl = artistCoverMap.get(artist.id);
  169. return artist;
  170. });
  171. this.albums = albumList.map(album => {
  172. album.coverUrl = albumCoverMap.get(album.id);
  173. return album;
  174. });
  175. Logger.info('heanup', `Navidrome 已加载: 歌曲 ${songs.length} 首, 艺术家 ${artistList.length} 位, 专辑 ${albumList.length} 张`);
  176. } catch (error) {
  177. if (ticket === this.loadTicket) {
  178. Logger.error('heanup', `Navidrome 数据加载失败: ${(error as Error).message}`);
  179. ToastUtil.showToast((error as Error).message ?? 'Navidrome 数据加载失败');
  180. }
  181. } finally {
  182. if (ticket === this.loadTicket) {
  183. this.loading = false;
  184. }
  185. }
  186. }
  187. private async buildAlbumCoverMap(albums: NavidromeRestAlbum[], account: WebDavAccount): Promise<Map<string, string>> {
  188. const map = new Map<string, string>();
  189. const tasks: Promise<void>[] = [];
  190. for (let i = 0; i < albums.length; i++) {
  191. const album = albums[i];
  192. tasks.push((async () => {
  193. try {
  194. const directUrl = this.resolveEmbedCover(account, album.embedArtPath ?? album.coverArtPath);
  195. if (directUrl) {
  196. map.set(album.id, directUrl);
  197. return;
  198. }
  199. const coverId = album.coverArt ?? album.coverArtId ?? (album.id ? `al-${album.id}` : undefined);
  200. const url = await this.buildCoverUrl(account, coverId);
  201. if (url) {
  202. map.set(album.id, url);
  203. }
  204. } catch (error) {
  205. Logger.warn('heanup', `Navidrome 专辑封面解析失败: ${(error as Error).message}`);
  206. }
  207. })());
  208. }
  209. await Promise.all(tasks);
  210. return map;
  211. }
  212. private async buildArtistCoverMap(artists: NavidromeRestArtist[], account: WebDavAccount): Promise<Map<string, string>> {
  213. const map = new Map<string, string>();
  214. const tasks: Promise<void>[] = [];
  215. for (let i = 0; i < artists.length; i++) {
  216. const artist = artists[i];
  217. tasks.push((async () => {
  218. try {
  219. const directUrl = artist.mediumImageUrl ?? artist.largeImageUrl ?? this.resolveEmbedCover(account, artist.coverArtPath);
  220. if (directUrl) {
  221. map.set(artist.id, directUrl);
  222. return;
  223. }
  224. const coverId = artist.coverArt ?? artist.coverArtId ?? (artist.id ? `ar-${artist.id}` : undefined);
  225. const url = await this.buildCoverUrl(account, coverId, 256);
  226. if (url) {
  227. map.set(artist.id, url);
  228. }
  229. } catch (error) {
  230. Logger.warn('heanup', `Navidrome 艺术家封面解析失败: ${(error as Error).message}`);
  231. }
  232. })());
  233. }
  234. await Promise.all(tasks);
  235. return map;
  236. }
  237. private async convertSongsToVideoItems(songs: NavidromeRestSong[], account: WebDavAccount,
  238. albumCoverMap: Map<string, string>): Promise<VideoItem[]> {
  239. const tasks: Promise<VideoItem>[] = [];
  240. for (let i = 0; i < songs.length; i++) {
  241. const song = songs[i];
  242. tasks.push((async () => {
  243. let coverUrl: string | undefined;
  244. try {
  245. coverUrl = await this.resolveSongCover(song, account, albumCoverMap);
  246. } catch (error) {
  247. Logger.warn('heanup', `Navidrome 单曲封面解析失败: ${(error as Error).message}`);
  248. }
  249. return this.convertSongToVideoItem(song, account, coverUrl);
  250. })());
  251. }
  252. return Promise.all(tasks);
  253. }
  254. private async resolveSongCover(song: NavidromeRestSong, account: WebDavAccount,
  255. albumCoverMap: Map<string, string>): Promise<string | undefined> {
  256. if (song.albumId) {
  257. const albumCover = albumCoverMap.get(song.albumId);
  258. if (albumCover) {
  259. return albumCover;
  260. }
  261. }
  262. const directUrl = this.resolveEmbedCover(account, song.embedArtPath ?? song.coverArtPath);
  263. if (directUrl) {
  264. return directUrl;
  265. }
  266. const coverId = song.coverArt ?? song.coverArtId ?? song.id;
  267. return this.buildCoverUrl(account, coverId);
  268. }
  269. private async buildCoverUrl(account: WebDavAccount, coverId?: string, size: number = 300): Promise<string | undefined> {
  270. if (!coverId || coverId.trim().length === 0) {
  271. return undefined;
  272. }
  273. const normalizedId = coverId.trim();
  274. const cacheKey = `${normalizedId}_${size}`;
  275. const cached = this.coverUrlCache.get(cacheKey);
  276. if (cached) {
  277. return cached;
  278. }
  279. const url = await navidromeApi.buildCoverArtUrl(account, normalizedId, size);
  280. if (url) {
  281. this.coverUrlCache.set(cacheKey, url);
  282. }
  283. return url;
  284. }
  285. private resolveEmbedCover(account: WebDavAccount, path?: string): string | undefined {
  286. return navidromeRestApi.resolveResourceUrl(account, path);
  287. }
  288. private convertSongToVideoItem(song: NavidromeRestSong, account: WebDavAccount, coverUrl?: string): VideoItem {
  289. const title = song.title ?? Constants.UNKNOWN_TITLE;
  290. const videoItem = new VideoItem(
  291. title,
  292. song.id,
  293. `navidrome://${account.id ?? 0}/${song.id}`,
  294. CommonConstants.TYPE_NAVIDROME,
  295. song.size ?? 0,
  296. song.createdAt ?? '',
  297. Utility.formatFSize(song.size ?? 0),
  298. undefined,
  299. song.artist ?? Constants.UNKNOWN_ARTIST,
  300. song.album ?? '',
  301. `${title}${song.suffix ? '.' + song.suffix : ''}`
  302. );
  303. const durationStr = this.formatSongDuration(song.duration);
  304. if (durationStr) {
  305. videoItem.duration = durationStr;
  306. }
  307. videoItem.size = Utility.formatFSize(song.size ?? 0);
  308. videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
  309. videoItem.genre = song.genre;
  310. videoItem.webdav_account_id = account.id?.toString();
  311. videoItem.remote_rel_path = song.id;
  312. videoItem.navArtistId = song.artistId;
  313. videoItem.navAlbumId = song.albumId;
  314. videoItem.pixelMapPath = coverUrl;
  315. // 调试日志:检查歌曲的 albumId 和 artistId
  316. if (this.allVideos.length < 3) {
  317. Logger.info('heanup', `歌曲 ${title}: artistId=${song.artistId}, albumId=${song.albumId}, artist=${song.artist}, album=${song.album}`);
  318. }
  319. if (song.track !== undefined && song.track !== null) {
  320. videoItem.track = song.track.toString();
  321. }
  322. if (song.year !== undefined && song.year !== null) {
  323. videoItem.year = song.year.toString();
  324. }
  325. if (song.contentType) {
  326. videoItem.mimeType = song.contentType;
  327. }
  328. return videoItem;
  329. }
  330. private formatSongDuration(durationSeconds?: number): string | undefined {
  331. if (durationSeconds === undefined || durationSeconds === null || durationSeconds < 0) {
  332. return undefined;
  333. }
  334. const totalSeconds = Math.floor(durationSeconds);
  335. const minutes = Math.floor(totalSeconds / 60);
  336. const seconds = totalSeconds % 60;
  337. const pad = (value: number) => value.toString().padStart(2, '0');
  338. return `${pad(minutes)}:${pad(seconds)}`;
  339. }
  340. @Builder
  341. topTitleBar() {
  342. Column() {
  343. Row({ space: 6 }) {
  344. // 标题或搜索框
  345. if (!this.isSearchMode) {
  346. // 左侧返回按钮
  347. Button({ type: ButtonType.Circle, stateEffect: true }) {
  348. SymbolGlyph($r('sys.symbol.sort'))
  349. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  350. }
  351. .attributeModifier(new ButtonFancyModifier(40, 40))
  352. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  353. .animation({ duration: 300, curve: Curve.Ease })
  354. .onClick(() => {
  355. this.getUIContext().animateTo({ duration: 555 }, () => {
  356. // 动画闭包内控制Image组件的出现和消失
  357. this.isShowDrawer = !this.isShowDrawer
  358. this.offsetX = 0
  359. })
  360. })
  361. .attributeModifier(new ShadowModifier())
  362. .zIndex(0)
  363. Text(this.selectedAccount.name)
  364. .margin({ left: 3, right: 10 })
  365. .fontColor($r('app.color.text_color'))
  366. .fontSize(18)
  367. .maxLines(1)
  368. .textOverflow({ overflow: TextOverflow.MARQUEE })
  369. .layoutWeight(1)
  370. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  371. .onClick(() => {
  372. // 可以添加标题点击事件
  373. })
  374. .animation({ duration: 300, curve: Curve.Ease })
  375. } else {
  376. Button({ type: ButtonType.Circle, stateEffect: true }) {
  377. SymbolGlyph($r('sys.symbol.chevron_left'))
  378. .attributeModifier(new SymbolGlyphFancyModifier(24, '', ''))
  379. }
  380. .attributeModifier(new ButtonFancyModifier(40, 40))
  381. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  382. .animation({ duration: 300, curve: Curve.Ease })
  383. .onClick(() => {
  384. this.isSearchMode = false;
  385. this.onSearchInput('');
  386. })
  387. .attributeModifier(new ShadowModifier())
  388. .zIndex(0)
  389. }
  390. // 搜索框
  391. Search({ value: this.searchText, placeholder: '搜索标题、艺术家...' })
  392. .searchButton('搜索', { fontColor: this.themeColor })
  393. .searchIcon({
  394. src: $r('sys.media.ohos_ic_public_search_filled')
  395. })
  396. .cancelButton({
  397. style: CancelButtonStyle.CONSTANT,
  398. icon: {
  399. src: $r('sys.media.ohos_ic_public_cancel_filled')
  400. }
  401. })
  402. .layoutWeight(1)
  403. .height(35)
  404. .maxLength(20)
  405. .backgroundColor(this.isDarkMode?Color.Black:'#F5F5F5')
  406. .placeholderColor(Color.Grey)
  407. .placeholderFont({ size: 14, weight: 400 })
  408. .textFont({ size: 14, weight: 400 })
  409. .onSubmit((value: string) => {
  410. this.onSearchInput(value);
  411. })
  412. .onChange((value: string) => {
  413. this.onSearchInput(value);
  414. })
  415. .visibility(this.isSearchMode?Visibility.Visible:Visibility.None)
  416. .animation({ duration: 300, curve: Curve.Ease })
  417. // 搜索/排序按钮
  418. if (!this.isSearchMode) {
  419. // 搜索按钮
  420. Button({ type: ButtonType.Circle, stateEffect: true }) {
  421. SymbolGlyph($r('sys.symbol.magnifyingglass'))
  422. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  423. }
  424. .attributeModifier(new ButtonFancyModifier(40, 40))
  425. .animation({ duration: 300, curve: Curve.Ease })
  426. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  427. .attributeModifier(new ShadowModifier())
  428. .zIndex(0)
  429. .visibility(this.selectedTab==0?Visibility.Visible:Visibility.None)
  430. .onClick(() => {
  431. this.isSearchMode = true;
  432. if(ArrayUtil.isEmpty(this.filteredList)){
  433. this.filteredList = [...this.allVideos];
  434. }
  435. })
  436. // 排序按钮
  437. Button({ type: ButtonType.Circle, stateEffect: true }) {
  438. SymbolGlyph($r('sys.symbol.list_number'))
  439. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  440. }
  441. .attributeModifier(new ButtonFancyModifier(40, 40))
  442. .animation({ duration: 300, curve: Curve.Ease })
  443. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  444. .bindMenu(this.SortMenuBuilder)
  445. .attributeModifier(new ShadowModifier())
  446. .zIndex(0)
  447. }
  448. }
  449. .width('100%')
  450. .height(55)
  451. .padding({ left: 15, right: 15 })
  452. .justifyContent(FlexAlign.SpaceBetween)
  453. .alignItems(VerticalAlign.Center)
  454. // 分段按钮
  455. SegmentButton({
  456. options: this.tabOptions,
  457. selectedIndexes: $tabSelectedIndexes
  458. })
  459. .width('100%')
  460. .padding({ left: 25, right: 25, top: 5, bottom: 5 })
  461. }
  462. .width('100%')
  463. .padding({ top: this.topRectHeight + 5 })
  464. .backgroundColor($r('app.color.start_window_background'))
  465. }
  466. @Builder
  467. SortMenuBuilder() {
  468. Menu() {
  469. MenuItem({
  470. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  471. content: $r('app.string.sort_by_name')
  472. })
  473. .onClick(async () => {
  474. this.doSortType(0);
  475. PreferencesUtil.put("navidromeSortType", 0);
  476. })
  477. MenuItem({
  478. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  479. content: '按名称降序'
  480. })
  481. .onClick(async () => {
  482. this.doSortType(1);
  483. PreferencesUtil.put("navidromeSortType", 1);
  484. })
  485. MenuItem({
  486. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')),
  487. content: '按艺术家升序'
  488. })
  489. .onClick(async () => {
  490. this.doSortType(2);
  491. PreferencesUtil.put("navidromeSortType", 2);
  492. })
  493. MenuItem({
  494. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.timer')),
  495. content: '按艺术家降序'
  496. })
  497. .onClick(async () => {
  498. this.doSortType(3);
  499. PreferencesUtil.put("navidromeSortType", 3);
  500. })
  501. MenuItem({
  502. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  503. content: '按专辑升序'
  504. })
  505. .onClick(async () => {
  506. this.doSortType(4);
  507. PreferencesUtil.put("navidromeSortType", 4);
  508. })
  509. MenuItem({
  510. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  511. content: '按专辑降序'
  512. })
  513. .onClick(async () => {
  514. this.doSortType(5);
  515. PreferencesUtil.put("navidromeSortType", 5);
  516. })
  517. }
  518. }
  519. doSortType(index: number) {
  520. this.sortType = index;
  521. const songs = this.isSearchMode ? this.filteredList :this.allVideos;
  522. // 对歌曲列表进行排序
  523. switch (index) {
  524. case 0: // 名称升序
  525. Utility.doSortListAscending(songs,false)
  526. break;
  527. case 1: // 名称降序
  528. Utility.doSortListDescending(songs,true)
  529. break;
  530. case 2: // 艺术家升序
  531. songs.sort((a, b) => {
  532. // 处理艺术家可能为undefined的字符串比较
  533. const artistA = a.artist?.trim() || ''; // 可选添加 trim() 处理空格
  534. const artistB = b.artist?.trim() || '';
  535. return artistA.localeCompare(artistB);
  536. });
  537. break;
  538. case 3: // 艺术家降序
  539. songs.sort((a, b) => {
  540. // 处理艺术家可能为undefined的字符串比较
  541. const artistA = a.artist?.trim() || ''; // 可选添加 trim() 处理空格
  542. const artistB = b.artist?.trim() || '';
  543. return artistB.localeCompare(artistA);
  544. });
  545. break;
  546. case 4: // 专辑升序
  547. songs.sort((a, b) => {
  548. const albumA = a.album?.trim() || ''; // 可选添加 trim() 处理空格
  549. const albumB = b.album?.trim() || '';
  550. return albumA.localeCompare(albumB);
  551. });
  552. break;
  553. case 5: // 专辑降序
  554. songs.sort((a, b) => {
  555. const albumA = a.album?.trim() || ''; // 可选添加 trim() 处理空格
  556. const albumB = b.album?.trim() || '';
  557. return albumB.localeCompare(albumA);
  558. });
  559. break;
  560. }
  561. // 更新显示列表
  562. if (this.isSearchMode) {
  563. this.filteredList = [...songs];
  564. }
  565. }
  566. // 实时搜索逻辑
  567. private onSearchInput(value: string) {
  568. this.searchText = value.trim();
  569. let mSearchList: Array<VideoItem> = [...this.allVideos];
  570. // 新增条件判断:空输入时显示所有数据
  571. if (this.searchText === '') {
  572. this.filteredList = [...mSearchList]; // 创建新数组以触发状态更新
  573. } else {
  574. this.filteredList = mSearchList.filter((item: VideoItem) => {
  575. // 支持模糊匹配和艺术家 专辑匹配
  576. const regex = new RegExp(this.searchText.toLowerCase().replace(/\s+/g, '.*'), 'i');
  577. return regex.test(item.name.toLowerCase()) ||
  578. regex.test(item.fileName?.toLowerCase() ?? "") ||
  579. regex.test(item.artist?.toLowerCase() ?? "") ||
  580. regex.test(item.album?.toLowerCase() ?? "")
  581. });
  582. }
  583. }
  584. private getCurrentCount(): number {
  585. switch (this.selectedTab) {
  586. case 1:
  587. return this.artists.length;
  588. case 2:
  589. return this.albums.length;
  590. default:
  591. return this.getVisibleSongs().length;
  592. }
  593. }
  594. private getEmptyTitle(): string {
  595. switch (this.selectedTab) {
  596. case 1:
  597. return '暂无艺术家';
  598. case 2:
  599. return '暂无专辑';
  600. default:
  601. return this.filterType === NavFilterType.None ? '暂无音乐' : '该筛选下暂无歌曲';
  602. }
  603. }
  604. private getEmptySubtitle(): string {
  605. switch (this.selectedTab) {
  606. case 1:
  607. return '当前筛选没有找到艺术家';
  608. case 2:
  609. return '当前筛选没有找到专辑';
  610. default:
  611. return this.filterType === NavFilterType.None ? '当前分类下没有找到音乐文件' : '请尝试调整筛选条件';
  612. }
  613. }
  614. @Builder
  615. buildSongItem(song: VideoItem, index: number) {
  616. Button({ type: ButtonType.Normal, stateEffect: false }) {
  617. Row({ space: 12 }) {
  618. // 歌曲封面
  619. Image(song.pixelMapPath ? song.pixelMapPath : $r('app.media.music_red'))
  620. .width(48)
  621. .height(48)
  622. .borderRadius(10)
  623. .sourceSize({ width: 38, height: 38 })
  624. .alt($r('app.media.music_red'))
  625. .fillColor(song.pixelMapPath ? undefined : this.themeColor)
  626. .objectFit(ImageFit.Cover)
  627. .margin({ left: 8 })
  628. .onClick(() => {
  629. this.playSong(song, index, true);
  630. })
  631. // 歌曲信息
  632. Column({ space: 4 }) {
  633. Text(song.name)
  634. .fontSize(15)
  635. .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color'))
  636. .maxLines(1)
  637. .textOverflow({ overflow: TextOverflow.Ellipsis })
  638. Row() {
  639. Text((song.artist ?? '') + " ")
  640. .fontSize(13)
  641. .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color'))
  642. .opacity(0.6)
  643. .maxLines(1)
  644. .visibility(song.artist ? Visibility.Visible : Visibility.None)
  645. .textOverflow({ overflow: TextOverflow.Ellipsis })
  646. Text(this.buildSongMetaLine(song))
  647. .fontSize(13)
  648. .fontColor(this.currentSong?.filePath == song.filePath ? this.themeColor : $r('app.color.index_tab_font_color'))
  649. .opacity(0.6)
  650. .maxLines(1)
  651. .textOverflow({ overflow: TextOverflow.Ellipsis })
  652. }
  653. .width('90%')
  654. }
  655. .alignItems(HorizontalAlign.Start)
  656. .layoutWeight(1)
  657. .padding({ right: 20 })
  658. Column() {
  659. ImageAnimator()
  660. .images(CommonConstants.IMAGE_FRAME_INFO)// 动画数组
  661. .duration(1000)// 持续
  662. .state(AnimationStatus.Running)// 动画状态
  663. .fillMode(FillMode.Forwards)
  664. .visibility(this.currentSong?.filePath == song.filePath ? Visibility.Visible :
  665. Visibility.None)
  666. .width(18)
  667. .margin({ right: 12, top: 8, bottom: 8 })
  668. .height(18)
  669. .iterations(-1) // 播放次数
  670. }
  671. }
  672. }
  673. .width('100%')
  674. .padding(12)
  675. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  676. .backgroundColor(Color.Transparent)
  677. .onClick(() => {
  678. this.playSong(song, index);
  679. })
  680. }
  681. @Builder
  682. buildArtistItem(artist: NavidromeRestArtist) {
  683. Button({ type: ButtonType.Normal, stateEffect: false }) {
  684. Row({ space: 12 }) {
  685. Image(artist.coverUrl ?? $r('app.media.music_red'))
  686. .width(48)
  687. .height(48)
  688. .borderRadius(10)
  689. .fillColor(artist.coverUrl ? undefined : this.themeColor)
  690. .objectFit(ImageFit.Cover)
  691. .margin({ left: 8 })
  692. Column({ space: 4 }) {
  693. Text(artist.name ?? Constants.UNKNOWN_ARTIST)
  694. .fontSize(15)
  695. .fontColor($r('app.color.index_tab_font_color'))
  696. .maxLines(1)
  697. .textAlign(TextAlign.Start)
  698. .textOverflow({ overflow: TextOverflow.Ellipsis })
  699. Text(this.buildArtistMetaLine(artist))
  700. .fontSize(13)
  701. .fontColor($r('app.color.index_tab_font_color'))
  702. .opacity(0.6)
  703. .maxLines(1)
  704. .textOverflow({ overflow: TextOverflow.Ellipsis })
  705. }
  706. .alignItems(HorizontalAlign.Start)
  707. .padding({ right: 20 })
  708. }
  709. .width('100%')
  710. }
  711. .width('100%')
  712. .padding(12)
  713. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
  714. .backgroundColor(Color.Transparent)
  715. .onClick(() => {
  716. this.onArtistSelected(artist);
  717. })
  718. }
  719. @Builder
  720. buildAlbumItem(album: NavidromeRestAlbum) {
  721. Button({ type: ButtonType.Normal, stateEffect: false }) {
  722. Row({ space: 12 }) {
  723. Image(album.coverUrl ?? $r('app.media.music_red'))
  724. .width(48)
  725. .height(48)
  726. .borderRadius(10)
  727. .fillColor(album.coverUrl ? undefined : this.themeColor)
  728. .objectFit(ImageFit.Cover)
  729. .margin({ left: 8 })
  730. Column({ space: 4 }) {
  731. Text(album.name ?? '未知专辑')
  732. .fontSize(15)
  733. .fontColor($r('app.color.index_tab_font_color'))
  734. .maxLines(1)
  735. .textAlign(TextAlign.Start)
  736. .textOverflow({ overflow: TextOverflow.Ellipsis })
  737. Row() {
  738. Text(album.artist ?? Constants.UNKNOWN_ARTIST)
  739. .fontSize(13)
  740. .fontColor($r('app.color.index_tab_font_color'))
  741. .opacity(0.6)
  742. .maxLines(1)
  743. .textOverflow({ overflow: TextOverflow.Ellipsis })
  744. Text(this.buildAlbumMetaLine(album))
  745. .fontSize(13)
  746. .fontColor($r('app.color.index_tab_font_color'))
  747. .opacity(0.6)
  748. .maxLines(1)
  749. .textOverflow({ overflow: TextOverflow.Ellipsis })
  750. }
  751. }
  752. .alignItems(HorizontalAlign.Start)
  753. .padding({ right: 20 })
  754. }
  755. .width('100%')
  756. }
  757. .width('100%')
  758. .padding(12)
  759. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
  760. .backgroundColor(Color.Transparent)
  761. .onClick(() => {
  762. this.onAlbumSelected(album);
  763. })
  764. }
  765. private buildSongMetaLine(song: VideoItem): string {
  766. const parts: string[] = [];
  767. if (song.duration) {
  768. parts.push(song.duration as string);
  769. }
  770. // if (song.size) {
  771. // parts.push(song.size as string);
  772. // }
  773. // if (parts.length === 0 && song.cTime) {
  774. // parts.push(song.cTime as string);
  775. // }
  776. return parts.join(' · ');
  777. }
  778. private buildArtistMetaLine(artist: NavidromeRestArtist): string {
  779. const albumCount = artist.albumCount ?? 0;
  780. const songCount = artist.songCount ?? 0;
  781. const playCount = artist.playCount ?? 0;
  782. return `专辑 ${albumCount} · 歌曲 ${songCount} `;
  783. }
  784. private buildAlbumMetaLine(album: NavidromeRestAlbum): string {
  785. const parts: string[] = [];
  786. if (album.duration !== undefined) {
  787. const duration = this.formatSongDuration(album.duration);
  788. if (duration) {
  789. parts.push(duration);
  790. }
  791. }
  792. if (album.minYear) {
  793. parts.push(`发行 ${album.minYear}`);
  794. }
  795. return parts.join(' · ');
  796. }
  797. private getVisibleSongs(): VideoItem[] {
  798. if(this.isSearchMode)
  799. return this.filteredList;
  800. if (this.filterType === NavFilterType.Artist && this.filterId.length > 0) {
  801. const filtered = this.allVideos.filter(item => item.navArtistId === this.filterId || item.artist === this.filterLabel);
  802. Logger.info('heanup', `艺术家筛选: filterId=${this.filterId}, filterLabel=${this.filterLabel}, 结果数=${filtered.length}`);
  803. return filtered;
  804. }
  805. if (this.filterType === NavFilterType.Album && this.filterId.length > 0) {
  806. const filtered = this.allVideos.filter(item => {
  807. const match = item.navAlbumId === this.filterId || item.album === this.filterLabel;
  808. if (!match && this.allVideos.indexOf(item) < 3) {
  809. // 只打印前3首歌的调试信息
  810. Logger.info('heanup', `歌曲 ${item.name}: navAlbumId=${item.navAlbumId}, album=${item.album}, 期望albumId=${this.filterId}, 期望album=${this.filterLabel}`);
  811. }
  812. return match;
  813. });
  814. Logger.info('heanup', `专辑筛选: filterId=${this.filterId}, filterLabel=${this.filterLabel}, 结果数=${filtered.length}`);
  815. return filtered;
  816. }
  817. return this.allVideos;
  818. }
  819. private onArtistSelected(artist: NavidromeRestArtist): void {
  820. if (!artist || !artist.id) {
  821. return;
  822. }
  823. this.applyFilter(NavFilterType.Artist, artist.id, artist.name ?? Constants.UNKNOWN_ARTIST);
  824. }
  825. private onAlbumSelected(album: NavidromeRestAlbum): void {
  826. if (!album || !album.id) {
  827. return;
  828. }
  829. this.applyFilter(NavFilterType.Album, album.id, album.name ?? '未知专辑');
  830. }
  831. private applyFilter(type: NavFilterType, id: string, label: string): void {
  832. Logger.info('heanup', `应用筛选: type=${type}, id=${id}, label=${label}`);
  833. // 先更新状态
  834. this.filterType = type;
  835. this.filterId = id;
  836. this.filterLabel = label;
  837. // 退出搜索模式,确保显示筛选结果
  838. this.isSearchMode = false;
  839. this.searchText = '';
  840. this.filteredList = [];
  841. // 调试日志:检查筛选结果
  842. const visibleSongs = this.getVisibleSongs();
  843. Logger.info('heanup', `筛选后歌曲数量: ${visibleSongs.length}, 总歌曲数: ${this.allVideos.length}`);
  844. if (visibleSongs.length > 0) {
  845. Logger.info('heanup', `第一首歌: ${visibleSongs[0].name}, albumId=${visibleSongs[0].navAlbumId}, album=${visibleSongs[0].album}`);
  846. }
  847. // 然后执行动画切换标签页
  848. this.getUIContext().animateTo({ duration: 555 }, () => {
  849. this.selectedTab = 0;
  850. this.tabSelectedIndexes = [0];
  851. })
  852. }
  853. private clearFilter(): void {
  854. this.filterType = NavFilterType.None;
  855. this.filterId = '';
  856. this.filterLabel = '';
  857. }
  858. private playSong(song: VideoItem, index: number, isJump: boolean = false): void {
  859. try {
  860. if (!this.allVideos || this.allVideos.length === 0) {
  861. ToastUtil.showToast('暂无可播放的歌曲');
  862. return;
  863. }
  864. const account = this.resolveActiveAccount();
  865. if (!account || !account.id) {
  866. ToastUtil.showToast('Navidrome账号信息不完整,无法播放');
  867. return;
  868. }
  869. Logger.info('heanup', `Navidrome 播放歌曲: ${song.name}, 索引: ${index}`);
  870. const targetIndex = this.allVideos.findIndex(item => item.id === song.id);
  871. const startIndex = targetIndex >= 0 ? targetIndex : index;
  872. setNavidromePlaylist(this.allVideos, startIndex);
  873. const playlistData: PlaylistEventData = {
  874. playlistId: NAVIDROME_PLAYLIST_ID,
  875. playlistName: `Navidrome - ${account.name ?? '未知账户'}`,
  876. songCount: this.allVideos.length,
  877. startIndex,
  878. isJump: isJump,//设置true会弹出播放页
  879. songFilePaths: this.allVideos.map(item => item.filePath)
  880. };
  881. const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
  882. emitter.emit(eventPlaylistPlay, { data: playlistData });
  883. Logger.info('heanup', `Navidrome 发送播放事件,歌曲数: ${this.allVideos.length}, 起始: ${index}`);
  884. if(!this.isNoJumpToHome){
  885. // 跳转到首页播放器
  886. this.getUIContext()?.animateTo({ duration: 555 }, () => {
  887. this.mType = 0
  888. })
  889. }
  890. } catch (error) {
  891. const err = error as Error;
  892. Logger.error('heanup', '播放歌曲失败: ' + err.message);
  893. ToastUtil.showToast('播放失败');
  894. }
  895. }
  896. @Builder
  897. buildContentView(){
  898. // 主内容区域
  899. if (this.loading) {
  900. Column() {
  901. LoadingProgress()
  902. .width(50)
  903. .height(50)
  904. .color($r('app.color.title_bar_bg'))
  905. Text('加载中...')
  906. .margin({ top: 10 })
  907. .fontSize(14)
  908. .fontColor($r('app.color.index_tab_unselected_font_color'))
  909. }
  910. .width('100%')
  911. .layoutWeight(1)
  912. .justifyContent(FlexAlign.Center)
  913. } else {
  914. if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
  915. Row({ space: 8 }) {
  916. Text(`筛选:${this.filterLabel}`)
  917. .fontSize(13)
  918. .fontColor(this.themeColor)
  919. .layoutWeight(1)
  920. Button('清除筛选')
  921. .type(ButtonType.Capsule)
  922. .backgroundColor(this.themeColor)
  923. .fontSize(12)
  924. .onClick(() => this.clearFilter())
  925. }
  926. .width('90%')
  927. .padding({ left: 16, right: 16, top: 6, bottom: 2 })
  928. }
  929. List({ space: 8 }) {
  930. if (this.selectedTab === 0) {
  931. ForEach(this.getVisibleSongs(), (item: VideoItem, index: number) => {
  932. ListItem() {
  933. this.buildSongItem(item, index)
  934. }
  935. }, (item: VideoItem) => item.id)
  936. } else if (this.selectedTab === 1) {
  937. ForEach(this.artists, (artist: NavidromeRestArtist) => {
  938. ListItem() {
  939. this.buildArtistItem(artist)
  940. }
  941. }, (artist: NavidromeRestArtist) => artist.id)
  942. } else {
  943. ForEach(this.albums, (album: NavidromeRestAlbum) => {
  944. ListItem() {
  945. this.buildAlbumItem(album)
  946. }
  947. }, (album: NavidromeRestAlbum) => album.id)
  948. }
  949. }
  950. .width('100%')
  951. .layoutWeight(1)
  952. .padding({ top: 10, bottom: 10 })
  953. .listDirection(Axis.Vertical)
  954. .scrollBar(BarState.Auto)
  955. .edgeEffect(EdgeEffect.Spring)
  956. // 空状态
  957. if (this.getCurrentCount() === 0) {
  958. Column() {
  959. Image($r('app.media.music_red'))
  960. .width(80)
  961. .height(80)
  962. .opacity(0.6)
  963. Text(this.getEmptyTitle())
  964. .margin({ top: 16 })
  965. .fontSize(16)
  966. .fontColor($r('app.color.index_tab_unselected_font_color'))
  967. Text(this.getEmptySubtitle())
  968. .margin({ top: 8 })
  969. .fontSize(14)
  970. .fontColor($r('app.color.index_tab_unselected_font_color'))
  971. }
  972. .width('100%')
  973. .layoutWeight(1)
  974. .justifyContent(FlexAlign.Center)
  975. }
  976. }
  977. }
  978. build() {
  979. Stack() {
  980. this.buildContentView();
  981. Column() {
  982. this.topTitleBar()
  983. }
  984. }
  985. .alignContent(Alignment.Top)
  986. .width('100%')
  987. .height('100%')
  988. }
  989. }