项目地址

前言

此项目是我想开发唱歌背单词AI初次开发的时候找到的项目,我想学习一下这个开源项目,并在此基础之上进行二次开发。

点击歌词按钮数据状态流

歌词按钮组件如下:

<Button
          className={`lyrics-btn !m-0 !rounded-none !border-0 bg-transparent !p-0 outline-1 outline-offset-1 after:absolute after:h-1 after:w-1 after:translate-y-4 after:rounded-full after:bg-font-color-highlight after:opacity-0 after:transition-opacity hover:bg-transparent focus-visible:!outline dark:bg-transparent dark:after:bg-dark-font-color-highlight dark:hover:bg-transparent ${
            currentlyActivePage.pageTitle === 'Lyrics' && 'active after:opacity-100'
          }`}
          tooltipLabel={t('player.lyrics')}
          iconName="notes"
          iconClassName={`material-icons-round !text-2xl opacity-60 transition-opacity hover:opacity-80 ${
            currentlyActivePage.pageTitle === 'Lyrics' &&
            '!text-font-color-highlight dark:!text-dark-font-color-highlight !opacity-100'
          }`}
          clickHandler={() =>
            currentlyActivePage.pageTitle === 'Lyrics'
              ? changeCurrentActivePage('Home')  //重点方法
              : changeCurrentActivePage('Lyrics')
          }
        />

changeCurrentActivePage是由Context传过来的 changeCurrentActivePage: (pageTitle: PageTitles, data?: PageData) => void; 但也只是AppUpdateContextType中的一个方法类型,那么问题来了,这个方法的实例在哪里,具体是如何触发的

在App.tsx中的最后由 <AppUpdateContext.Provider value={appUpdateContextValues}>所包裹的应用中 appUpdateContextValues包含了changeCurrentActivePage函数的实例

我把这个函数的实例贴上来

const changeCurrentActivePage = useCallback(
    (pageClass: PageTitles, data?: PageData) => {
          // 1. 获取当前导航历史状态
      const navigationHistory = { ...store.state.navigationHistory };
      const { pageTitle, onPageChange } =
        navigationHistory.history[navigationHistory.pageHistoryIndex];
 
      const currentPageData = navigationHistory.history[navigationHistory.pageHistoryIndex].data;
       
      // 2. 检查是否需要切换页面
      if (
        pageTitle !== pageClass ||
        (currentPageData && data && isDataChanged(currentPageData, data))
      ) {
         // 3. 调用页面切换回调(如果存在)
        if (onPageChange) onPageChange(pageClass, data);
        // 4. 创建新的页面数据对象
        const pageData = {
          pageTitle: pageClass,
          data
        };
        // 5. 截断历史记录并添加新页面
        navigationHistory.history = navigationHistory.history.slice(
          0,
          navigationHistory.pageHistoryIndex + 1
        );
        navigationHistory.history.push(pageData);
        navigationHistory.pageHistoryIndex += 1;
        // 6. 清除多选状态
        toggleMultipleSelections(false);
        log(`User navigated to '${pageClass}'`);
         // 7. 关键步骤:通过dispatch更新状态
        dispatch({
          type: 'UPDATE_NAVIGATION_HISTORY',
          data: navigationHistory
        });
      } else
        addNewNotifications([
          {
            content: t('notifications.alreadyInPage'),
            iconName: 'info',
            iconClassName: 'material-icons-round-outlined',
            id: 'alreadyInCurrentPage',
            duration: 2500
          }
        ]);
    },
    [addNewNotifications, t, toggleMultipleSelections]
  );

// 7. 关键步骤:通过dispatch更新状态 dispatch({ type: ‘UPDATE_NAVIGATION_HISTORY’, data: navigationHistory }); 在找找用户更新导航历史后的逻辑 在store.ts当中

export const dispatch = (options: AppReducerStateActions) => {
  store.setState((state) => {
    return appReducer(state, options);
  });
};

appReducer返回更新后的store实例,那么就在这里找相关逻辑

case 'UPDATE_NAVIGATION_HISTORY': {
      const navigationHistory = { ...action.data };//这里的data为dispatch穿过来的  data: navigationHistory导航历史对象
      return {
        ...state,
        bodyBackgroundImage: undefined,
        navigationHistory,
        currentlyActivePage: navigationHistory.history[navigationHistory.pageHistoryIndex]
      };
    }

找到了,他更改了一下store中currentlyActivePage这个变量,但为什么改变navigationHistory和currentlyActivePage中的navigationHistory.history就能改变当前页面的状态,我们还得研究一下

回到函数的类型 changeCurrentActivePage: (pageTitle: PageTitles, data?: PageData) => void; 和调用的函数来看 changeCurrentActivePage(‘Lyrics’),其中Lyrics就是page.titile

看看PageTitles

  type PageTitles =
    | DefaultPages
    | 'Settings'
    | 'Lyrics' //这里
    | 'SongInfo'
    | 'ArtistInfo'
    | 'AlbumInfo'
    | 'PlaylistInfo'
    | 'GenreInfo'
    | 'MusicFolderInfo'
    | 'CurrentQueue'
    | 'SongTagsEditor'
    | 'LyricsEditor'
    | 'AllSearchResults';

可以清晰的看到这些类型已经定义好的页面类型

在实例函数中

 // 5. 截断历史记录并添加新页面
        navigationHistory.history = navigationHistory.history.slice(
          0,
          navigationHistory.pageHistoryIndex + 1
        );
        navigationHistory.history.push(pageData);
        navigationHistory.pageHistoryIndex += 1;

通过添加pageData

const pageData = {
          pageTitle: pageClass,//传入过来的Lyrics字符串
          data
        };

来更改导航的历史记录,我们再深入了解这个navigationHistory类型定义navigationHistory: NavigationHistoryData;

  interface NavigationHistoryData {
    pageHistoryIndex: number;
    history: NavigationHistory[];
  }
 
	interface NavigationHistory {
	    pageTitle: PageTitles;
	    data?: PageData;
	    onPageChange?: (changedPageTitle: PageTitles, changedPageData?: PageData)          => void;
	}

具体分析可以看看下面: Nora页面导航

添加后退监听事件

由于本项目没有鼠标后侧键的监听事件,因此我为他添加了一个鼠标侧键后退的监听事件

  const handleMouseBackButton = useCallback((e: MouseEvent) => {
    if (e.button === 3) {
      e.preventDefault();
      updatePageHistoryIndex('decrement');
    }
  }, [updatePageHistoryIndex]);
 
  useEffect(() => {
    window.addEventListener('mouseup', handleMouseBackButton);
    return () => {
      window.removeEventListener('mouseup', handleMouseBackButton);
    };
  }, [handleMouseBackButton]);

歌词部分

在Nora中处理歌词文件中的数据结构 以一个lrc文件为例,展示了parseLrycis这个函数是如何运作的 lrc文件例子转化为LrycisData的解析过程 无论他是从哪里得到的lrc文件,只要我们搞清楚LyricsData的结构就行了

{
  isSynced: true,
  isTranslated: true,
  isRomanized: false,
  isReset: false,
  parsedLyrics: [
    { originalText: '気分次第です僕は', translatedTexts: [{ lang: 'en', text: '随心而定的我' }], start: 5.46, end: 7.95, isEnhancedSynced: false, convertedLyrics: '' },
    { originalText: '敵を選んで戦う少年', translatedTexts: [{ lang: 'en', text: '是个择敌而战的少年' }], start: 7.95, end: 11.42, isEnhancedSynced: false, convertedLyrics: '' },
    { originalText: '叶えたい未来も無くて', translatedTexts: [{ lang: 'en', text: '没有想要实现的未来' }], start: 11.42, end: 14.95, isEnhancedSynced: false, convertedLyrics: '' },
    { originalText: '夢に描かれるのを待ってた', translatedTexts: [{ lang: 'en', text: '只是等待着梦想自己降临' }], start: 14.95, end: 18.92, isEnhancedSynced: false, convertedLyrics: '' },
    // ... 其他歌词行 ...
    { originalText: '♪', translatedTexts: [], start: 48.95, end: 50.39, isEnhancedSynced: false, convertedLyrics: '' }, // 空行
    // ... 更多歌词行 ...
    { originalText: '今日の日をいつか思い出せ', translatedTexts: [{ lang: 'en', text: '在某一天 回想起今天吧' }], start: 3*60 + 20.17, end: 3*60 + 22.43, isEnhancedSynced: false, convertedLyrics: '' },
    { originalText: '未来の僕ら', translatedTexts: [{ lang: 'en', text: '未来的我们' }], start: 3*60 + 22.43, end: Infinity, isEnhancedSynced: false, convertedLyrics: '' }
  ],
  unparsedLyrics: "[offset:0]...", // 完整的原始LRC字符串
  offset: 0,
  originalLanguage: 'ja',
  translatedLanguages: ['en'], // 注意:这里假设所有中文都被识别为 'en' (基于代码默认行为)
  copyright: undefined
}

利用Jest库来进行测试

没用过Jest Jest

其他

这个项目用到了useMemouseCallback这两个Hook,我没用过,记录一下 还用到了Tanstack Store,没接触过,学习一下

发现了一个问题,就是点击一个歌曲的时候会有缓存时间,导致会过好几秒才能加载出音频,我还以为没点到,我就多点了几次,就卡住了,会同时播放两种不同的歌曲,算是一个bug吧,后面熟悉项目整体后再修修这个问题

碰到的Issue

  • 添加歌单,或者将一首歌加入歌单后,点叉叉退出退出不了,且逻辑应该是添加完毕过后直接退出(这个Issue是我自己的那个修改的版本)
  • 有时候就算在同一文件下,相同文件名的音频文件,歌词文件,歌曲封面图片都识别不出来是一同一首歌的,不知道是不是音乐标签没识别完全music-tag-web这个项目粗略看了一下有获取歌词API还有处理音乐标签的模块,后面再看看,不过大部分的标签和封面都能识别的

reference