NavidromePage.ets 29 KB

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