Просмотр исходного кода

实现Navidrome的歌单功能

onecold 7 месяцев назад
Родитель
Сommit
7d22bb21a3

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

@@ -83,6 +83,24 @@ export interface NavidromeRestAlbum {
   coverUrl?: string;
 }
 
+export interface NavidromeRestPlaylist {
+  id: string;
+  name?: string;
+  comment?: string;
+  duration?: number;
+  size?: number;
+  songCount?: number;
+  ownerName?: string;
+  ownerId?: string;
+  public?: boolean;
+  path?: string;
+  sync?: boolean;
+  createdAt?: string;
+  updatedAt?: string;
+  rules?: object;
+  evaluatedAt?: string;
+}
+
 export interface NavidromePagedResponse<T> {
   data: T[];
   nextStart: number | null;
@@ -104,6 +122,10 @@ export class NavidromeRestApi {
     return this.fetchPage<NavidromeRestAlbum>(account, '/api/album', start, 'name', 'ASC');
   }
 
+  async fetchPlaylistPage(account: WebDavAccount, start: number): Promise<NavidromePagedResponse<NavidromeRestPlaylist>> {
+    return this.fetchPage<NavidromeRestPlaylist>(account, '/api/playlist', start, 'name', 'ASC');
+  }
+
   async fetchSongsByArtist(account: WebDavAccount, artistId: string, label?: string): Promise<NavidromeRestSong[]> {
     // 这里判断fetchSongsWithFilter如果返回空数据,则用搜索的接口去搜索艺术家 label就是艺术家名称
     const songs = await this.fetchSongsWithFilter(account, [new QueryParam('artist_id', artistId)]);
@@ -153,6 +175,31 @@ export class NavidromeRestApi {
     return songs ?? [];
   }
 
+  async fetchSongsByPlaylist(account: WebDavAccount, playlistId: string): Promise<NavidromeRestSong[]> {
+    const results: NavidromeRestSong[] = [];
+    let start = 0;
+    while (true) {
+      const end = start + this.PAGE_SIZE;
+      const params = [
+        new QueryParam('_start', `${start}`),
+        new QueryParam('_end', `${end}`),
+        new QueryParam('_sort', 'createdAt'),
+        new QueryParam('_order', 'DESC')
+      ];
+      const path = `/api/playlist/${playlistId}/tracks`;
+      const chunk = await this.get<NavidromeRestSong[]>(account, path, params);
+      if (!chunk || chunk.length === 0) {
+        break;
+      }
+      results.push(...chunk);
+      if (chunk.length < this.PAGE_SIZE) {
+        break;
+      }
+      start = end;
+    }
+    return results;
+  }
+
   // 将 NavidromeSong 转换为 NavidromeRestSong
   private convertToRestSongs(songs: import('./NavidromeApi').NavidromeSong[]): NavidromeRestSong[] {
     return songs.map((song): NavidromeRestSong => ({

+ 137 - 7
entry/src/main/ets/view/NavidromePage.ets

@@ -13,7 +13,7 @@ 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 { navidromeRestApi, NavidromeRestAlbum, NavidromeRestArtist, NavidromeRestSong, NavidromeRestPlaylist } from '../common/network/NavidromeRestApi';
 import { Utility } from '../common/util/Utility';
 import { Constants } from '../Constants';
 import { EventConstants } from '../common/constants/EventConstants';
@@ -39,7 +39,8 @@ interface PlaylistEventData {
 enum NavFilterType {
   None = 0,
   Artist = 1,
-  Album = 2
+  Album = 2,
+  Playlist = 3
 }
 
 interface CachePreviewInfo {
@@ -69,13 +70,16 @@ export struct NavidromePage {
   @State allVideos: VideoItem[] = [];
   @State artists: NavidromeRestArtist[] = [];
   @State albums: NavidromeRestAlbum[] = [];
+  @State playlists: NavidromeRestPlaylist[] = [];
   @State loading: boolean = false;
   @State songNextStart: number | null = 0;
   @State artistNextStart: number | null = 0;
   @State albumNextStart: number | null = 0;
+  @State playlistNextStart: number | null = 0;
   @State isSongPageLoading: boolean = false;
   @State isArtistPageLoading: boolean = false;
   @State isAlbumPageLoading: boolean = false;
+  @State isPlaylistPageLoading: boolean = false;
 
   // 新增:详情视图状态
   @State isDetailView: boolean = false; // 是否在艺术家/专辑详情视图
@@ -101,7 +105,8 @@ export struct NavidromePage {
     const buttons = [
       { text: '全部' },
       { text: '艺术家' },
-      { text: '专辑' }
+      { text: '专辑' },
+      { text: '歌单' }
     ] as SegmentButtonItemTuple;
     return SegmentButtonOptions.capsule({
       buttons,
@@ -238,15 +243,18 @@ export struct NavidromePage {
     this.allVideos = [];
     this.artists = [];
     this.albums = [];
+    this.playlists = [];
     this.clearFilter();
     this.coverUrlCache.clear();
     this.albumCoverLookup.clear();
     this.songNextStart = 0;
     this.artistNextStart = 0;
     this.albumNextStart = 0;
+    this.playlistNextStart = 0;
     this.isSongPageLoading = false;
     this.isArtistPageLoading = false;
     this.isAlbumPageLoading = false;
+    this.isPlaylistPageLoading = false;
   }
 
   private async loadNavidromeLibrary(account: WebDavAccount): Promise<void> {
@@ -262,12 +270,13 @@ export struct NavidromePage {
       await Promise.all([
         this.loadNextSongPage(account, ticket),
         this.loadNextArtistPage(account, ticket),
-        this.loadNextAlbumPage(account, ticket)
+        this.loadNextAlbumPage(account, ticket),
+        this.loadNextPlaylistPage(account, ticket)
       ]);
       if (ticket !== this.loadTicket) {
         return;
       }
-      void ServerLogUtil.info('NavidromeLoad', `首次加载完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length}`);
+      void ServerLogUtil.info('NavidromeLoad', `首次加载完成: 歌曲 ${this.allVideos.length} / 艺术家 ${this.artists.length} / 专辑 ${this.albums.length} / 歌单 ${this.playlists.length}`);
       await this.logCacheStatistics(account);
     } catch (error) {
       if (ticket === this.loadTicket) {
@@ -394,6 +403,30 @@ export struct NavidromePage {
     }
   }
 
+  private async loadNextPlaylistPage(account: WebDavAccount, ticket?: number): Promise<void> {
+    if (this.playlistNextStart === null || this.isPlaylistPageLoading) {
+      return;
+    }
+    const currentTicket = ticket ?? this.loadTicket;
+    this.isPlaylistPageLoading = true;
+    try {
+      const response = await navidromeRestApi.fetchPlaylistPage(account, this.playlistNextStart);
+      if (currentTicket !== this.loadTicket) {
+        return;
+      }
+      const chunk = response.data ?? [];
+      if (chunk.length === 0) {
+        this.playlistNextStart = null;
+        return;
+      }
+      this.playlists = [...this.playlists, ...chunk];
+      this.playlistNextStart = response.nextStart;
+      void ServerLogUtil.info('NavidromeLoad', `歌单列表追加: 本次 ${chunk.length} 个, 总数 ${this.playlists.length}`);
+    } finally {
+      this.isPlaylistPageLoading = false;
+    }
+  }
+
   private async handleReachEnd(): Promise<void> {
     if (this.loading) {
       return;
@@ -415,6 +448,9 @@ export struct NavidromePage {
       case 2:
         await this.loadNextAlbumPage(account);
         break;
+      case 3:
+        await this.loadNextPlaylistPage(account);
+        break;
       default:
         break;
     }
@@ -1084,6 +1120,8 @@ export struct NavidromePage {
         return this.artists.length;
       case 2:
         return this.albums.length;
+      case 3:
+        return this.playlists.length;
       default:
         return this.getVisibleSongs().length;
     }
@@ -1095,6 +1133,8 @@ export struct NavidromePage {
         return '暂无艺术家';
       case 2:
         return '暂无专辑';
+      case 3:
+        return '暂无歌单';
       default:
         if (this.isSearchMode && this.searchText.length > 0) {
           return this.isSearchLoading ? '正在搜索远程歌曲' : '没有匹配的远程歌曲';
@@ -1112,6 +1152,8 @@ export struct NavidromePage {
         return '当前筛选没有找到艺术家';
       case 2:
         return '当前筛选没有找到专辑';
+      case 3:
+        return '当前筛选没有找到歌单';
       default:
         if (this.isSearchMode && this.searchText.length > 0) {
           return this.isSearchLoading ? '请稍候,正在通过 API 搜索' : '换个关键词再试试吧';
@@ -1309,6 +1351,59 @@ export struct NavidromePage {
     })
   }
 
+  @Builder
+  buildPlaylistItem(playlist: NavidromeRestPlaylist) {
+    Button({ type: ButtonType.Capsule, stateEffect: true }) {
+      Row({ space: 12 }) {
+        Image($r('app.media.alt'))
+          .width(48)
+          .height(48)
+          .borderRadius(10)
+          .alt($r('app.media.alt'))
+          .draggable(false)
+          .sourceSize({ width: 38, height: 38 })
+          .fillColor(this.themeColor)
+          .objectFit(ImageFit.Cover)
+          .margin({ left: 20 })
+
+        Column({ space: 4 }) {
+          Text(playlist.name ?? '未知歌单')
+            .fontSize(15)
+            .fontColor($r('app.color.index_tab_font_color'))
+            .maxLines(1)
+            .textAlign(TextAlign.Start)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+          Text(this.buildPlaylistMetaLine(playlist))
+            .fontSize(13)
+            .fontColor($r('app.color.index_tab_font_color'))
+            .opacity(0.6)
+            .maxLines(1)
+            .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+          if (playlist.ownerName) {
+            Text(playlist.ownerName)
+              .fontSize(12)
+              .fontColor($r('app.color.index_tab_font_color'))
+              .opacity(0.5)
+              .maxLines(1)
+              .textOverflow({ overflow: TextOverflow.Ellipsis })
+          }
+        }
+        .alignItems(HorizontalAlign.Start)
+        .padding({ right: 20 })
+      }
+      .width('100%')
+    }
+    .width('100%')
+    .padding(12)
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.85 })
+    .backgroundColor(Color.Transparent)
+    .onClick(() => {
+      this.onPlaylistSelected(playlist);
+    })
+  }
+
   private buildSongMetaLine(song: VideoItem): string {
     const parts: string[] = [];
     if (song.duration) {
@@ -1344,6 +1439,20 @@ export struct NavidromePage {
     return parts.join(' · ');
   }
 
+  private buildPlaylistMetaLine(playlist: NavidromeRestPlaylist): string {
+    const parts: string[] = [];
+    if (playlist.songCount !== undefined) {
+      parts.push(`${playlist.songCount} 首歌`);
+    }
+    if (playlist.duration !== undefined) {
+      const duration = this.formatSongDuration(playlist.duration);
+      if (duration) {
+        parts.push(duration);
+      }
+    }
+    return parts.join(' · ');
+  }
+
   private getVisibleSongs(): VideoItem[] {
     if (this.isSearchMode) {
       return this.searchText.length > 0 ? this.filteredList : this.allVideos;
@@ -1378,11 +1487,24 @@ export struct NavidromePage {
     void this.applyFilter(NavFilterType.Album, album.id, album.name ?? '未知专辑');
   }
 
+  private onPlaylistSelected(playlist: NavidromeRestPlaylist): void {
+    if (!playlist || !playlist.id) {
+      return;
+    }
+    this.isShowTitleBar = true;
+    // 保存当前标签页,进入详情视图
+    this.previousTab = this.selectedTab;
+    this.isDetailView = true;
+    // 应用筛选但不切换标签页
+    void this.applyFilter(NavFilterType.Playlist, playlist.id, playlist.name ?? '未知歌单');
+  }
+
   private async applyFilter(type: NavFilterType, id: string, label: string): Promise<void> {
 
     // 记录筛选操作
     void ServerLogUtil.info('NavidromeFilter', `应用筛选:`);
-    void ServerLogUtil.info('NavidromeFilter', `- 类型: ${type} (${type === NavFilterType.Artist ? '艺术家' : type === NavFilterType.Album ? '专辑' : '无'})`);
+    const typeText = type === NavFilterType.Artist ? '艺术家' : type === NavFilterType.Album ? '专辑' : type === NavFilterType.Playlist ? '歌单' : '无';
+    void ServerLogUtil.info('NavidromeFilter', `- 类型: ${type} (${typeText})`);
     void ServerLogUtil.info('NavidromeFilter', `- ID: ${id}`);
     void ServerLogUtil.info('NavidromeFilter', `- 标签: ${label}`);
     void ServerLogUtil.info('NavidromeFilter', `- 筛选前总歌曲数: ${this.allVideos.length}`);
@@ -1429,6 +1551,8 @@ export struct NavidromePage {
         songs = await navidromeRestApi.fetchSongsByArtist(account, this.filterId,label);
       } else if (this.filterType === NavFilterType.Album) {
         songs = await navidromeRestApi.fetchSongsByAlbum(account, this.filterId,label);
+      } else if (this.filterType === NavFilterType.Playlist) {
+        songs = await navidromeRestApi.fetchSongsByPlaylist(account, this.filterId);
       }
       const videoItems = await this.convertSongsToVideoItems(songs, account);
       if (expectedFilterId !== this.filterId || expectedFilterType !== this.filterType) {
@@ -1586,12 +1710,18 @@ export struct NavidromePage {
                 this.buildArtistItem(artist)
               }
             }, (artist: NavidromeRestArtist) => artist.id)
-          } else {
+          } else if (this.selectedTab === 2) {
             ForEach(this.albums, (album: NavidromeRestAlbum) => {
               ListItem() {
                 this.buildAlbumItem(album)
               }
             }, (album: NavidromeRestAlbum) => album.id)
+          } else if (this.selectedTab === 3) {
+            ForEach(this.playlists, (playlist: NavidromeRestPlaylist) => {
+              ListItem() {
+                this.buildPlaylistItem(playlist)
+              }
+            }, (playlist: NavidromeRestPlaylist) => playlist.id)
           }
         }
         .onScrollFrameBegin((offset: number) => {