Browse Source

feat(navidrome): 实现Navidrome音乐播放功能

- 新增Navidrome REST API客户端,支持登录认证和数据获取
- 实现艺术家、专辑、歌曲的完整数据加载与展示
- 添加播放列表管理功能,支持从Navidrome服务器播放音乐
- 完善UI界面,支持按艺术家和专辑筛选歌曲
- 增加播放事件发送机制,与其他模块协同工作
- 添加日志记录和错误处理,提升系统稳定性
- 实现空状态提示和筛选清除功能,改善用户体验
chendeben 9 tháng trước cách đây
mục cha
commit
6f4fac73a3

+ 261 - 0
entry/src/main/ets/common/network/NavidromeRestApi.ets

@@ -0,0 +1,261 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+
+const TAG = 'heanup NavidromeRestApi';
+
+interface NavidromeLoginResponse {
+  id?: string;
+  token?: string;
+}
+
+interface NavidromeLoginBody {
+  username: string;
+  password: string;
+}
+
+interface NavidromeAuthContext {
+  token: string;
+  clientId: string;
+}
+
+class QueryParam {
+  key: string;
+  value: string;
+
+  constructor(key: string, value: string) {
+    this.key = key;
+    this.value = value;
+  }
+}
+
+export interface NavidromeRestSong {
+  id: string;
+  title?: string;
+  album?: string;
+  albumId?: string;
+  artist?: string;
+  artistId?: string;
+  duration?: number;
+  bitRate?: number;
+  suffix?: string;
+  size?: number;
+  createdAt?: string;
+  genre?: string;
+}
+
+export interface NavidromeRestArtist {
+  id: string;
+  name?: string;
+  albumCount?: number;
+  songCount?: number;
+  playCount?: number;
+  mediumImageUrl?: string;
+  largeImageUrl?: string;
+}
+
+export interface NavidromeRestAlbum {
+  id: string;
+  name?: string;
+  artist?: string;
+  artistId?: string;
+  songCount?: number;
+  duration?: number;
+  minYear?: number;
+  maxYear?: number;
+  createdAt?: string;
+  embedArtPath?: string;
+}
+
+export class NavidromeRestApi {
+  private authCache: Map<string, NavidromeAuthContext> = new Map();
+
+  async fetchAllSongs(account: WebDavAccount): Promise<NavidromeRestSong[]> {
+    const params: Array<QueryParam> = [
+      new QueryParam('_start', '0'),
+      new QueryParam('_end', '0'),
+      new QueryParam('_sort', 'createdAt'),
+      new QueryParam('_order', 'DESC')
+    ];
+    return this.get<NavidromeRestSong[]>(account, '/api/song', params);
+  }
+
+  async fetchArtists(account: WebDavAccount): Promise<NavidromeRestArtist[]> {
+    const params: Array<QueryParam> = [
+      new QueryParam('_start', '0'),
+      new QueryParam('_end', '0'),
+      new QueryParam('_sort', 'name'),
+      new QueryParam('_order', 'ASC')
+    ];
+    return this.get<NavidromeRestArtist[]>(account, '/api/artist', params);
+  }
+
+  async fetchAlbums(account: WebDavAccount): Promise<NavidromeRestAlbum[]> {
+    const params: Array<QueryParam> = [
+      new QueryParam('_start', '0'),
+      new QueryParam('_end', '0'),
+      new QueryParam('_sort', 'name'),
+      new QueryParam('_order', 'ASC')
+    ];
+    return this.get<NavidromeRestAlbum[]>(account, '/api/album', params);
+  }
+
+  private async get<T>(account: WebDavAccount, path: string, params?: Array<QueryParam>, retry: boolean = true): Promise<T> {
+    const httpRequest = http.createHttp();
+    try {
+      const auth = await this.ensureAuth(account);
+      const query = this.buildQueryString(params);
+      const url = `${this.buildRootBase(account)}${path}${query}`;
+      const response = await httpRequest.request(url, {
+        method: http.RequestMethod.GET,
+        connectTimeout: 10000,
+        readTimeout: 15000,
+        expectDataType: http.HttpDataType.STRING,
+        header: this.buildAuthHeader(auth)
+      });
+
+      if (response.responseCode === 401 && retry) {
+        this.invalidateAuth(account);
+        return this.get(account, path, params, false);
+      }
+      if (response.responseCode !== 200) {
+        throw new Error(`Navidrome API 请求失败: HTTP ${response.responseCode}`);
+      }
+      return JSON.parse(response.result as string) as T;
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+
+  private async ensureAuth(account: WebDavAccount): Promise<NavidromeAuthContext> {
+    const key = this.getAccountKey(account);
+    const cached = this.authCache.get(key);
+    if (cached) {
+      return cached;
+    }
+    const auth = await this.login(account);
+    this.authCache.set(key, auth);
+    return auth;
+  }
+
+  private invalidateAuth(account: WebDavAccount): void {
+    const key = this.getAccountKey(account);
+    if (this.authCache.has(key)) {
+      this.authCache.delete(key);
+    }
+  }
+
+  private async login(account: WebDavAccount): Promise<NavidromeAuthContext> {
+    const httpRequest = http.createHttp();
+    try {
+      const rootBase = this.buildRootBase(account);
+      const url = `${rootBase}/auth/login`;
+      const username = account.account?.trim();
+      const password = account.password?.trim();
+      if (!username || !password) {
+        throw new Error('Navidrome账号缺少用户名或密码');
+      }
+      const response = await httpRequest.request(url, {
+        method: http.RequestMethod.POST,
+        connectTimeout: 10000,
+        readTimeout: 15000,
+        expectDataType: http.HttpDataType.STRING,
+        header: {
+          'Content-Type': 'application/json'
+        },
+        extraData: JSON.stringify(this.buildLoginBody(username, password))
+      });
+
+      if (response.responseCode !== 200) {
+        throw new Error(`Navidrome 登录失败: HTTP ${response.responseCode}`);
+      }
+      const body = JSON.parse(response.result as string) as NavidromeLoginResponse;
+      if (!body.token || !body.id) {
+        throw new Error('Navidrome 登录响应缺少 token 信息');
+      }
+      return {
+        token: body.token,
+        clientId: body.id
+      };
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `Navidrome 登录异常: ${err.message}`);
+      throw err;
+    } finally {
+      httpRequest.destroy();
+    }
+  }
+
+  private buildAuthHeader(auth: NavidromeAuthContext): Record<string, string> {
+    return {
+      'x-nd-authorization': `Bearer ${auth.token}`,
+      'x-nd-client-unique-id': auth.clientId,
+      'Accept': 'application/json'
+    };
+  }
+
+  private buildLoginBody(username: string, password: string): NavidromeLoginBody {
+    const body: NavidromeLoginBody = {
+      username,
+      password
+    };
+    return body;
+  }
+
+  private buildQueryString(params?: Array<QueryParam>): string {
+    if (!params || params.length === 0) {
+      return '';
+    }
+    const parts: string[] = [];
+    for (let i = 0; i < params.length; i++) {
+      const param = params[i];
+      parts.push(`${encodeURIComponent(param.key)}=${encodeURIComponent(param.value)}`);
+    }
+    return parts.length > 0 ? `?${parts.join('&')}` : '';
+  }
+
+  private buildRootBase(account: WebDavAccount): string {
+    const protocol = account.enableHttps ? 'https' : 'http';
+    const host = (account.isUseLocalHost && account.localHost ? account.localHost : account.host)?.trim();
+    if (!host || host.length === 0) {
+      throw new Error('Navidrome账号缺少服务器地址');
+    }
+    const port = account.port && account.port > 0 ? `:${account.port}` : '';
+    const prefix = this.resolveRootPath(account.navidromeBasePath);
+    return `${protocol}://${host}${port}${prefix}`;
+  }
+
+  private resolveRootPath(path?: string): string {
+    if (!path) {
+      return '';
+    }
+    let normalized = path.trim();
+    if (normalized.length === 0) {
+      return '';
+    }
+    if (!normalized.startsWith('/')) {
+      normalized = `/${normalized}`;
+    }
+    while (normalized.endsWith('/') && normalized.length > 1) {
+      normalized = normalized.slice(0, -1);
+    }
+    const lower = normalized.toLowerCase();
+    if (lower === '/rest') {
+      return '';
+    }
+    if (lower.endsWith('/rest')) {
+      const prefix = normalized.slice(0, normalized.length - 5);
+      return prefix === '/' ? '' : prefix;
+    }
+    return normalized === '/' ? '' : normalized;
+  }
+
+  private getAccountKey(account: WebDavAccount): string {
+    if (account.id && account.id > 0) {
+      return account.id.toString();
+    }
+    return `${account.host ?? ''}_${account.account ?? ''}`;
+  }
+}
+
+export const navidromeRestApi = new NavidromeRestApi();

+ 22 - 0
entry/src/main/ets/common/util/NavidromePlaylistStore.ets

@@ -0,0 +1,22 @@
+import { VideoItem } from '../../viewmodel/VideoItem';
+
+let navidromeVideoItems: VideoItem[] = [];
+let navidromeCurrentPlayIndex: number = 0;
+
+export function setNavidromePlaylist(items: VideoItem[], startIndex: number): void {
+  navidromeVideoItems = items.slice();
+  navidromeCurrentPlayIndex = startIndex;
+}
+
+export function getNavidromeVideoItems(): VideoItem[] {
+  return navidromeVideoItems;
+}
+
+export function getNavidromeCurrentPlayIndex(): number {
+  return navidromeCurrentPlayIndex;
+}
+
+export function clearNavidromePlaylist(): void {
+  navidromeVideoItems = [];
+  navidromeCurrentPlayIndex = 0;
+}

+ 15 - 0
entry/src/main/ets/view/LocalMusic.ets

@@ -3,6 +3,7 @@ import { curves, display, MenuModifier,PiPWindow, promptAction, router, SymbolGl
 import { VideoItem } from '../viewmodel/VideoItem';
 import {  LengthMetrics, SegmentButton,SegmentButtonOptions } from '@kit.ArkUI';
 import { getWebdavVideoItems, getWebdavCurrentPlayIndex } from '../pages/WebDavMainPage';
+import { getNavidromeVideoItems, getNavidromeCurrentPlayIndex } from '../common/util/NavidromePlaylistStore';
 import {
   ButtonFancyModifier,
   ImageFancyModifier,
@@ -15160,6 +15161,20 @@ export struct LocalMusic {
         }
       }
 
+      if (playlistId === 'navidrome-playlist') {
+        Logger.info(`heanup 检测到Navidrome播放请求,从内存读取videoItems`);
+        const videoItems = getNavidromeVideoItems();
+        const currentPlayIndex = getNavidromeCurrentPlayIndex();
+        Logger.info(`heanup Navidrome歌曲列表长度: ${videoItems.length}`);
+        if (videoItems && videoItems.length > 0) {
+          this.finishLoadingPlaylist(videoItems, currentPlayIndex, playlistName);
+          return;
+        } else {
+          Logger.warn('heanup Navidrome播放请求缺少歌曲数据');
+          return;
+        }
+      }
+
       // 从数据库重新加载这些歌曲,使用索引记录位置以保持顺序
       const songMap: Map<number, VideoItem> = new Map()
       let completedQueries = 0

+ 372 - 46
entry/src/main/ets/view/NavidromePage.ets

@@ -1,4 +1,3 @@
-import { router } from '@kit.ArkUI';
 import { VideoItem } from '../viewmodel/VideoItem';
 import { LengthMetrics, SegmentButton, SegmentButtonItemTuple, SegmentButtonOptions } from '@kit.ArkUI';
 import {
@@ -11,6 +10,30 @@ import {
 } from '../common/util/AttributeModifierUtil';
 import { CommonConstants } from '../common/constants/CommonConstants';
 import { WebDavAccount } from '../viewmodel/WebDavAccount';
+import Logger from '../common/util/Logger';
+import { RemoteDriveType } from '../common/enums/RemoteDriveType';
+import { navidromeRestApi, NavidromeRestAlbum, NavidromeRestArtist, NavidromeRestSong } from '../common/network/NavidromeRestApi';
+import { Utility } from '../common/util/Utility';
+import { Constants } from '../Constants';
+import { EventConstants } from '../common/constants/EventConstants';
+import { emitter } from '@kit.BasicServicesKit';
+import { setNavidromePlaylist } from '../common/util/NavidromePlaylistStore';
+
+const NAVIDROME_PLAYLIST_ID = 'navidrome-playlist';
+
+interface PlaylistEventData {
+  playlistId: string;
+  playlistName: string;
+  songCount: number;
+  startIndex: number;
+  songFilePaths: string[];
+}
+
+enum NavFilterType {
+  None = 0,
+  Artist = 1,
+  Album = 2
+}
 
 @Component
 export struct NavidromePage {
@@ -21,14 +44,18 @@ export struct NavidromePage {
   @StorageProp('currentTheme') currentTheme: number = 0;
   @State selectedTab: number = 0; // 0: 全部, 1: 艺术家, 2: 专辑
   @State allVideos: VideoItem[] = [];
-  @State artistVideos: VideoItem[] = [];
-  @State albumVideos: VideoItem[] = [];
+  @State artists: NavidromeRestArtist[] = [];
+  @State albums: NavidromeRestAlbum[] = [];
   @State loading: boolean = false;
   @StorageProp('topRectHeight') topRectHeight: number = 0;
   @State @Watch('onTabSelectedIndexesChanged') tabSelectedIndexes: number[] = [0];
   @StorageProp('themeColor') themeColor: string =
     PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR);
   private tabs: string[] = ['全部', '艺术家', '专辑'];
+  private loadTicket: number = 0;
+  @State filterType: NavFilterType = NavFilterType.None;
+  @State filterLabel: string = '';
+  @State filterId: string = '';
 
   // SegmentButton选项
   @State tabOptions: SegmentButtonOptions = SegmentButtonOptions.capsule({
@@ -53,30 +80,116 @@ export struct NavidromePage {
   }
 
   //切换不同的NavidromePage
-  async onSwitchAccount(){
-
+  async onSwitchAccount() {
+    await this.refreshNavidromeData(true);
   }
 
   aboutToAppear() {
-    this.initMockData();
+    this.refreshNavidromeData();
   }
 
-  private initMockData() {
-    // 模拟全部数据
-    this.allVideos = [
-      new VideoItem('夜曲', '1', 'http://example.com/music1.mp3', CommonConstants.TYPE_NAVIDROME, 1024000, '2024-01-01', '1.0MB', '', '周杰伦', '十一月的萧邦', '夜曲.mp3', new Date('2024-01-15')),
-      new VideoItem('青花瓷', '2', 'http://example.com/music2.mp3', CommonConstants.TYPE_NAVIDROME, 2048000, '2024-01-02', '2.0MB', '', '周杰伦', '我很忙', '青花瓷.mp3', new Date('2024-01-14')),
-      new VideoItem('稻香', '3', 'http://example.com/music3.mp3', CommonConstants.TYPE_NAVIDROME, 1536000, '2024-01-03', '1.5MB', '', '周杰伦', '魔杰座', '稻香.mp3', new Date('2024-01-13')),
-      new VideoItem('告白气球', '4', 'http://example.com/music4.mp3', CommonConstants.TYPE_NAVIDROME, 2560000, '2024-01-04', '2.5MB', '', '周杰伦', '周杰伦的床边故事', '告白气球.mp3', new Date('2024-01-12')),
-      new VideoItem('演员', '5', 'http://example.com/music5.mp3', CommonConstants.TYPE_NAVIDROME, 1280000, '2024-01-05', '1.3MB', '', '薛之谦', '绅士', '演员.mp3', new Date('2024-01-11')),
-      new VideoItem('丑八怪', '6', 'http://example.com/music6.mp3', CommonConstants.TYPE_NAVIDROME, 1792000, '2024-01-06', '1.8MB', '', '薛之谦', '意外', '丑八怪.mp3', new Date('2024-01-10'))
-    ];
-
-    // 模拟艺术家数据 - 筛选周杰伦的歌曲
-    this.artistVideos = this.allVideos.filter(item => item.artist === '周杰伦');
-
-    // 模拟专辑数据 - 筛选十一月的萧邦专辑的歌曲
-    this.albumVideos = this.allVideos.filter(item => item.album === '十一月的萧邦');
+  private async refreshNavidromeData(showToastWhenMissing: boolean = false): Promise<void> {
+    const account = this.resolveActiveAccount();
+    if (!account) {
+      this.resetData();
+      if (showToastWhenMissing) {
+        ToastUtil.showToast('请先选择 Navidrome 账号');
+      }
+      return;
+    }
+    await this.loadNavidromeLibrary(account);
+  }
+
+  private resolveActiveAccount(): WebDavAccount | undefined {
+    if (!this.selectedAccount) {
+      return undefined;
+    }
+    if (this.selectedAccount.webType !== RemoteDriveType.Navidrome) {
+      return undefined;
+    }
+    if (!this.selectedAccount.host || this.selectedAccount.host.length === 0) {
+      return undefined;
+    }
+    return this.selectedAccount;
+  }
+
+  private resetData(): void {
+    this.allVideos = [];
+    this.artists = [];
+    this.albums = [];
+    this.clearFilter();
+  }
+
+  private async loadNavidromeLibrary(account: WebDavAccount): Promise<void> {
+    const ticket = ++this.loadTicket;
+    this.loading = true;
+    try {
+      const requestTasks: Promise<object>[] = [
+        navidromeRestApi.fetchAllSongs(account),
+        navidromeRestApi.fetchArtists(account),
+        navidromeRestApi.fetchAlbums(account)
+      ];
+      const responses = await Promise.all(requestTasks);
+      const songs = responses[0] as NavidromeRestSong[];
+      const artistList = responses[1] as NavidromeRestArtist[];
+      const albumList = responses[2] as NavidromeRestAlbum[];
+      if (ticket !== this.loadTicket) {
+        return;
+      }
+      this.allVideos = songs.map(song => this.convertSongToVideoItem(song, account));
+      this.artists = artistList;
+      this.albums = albumList;
+      Logger.info('heanup', `Navidrome 已加载: 歌曲 ${songs.length} 首, 艺术家 ${artistList.length} 位, 专辑 ${albumList.length} 张`);
+    } catch (error) {
+      if (ticket === this.loadTicket) {
+        Logger.error('heanup', `Navidrome 数据加载失败: ${(error as Error).message}`);
+        ToastUtil.showToast((error as Error).message ?? 'Navidrome 数据加载失败');
+      }
+    } finally {
+      if (ticket === this.loadTicket) {
+        this.loading = false;
+      }
+    }
+  }
+
+  private convertSongToVideoItem(song: NavidromeRestSong, account: WebDavAccount): VideoItem {
+    const title = song.title ?? Constants.UNKNOWN_TITLE;
+    const videoItem = new VideoItem(
+      title,
+      song.id,
+      `navidrome://${account.id ?? 0}/${song.id}`,
+      CommonConstants.TYPE_NAVIDROME,
+      song.size ?? 0,
+      song.createdAt ?? '',
+      Utility.formatFSize(song.size ?? 0),
+      undefined,
+      song.artist ?? Constants.UNKNOWN_ARTIST,
+      song.album ?? '',
+      `${title}${song.suffix ? '.' + song.suffix : ''}`
+    );
+    const durationStr = this.formatSongDuration(song.duration);
+    if (durationStr) {
+      videoItem.duration = durationStr;
+    }
+    videoItem.size = Utility.formatFSize(song.size ?? 0);
+    videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
+    videoItem.genre = song.genre;
+    videoItem.webdav_account_id = account.id?.toString();
+    videoItem.remote_rel_path = song.id;
+    videoItem.navArtistId = song.artistId;
+    videoItem.navAlbumId = song.albumId;
+    return videoItem;
+  }
+
+  private formatSongDuration(durationSeconds?: number): string | undefined {
+    if (durationSeconds === undefined || durationSeconds === null || durationSeconds < 0) {
+      return undefined;
+    }
+    const totalSeconds = Math.floor(durationSeconds);
+    const minutes = Math.floor(totalSeconds / 60);
+    const seconds = totalSeconds % 60;
+    const pad = (value: number) => value.toString().padStart(2, '0');
+    return `${pad(minutes)}:${pad(seconds)}`;
   }
 
   @Builder
@@ -137,14 +250,36 @@ export struct NavidromePage {
     .backgroundColor($r('app.color.start_window_background'))
   }
 
-  private getCurrentVideos(): VideoItem[] {
+  private getCurrentCount(): number {
+    switch (this.selectedTab) {
+      case 1:
+        return this.artists.length;
+      case 2:
+        return this.albums.length;
+      default:
+        return this.getVisibleSongs().length;
+    }
+  }
+
+  private getEmptyTitle(): string {
     switch (this.selectedTab) {
-      case 1: // 艺术家
-        return this.artistVideos;
-      case 2: // 专辑
-        return this.albumVideos;
-      default: // 全部
-        return this.allVideos;
+      case 1:
+        return '暂无艺术家';
+      case 2:
+        return '暂无专辑';
+      default:
+        return this.filterType === NavFilterType.None ? '暂无音乐' : '该筛选下暂无歌曲';
+    }
+  }
+
+  private getEmptySubtitle(): string {
+    switch (this.selectedTab) {
+      case 1:
+        return '当前筛选没有找到艺术家';
+      case 2:
+        return '当前筛选没有找到专辑';
+      default:
+        return this.filterType === NavFilterType.None ? '当前分类下没有找到音乐文件' : '请尝试调整筛选条件';
     }
   }
 
@@ -214,11 +349,93 @@ export struct NavidromePage {
     })
   }
 
+  @Builder
+  buildArtistItem(artist: NavidromeRestArtist) {
+    Button({ type: ButtonType.Normal, stateEffect: false }) {
+      Row({ space: 12 }) {
+        Image($r('app.media.music_red'))
+          .width(48)
+          .height(48)
+          .borderRadius(10)
+          .margin({ left: 8 })
+
+        Column({ space: 4 }) {
+          Text(artist.name ?? Constants.UNKNOWN_ARTIST)
+            .fontSize(15)
+            .fontColor(this.themeColor)
+            .maxLines(1)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+          Text(this.buildArtistMetaLine(artist))
+            .fontSize(13)
+            .fontColor($r('app.color.index_tab_font_color'))
+            .opacity(0.6)
+            .maxLines(1)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+        }
+        .layoutWeight(1)
+        .padding({ right: 20 })
+      }
+    }
+    .width('100%')
+    .padding(12)
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
+    .backgroundColor(Color.Transparent)
+    .onClick(() => {
+      this.onArtistSelected(artist);
+    })
+  }
+
+  @Builder
+  buildAlbumItem(album: NavidromeRestAlbum) {
+    Button({ type: ButtonType.Normal, stateEffect: false }) {
+      Row({ space: 12 }) {
+        Image($r('app.media.music_red'))
+          .width(48)
+          .height(48)
+          .borderRadius(10)
+          .margin({ left: 8 })
+
+        Column({ space: 4 }) {
+          Text(album.name ?? '未知专辑')
+            .fontSize(15)
+            .fontColor(this.themeColor)
+            .maxLines(1)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+          Text(album.artist ?? Constants.UNKNOWN_ARTIST)
+            .fontSize(13)
+            .fontColor($r('app.color.index_tab_font_color'))
+            .opacity(0.6)
+            .maxLines(1)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+          Text(this.buildAlbumMetaLine(album))
+            .fontSize(13)
+            .fontColor($r('app.color.index_tab_font_color'))
+            .opacity(0.6)
+            .maxLines(1)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+        }
+        .layoutWeight(1)
+        .padding({ right: 20 })
+      }
+    }
+    .width('100%')
+    .padding(12)
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
+    .backgroundColor(Color.Transparent)
+    .onClick(() => {
+      this.onAlbumSelected(album);
+    })
+  }
+
   private buildSongMetaLine(song: VideoItem): string {
     const parts: string[] = [];
     if (song.duration) {
       parts.push(song.duration as string);
-    } else if (song.size) {
+    }
+    if (song.size) {
       parts.push(song.size as string);
     }
     if (parts.length === 0 && song.cTime) {
@@ -227,18 +444,98 @@ export struct NavidromePage {
     return parts.join(' · ');
   }
 
-  private playSong(song: VideoItem, index: number): void {
-    try {
-      console.info('heanup', `Navidrome 播放歌曲: ${song.name}, 索引: ${index}`);
+  private buildArtistMetaLine(artist: NavidromeRestArtist): string {
+    const albumCount = artist.albumCount ?? 0;
+    const songCount = artist.songCount ?? 0;
+    const playCount = artist.playCount ?? 0;
+    return `专辑 ${albumCount} · 歌曲 ${songCount} · 播放 ${playCount}`;
+  }
 
-      // TODO: 实现Navidrome播放功能
-      // 这里可以参考WebDavMainPage中的playSong方法
+  private buildAlbumMetaLine(album: NavidromeRestAlbum): string {
+    const parts: string[] = [];
+    if (album.songCount !== undefined) {
+      parts.push(`歌曲 ${album.songCount}`);
+    }
+    if (album.duration !== undefined) {
+      const duration = this.formatSongDuration(album.duration);
+      if (duration) {
+        parts.push(duration);
+      }
+    }
+    if (album.minYear) {
+      parts.push(`发行 ${album.minYear}`);
+    }
+    return parts.join(' · ');
+  }
+
+  private getVisibleSongs(): VideoItem[] {
+    if (this.filterType === NavFilterType.Artist && this.filterId.length > 0) {
+      return this.allVideos.filter(item => item.navArtistId === this.filterId || item.artist === this.filterLabel);
+    }
+    if (this.filterType === NavFilterType.Album && this.filterId.length > 0) {
+      return this.allVideos.filter(item => item.navAlbumId === this.filterId || item.album === this.filterLabel);
+    }
+    return this.allVideos;
+  }
+
+  private onArtistSelected(artist: NavidromeRestArtist): void {
+    if (!artist || !artist.id) {
+      return;
+    }
+    this.applyFilter(NavFilterType.Artist, artist.id, artist.name ?? Constants.UNKNOWN_ARTIST);
+  }
+
+  private onAlbumSelected(album: NavidromeRestAlbum): void {
+    if (!album || !album.id) {
+      return;
+    }
+    this.applyFilter(NavFilterType.Album, album.id, album.name ?? '未知专辑');
+  }
+
+  private applyFilter(type: NavFilterType, id: string, label: string): void {
+    this.filterType = type;
+    this.filterId = id;
+    this.filterLabel = label;
+    this.selectedTab = 0;
+    this.tabSelectedIndexes = [0];
+  }
 
+  private clearFilter(): void {
+    this.filterType = NavFilterType.None;
+    this.filterId = '';
+    this.filterLabel = '';
+  }
 
+  private playSong(song: VideoItem, index: number): void {
+    try {
+      if (!this.allVideos || this.allVideos.length === 0) {
+        ToastUtil.showToast('暂无可播放的歌曲');
+        return;
+      }
+      const account = this.resolveActiveAccount();
+      if (!account || !account.id) {
+        ToastUtil.showToast('Navidrome账号信息不完整,无法播放');
+        return;
+      }
+      Logger.info('heanup', `Navidrome 播放歌曲: ${song.name}, 索引: ${index}`);
+      const targetIndex = this.allVideos.findIndex(item => item.id === song.id);
+      const startIndex = targetIndex >= 0 ? targetIndex : index;
+      setNavidromePlaylist(this.allVideos, startIndex);
+      const playlistData: PlaylistEventData = {
+        playlistId: NAVIDROME_PLAYLIST_ID,
+        playlistName: `Navidrome - ${account.name ?? '未知账户'}`,
+        songCount: this.allVideos.length,
+        startIndex,
+        songFilePaths: this.allVideos.map(item => item.filePath)
+      };
+      const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
+      emitter.emit(eventPlaylistPlay, { data: playlistData });
+      Logger.info('heanup', `Navidrome 发送播放事件,歌曲数: ${this.allVideos.length}, 起始: ${index}`);
+      this.mType = 0;
     } catch (error) {
       const err = error as Error;
-      console.error('heanup', '播放歌曲失败: ' + err.message);
-
+      Logger.error('heanup', '播放歌曲失败: ' + err.message);
+      ToastUtil.showToast('播放失败');
     }
   }
 
@@ -263,12 +560,41 @@ export struct NavidromePage {
         .layoutWeight(1)
         .justifyContent(FlexAlign.Center)
       } else {
+        if (this.selectedTab === 0 && this.filterType !== NavFilterType.None) {
+          Row({ space: 8 }) {
+            Text(`筛选:${this.filterLabel}`)
+              .fontSize(13)
+              .fontColor(this.themeColor)
+              .layoutWeight(1)
+            Button('清除筛选')
+              .type(ButtonType.Capsule)
+              .fontSize(12)
+              .onClick(() => this.clearFilter())
+          }
+          .width('100%')
+          .padding({ left: 16, right: 16, top: 6, bottom: 2 })
+        }
+
         List({ space: 8 }) {
-          ForEach(this.getCurrentVideos(), (item: VideoItem, index: number) => {
-            ListItem() {
-              this.buildSongItem(item, index)
-            }
-          })
+          if (this.selectedTab === 0) {
+            ForEach(this.getVisibleSongs(), (item: VideoItem, index: number) => {
+              ListItem() {
+                this.buildSongItem(item, index)
+              }
+            }, (item: VideoItem) => item.id)
+          } else if (this.selectedTab === 1) {
+            ForEach(this.artists, (artist: NavidromeRestArtist) => {
+              ListItem() {
+                this.buildArtistItem(artist)
+              }
+            }, (artist: NavidromeRestArtist) => artist.id)
+          } else {
+            ForEach(this.albums, (album: NavidromeRestAlbum) => {
+              ListItem() {
+                this.buildAlbumItem(album)
+              }
+            }, (album: NavidromeRestAlbum) => album.id)
+          }
         }
         .width('100%')
         .layoutWeight(1)
@@ -278,19 +604,19 @@ export struct NavidromePage {
         .edgeEffect(EdgeEffect.Spring)
 
         // 空状态
-        if (this.getCurrentVideos().length === 0) {
+        if (this.getCurrentCount() === 0) {
           Column() {
             Image($r('app.media.music_red'))
               .width(80)
               .height(80)
               .opacity(0.6)
 
-            Text('暂无音乐')
+            Text(this.getEmptyTitle())
               .margin({ top: 16 })
               .fontSize(16)
               .fontColor($r('app.color.index_tab_unselected_font_color'))
 
-            Text('当前分类下没有找到音乐文件')
+            Text(this.getEmptySubtitle())
               .margin({ top: 8 })
               .fontSize(14)
               .fontColor($r('app.color.index_tab_unselected_font_color'))
@@ -305,4 +631,4 @@ export struct NavidromePage {
     .height('100%')
     .backgroundColor($r('app.color.start_window_background'))
   }
-}
+}

+ 2 - 0
entry/src/main/ets/viewmodel/VideoItem.ets

@@ -68,6 +68,8 @@ export class VideoItem  {
 
   webdav_account_id?: string// WebDAV账号ID,用于获取认证信息
   remote_rel_path?: string // 远程相对路径(去掉协议+host+端口),便于重构URL
+  navArtistId?: string;
+  navAlbumId?: string;
 
   constructor(name: string, id: string, filePath: string, type:number,videoSize:number,cTime: string,
     size?: string,pixelMapPath?: string,artist?: string,album?: string,fileName?: string, lastPlayed?:Date) {