WebDavMainPage.ets 148 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666366736683669367036713672367336743675367636773678367936803681368236833684368536863687368836893690369136923693369436953696369736983699370037013702370337043705370637073708370937103711371237133714371537163717371837193720372137223723372437253726372737283729373037313732373337343735373637373738373937403741374237433744374537463747374837493750375137523753375437553756375737583759376037613762376337643765376637673768376937703771377237733774377537763777377837793780378137823783378437853786378737883789379037913792379337943795379637973798379938003801380238033804380538063807380838093810381138123813381438153816381738183819382038213822382338243825382638273828382938303831383238333834383538363837383838393840384138423843384438453846384738483849385038513852385338543855385638573858385938603861386238633864386538663867386838693870387138723873387438753876387738783879388038813882388338843885388638873888388938903891389238933894389538963897389838993900390139023903390439053906390739083909391039113912391339143915391639173918391939203921392239233924392539263927392839293930393139323933393439353936393739383939394039413942394339443945394639473948394939503951395239533954395539563957395839593960396139623963396439653966396739683969397039713972397339743975397639773978397939803981398239833984398539863987398839893990399139923993399439953996399739983999400040014002400340044005400640074008400940104011401240134014401540164017401840194020402140224023402440254026402740284029403040314032403340344035403640374038403940404041404240434044404540464047404840494050405140524053405440554056405740584059406040614062406340644065406640674068406940704071407240734074407540764077407840794080408140824083408440854086408740884089409040914092409340944095409640974098409941004101410241034104410541064107410841094110411141124113411441154116411741184119412041214122412341244125412641274128412941304131413241334134413541364137413841394140414141424143414441454146414741484149415041514152415341544155415641574158415941604161416241634164416541664167416841694170417141724173417441754176417741784179418041814182418341844185418641874188418941904191419241934194419541964197419841994200420142024203420442054206
  1. import { BreadcrumbItem, RemoteDirectorySnapshot, RemoteDriveGlobalSearchResult, RemoteDriveManager } from '../common/util/RemoteDriveManager';
  2. import { WebDavAccount } from '../viewmodel/WebDavAccount';
  3. import { Song } from '../viewmodel/Song';
  4. import { RemoteDriveManagerStates } from '../common/enums/RemoteDriveManagerStates';
  5. import Logger from '../common/util/Logger';
  6. import { SymbolGlyphModifier, router, curves } from '@kit.ArkUI';
  7. import { CommonConstants } from '../common/constants/CommonConstants';
  8. import { VideoItem } from '../viewmodel/VideoItem';
  9. import { GlobalContext } from '../common/util/GlobalContext';
  10. import { FileInfo } from '../viewmodel/FileInfo';
  11. import { emitter } from '@kit.BasicServicesKit';
  12. import { EventConstants } from '../common/constants/EventConstants';
  13. import { LazyDataSource } from '../common/util/LazyDataSource';
  14. import { ArrayUtil, FileUtil, MD5, PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils';
  15. import { ButtonFancyModifier,
  16. MenuModifier,
  17. ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil';
  18. import { getRemoteDriveAccountLabel, getRemoteDriveDisplayLabel, getRemoteDrivePlaylistPrefix } from '../common/util/RemoteDriveLabel';
  19. import { RemoteDriveType } from '../common/enums/RemoteDriveType';
  20. import { Utility } from '../common/util/Utility';
  21. import { SettingPage } from './SettingPage';
  22. import { getCloudDiskIcon } from '../dialog/RemoteDriveAccountDialog';
  23. import { CreateFolderDialog } from '../dialog/CreateFolderDialog';
  24. import { UploadMusicPage } from './UploadMusicPage';
  25. import { FFmpeg } from '@sj/ffmpeg';
  26. import FileManager from '../common/util/FileManager';
  27. import { fileIo, fileUri, picker } from '@kit.CoreFileKit';
  28. import { mergeCoverWithPriority, setVideoUrlForSong } from '../common/util/RemotePlayerUtil';
  29. import { showAddToPlaylistDialog } from '../dialog/AddToPlaylistDialog';
  30. import { showCreatePlaylistDialog } from '../dialog/PlaylistDialog';
  31. import { Playlist } from '../viewmodel/Playlist';
  32. import PlaylistTable from '../common/util/PlaylistTable';
  33. import MediaTable from '../common/util/MediaTable';
  34. import { DownloadCenterManager, DownloadCenterTask } from '../common/util/DownloadCenterManager';
  35. import { DownloadCenter } from '../view/DownloadCenter';
  36. import { WebDavUrlUtil } from '../common/util/WebDavUrlUtil';
  37. import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil';
  38. import { PlayingIndicator } from '../view/PlayingIndicator';
  39. /**
  40. * 歌单播放事件数据
  41. */
  42. interface PlaylistEventData {
  43. playlistId: string;
  44. playlistName: string;
  45. songCount: number;
  46. startIndex: number;
  47. isJump: boolean;
  48. songFilePaths: string[];
  49. // 移除webDavAuthInfo,现在直接使用videoItems中的webdav_account_id
  50. }
  51. interface WebDavMetadataUpdatePayload {
  52. filePath: string;
  53. pixelMapPath?: string;
  54. isCustomCover?: number;
  55. md5Str?: string;
  56. name?: string;
  57. artist?: string;
  58. }
  59. interface RemoteThumbSource {
  60. playUrl: string;
  61. headers: Map<string, string>;
  62. }
  63. const TAG = 'heanup WebDavMainPage';
  64. const REMOTE_THUMB_CACHE_DIR: string = 'remote_thumbs';
  65. const REMOTE_THUMB_MAX_TASK_COUNT: number = 12;
  66. const REMOTE_THUMB_LARGE_LIST_THRESHOLD: number = 120;
  67. const REMOTE_THUMB_LARGE_LIST_PREFETCH_COUNT: number = 6;
  68. const REMOTE_THUMB_LARGE_LIST_DELAY_MS: number = 900;
  69. const REMOTE_THUMB_CAPTURE_SECONDS: string = '1.2';
  70. const WEBDAV_PROGRESSIVE_RELOAD_THRESHOLD: number = 180;
  71. const WEBDAV_PROGRESSIVE_RELOAD_INITIAL_COUNT: number = 20;
  72. const WEBDAV_PROGRESSIVE_RELOAD_BATCH_SIZE: number = 20;
  73. const WEBDAV_PROGRESSIVE_RELOAD_DELAY_MS: number = 32;
  74. // WebDAV歌曲数据全局内存存储
  75. let globalWebdavVideoItems: VideoItem[] = [];
  76. let globalWebdavCurrentPlayIndex: number = 0;
  77. let globalRemoteThumbFfmpegQueue: Promise<void> = Promise.resolve();
  78. // 导出函数供LocalMusic访问
  79. export function getWebdavVideoItems(): VideoItem[] {
  80. return globalWebdavVideoItems;
  81. }
  82. export function getWebdavCurrentPlayIndex(): number {
  83. return globalWebdavCurrentPlayIndex;
  84. }
  85. export function clearWebdavVideoItems(): void {
  86. globalWebdavVideoItems = [];
  87. globalWebdavCurrentPlayIndex = 0;
  88. }
  89. // URL解码函数
  90. function decodeUrlEncodedString(encodedStr: string): string {
  91. try {
  92. return decodeURIComponent(encodedStr);
  93. } catch (error) {
  94. // 如果解码失败,返回原始字符串
  95. return encodedStr;
  96. }
  97. }
  98. @Preview
  99. @Entry
  100. @Component
  101. export struct WebDavMainPage {
  102. @State appName: string = ''
  103. @State isShowUploadFile: boolean = false
  104. @State isShowTitleBar: boolean = true //是否显示分类导航条
  105. private prevOffsetY: number = 0; // 记录上一次的Y轴偏移量
  106. @State autoHideTitle: boolean = true //滚动自动隐藏标题栏
  107. @State isCustomizeBg: boolean = false //自定义背景界面
  108. @StorageProp('isLandscape') isLandscape: boolean = false;
  109. @State blurValue: number = 0 //背景模糊
  110. @State bgBrightness: number = 0 //背景亮度
  111. @State customizeBgPath: string | undefined = '';
  112. private listScroller: ListScroller = new ListScroller()
  113. @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0;
  114. searchController: SearchController = new SearchController()
  115. private searchTicket: number = 0;
  116. @StorageProp('animationState') animationState: AnimationStatus= AnimationStatus.Running
  117. @State isSearchMode: boolean = false
  118. @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
  119. @StorageProp('topSafeHeight') topSafeHeight: number = 0;
  120. @State webdavManager: RemoteDriveManager = RemoteDriveManager.getInstance();
  121. @State accounts: WebDavAccount[] = [];
  122. @Prop @Watch('onSwitchAccount') selectedAccount: WebDavAccount;
  123. private songs: VideoItem[] = [];
  124. @State dataSource:LazyDataSource<VideoItem> = new LazyDataSource(this.songs)
  125. @Link mType: number;
  126. @Link offsetX: number;
  127. @Link isShowDrawer: boolean;
  128. @State isLoading: boolean = false;
  129. @State isRefreshingCache: boolean = false;
  130. private webDavFiles: FileInfo[] = []; // 原始目录数据保持为普通字段,避免大数组响应式代理卡顿
  131. @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
  132. @StorageProp('isDarkMode') isDarkMode: boolean = false;
  133. @State currentDirectoryPath: string = '';
  134. @State breadcrumbs:BreadcrumbItem[] = []//面包屑导航
  135. @State visibleFoldersState: FileInfo[] = []; // 改为普通状态变量
  136. @State isShowFileName: boolean = false//是否显示文件名
  137. @State isLongNameRoLL: boolean = true//长歌名滚动
  138. @State sortType: number = 0 //默认排序方式
  139. @State listRefreshKey: number = 0 // 列表刷新标识
  140. @Consume isMultiSelect: boolean
  141. @State selectedSongs: VideoItem[] = []
  142. @State selectedFolders: FileInfo[] = []
  143. @State isAllSelected: boolean = false
  144. @State isDeletingSelection: boolean = false
  145. @State ignoreTapAfterExitMultiSelect: boolean = false
  146. @State isShowSongPropertySheet: boolean = false
  147. @State songForPropertySheet: VideoItem | undefined = undefined
  148. @State rootPath: string = ''
  149. @State isShowDownloadCenter: boolean = false
  150. @State downloadCenterTabIndex: number[] = [0]
  151. @State downloadCenterRefreshVersion: number = 0
  152. @State downloadCenterActiveTasks: DownloadCenterTask[] = []
  153. @State downloadCenterCompletedTasks: DownloadCenterTask[] = []
  154. @State isSearchLoading: boolean = false
  155. private readonly downloadFolderName: string = '下载'
  156. private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
  157. private thumbnailTaskToken: number = 0;
  158. private thumbnailRunningKeys: Set<string> = new Set<string>();
  159. private readonly downloadCenterManager: DownloadCenterManager = DownloadCenterManager.getInstance();
  160. private readonly downloadCenterListener: () => void = (): void => {
  161. this.scheduleDownloadCenterRefresh();
  162. };
  163. private downloadCenterRefreshTimer: number = -1;
  164. private displayReloadToken: number = 0;
  165. private pendingDirectoryRefreshTimer: number = -1;
  166. private pendingDirectoryRefreshToken: number = 0;
  167. private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
  168. const nextTask: Promise<void> = globalRemoteThumbFfmpegQueue.then(async (): Promise<void> => {
  169. await task();
  170. }, async (): Promise<void> => {
  171. await task();
  172. });
  173. globalRemoteThumbFfmpegQueue = nextTask.catch((): void => {});
  174. await nextTask;
  175. }
  176. async onSwitchAccount(){
  177. console.log('heanup 切换账户:', this.selectedAccount.name);
  178. this.cancelPendingDirectoryRefresh();
  179. this.thumbnailTaskToken += 1;
  180. this.thumbnailRunningKeys.clear();
  181. this.searchTicket++;
  182. this.searchText = '';
  183. this.filteredList = [];
  184. this.filteredFolderList = [];
  185. this.isSearchMode = false;
  186. this.isSearchLoading = false;
  187. this.songs = [];
  188. this.visibleFoldersState = [];
  189. this.updateListData(this.songs)
  190. this.exitMultiSelect();
  191. // 注意:不再需要清空全局上下文,因为LocalMusic已改用实例变量缓存
  192. // 当新账户加载时,新的认证信息会自动覆盖旧的
  193. Logger.info(TAG, 'heanup 切换账户,准备加载新账户文件');
  194. this.currentDirectoryPath = this.selectedAccount.filepath || '/';
  195. this.scheduleDirectoryRefresh(this.selectedAccount.filepath,
  196. () => this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount), '加载文件失败');
  197. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  198. }
  199. updateListData(mList:Array<VideoItem>, noSort?: boolean, shouldScrollToTop: boolean = true){
  200. if (!noSort) {
  201. this.sortType = PreferencesUtil.getNumberSync('webDavSortType', 0)
  202. this.doSortType(this.sortType, shouldScrollToTop)
  203. return
  204. }
  205. this.refreshDisplaySongs(mList, shouldScrollToTop)
  206. }
  207. private refreshDisplaySongs(mList: Array<VideoItem>, shouldScrollToTop: boolean = true): void {
  208. const reloadToken: number = ++this.displayReloadToken;
  209. const displayItems: Array<VideoItem> = mList ? [...mList] : [];
  210. if (displayItems.length <= WEBDAV_PROGRESSIVE_RELOAD_THRESHOLD) {
  211. this.dataSource.pushArrayData(displayItems)
  212. if (shouldScrollToTop && displayItems.length > 0) {
  213. setTimeout(() => {
  214. if (reloadToken !== this.displayReloadToken) {
  215. return
  216. }
  217. this.listScroller.scrollToIndex(0)
  218. }, 120)
  219. }
  220. return
  221. }
  222. const initialItems: Array<VideoItem> = displayItems.slice(0, WEBDAV_PROGRESSIVE_RELOAD_INITIAL_COUNT)
  223. this.dataSource.pushArrayData(initialItems)
  224. if (shouldScrollToTop && initialItems.length > 0) {
  225. setTimeout(() => {
  226. if (reloadToken !== this.displayReloadToken) {
  227. return
  228. }
  229. this.listScroller.scrollToIndex(0)
  230. }, 80)
  231. }
  232. let cursor: number = initialItems.length
  233. const appendNextBatch = (): void => {
  234. if (reloadToken !== this.displayReloadToken) {
  235. return
  236. }
  237. if (cursor >= displayItems.length) {
  238. return
  239. }
  240. const nextBatch: Array<VideoItem> = displayItems.slice(cursor, cursor + WEBDAV_PROGRESSIVE_RELOAD_BATCH_SIZE)
  241. this.dataSource.appendArrayData(nextBatch)
  242. cursor += nextBatch.length
  243. if (cursor < displayItems.length) {
  244. setTimeout(() => {
  245. appendNextBatch()
  246. }, WEBDAV_PROGRESSIVE_RELOAD_DELAY_MS)
  247. }
  248. }
  249. setTimeout(() => {
  250. appendNextBatch()
  251. }, WEBDAV_PROGRESSIVE_RELOAD_DELAY_MS)
  252. }
  253. private cancelPendingDirectoryRefresh(): void {
  254. if (this.pendingDirectoryRefreshTimer >= 0) {
  255. clearTimeout(this.pendingDirectoryRefreshTimer)
  256. this.pendingDirectoryRefreshTimer = -1
  257. }
  258. this.pendingDirectoryRefreshToken += 1
  259. }
  260. private scheduleDirectoryRefresh(previewPath: string | undefined, loadAction: () => Promise<void>,
  261. errorPrefix: string): void {
  262. this.cancelPendingDirectoryRefresh()
  263. const refreshToken: number = this.pendingDirectoryRefreshToken
  264. const hasPreview: boolean = previewPath !== undefined ? this.showDirectoryPreviewCache(previewPath) : false
  265. this.isLoading = !hasPreview
  266. this.isRefreshingCache = false
  267. const executeLoad = (): void => {
  268. if (refreshToken !== this.pendingDirectoryRefreshToken) {
  269. return
  270. }
  271. void loadAction().catch((error: Error) => {
  272. if (refreshToken !== this.pendingDirectoryRefreshToken) {
  273. return
  274. }
  275. Logger.error(TAG, `${errorPrefix}: ${error.message}`)
  276. this.isLoading = false
  277. this.isRefreshingCache = false
  278. })
  279. }
  280. if (!hasPreview) {
  281. executeLoad()
  282. return
  283. }
  284. this.pendingDirectoryRefreshTimer = setTimeout(() => {
  285. this.pendingDirectoryRefreshTimer = -1
  286. executeLoad()
  287. }, 220) as number
  288. }
  289. private buildThumbnailIdentity(item: VideoItem): string {
  290. const accountId: string = item.webdav_account_id ? item.webdav_account_id : '';
  291. const relPath: string = item.remote_rel_path ? item.remote_rel_path : '';
  292. return `${item.type}:${accountId}:${relPath}:${item.filePath}`;
  293. }
  294. private async ensureThumbnailCacheDir(): Promise<string> {
  295. const context = getContext(this);
  296. const cacheDir: string = `${context.filesDir}/${REMOTE_THUMB_CACHE_DIR}`;
  297. await FileManager.createDir(cacheDir);
  298. return cacheDir;
  299. }
  300. private async resolveRemoteThumbPath(item: VideoItem): Promise<string> {
  301. const cacheDir: string = await this.ensureThumbnailCacheDir();
  302. const identity: string = this.buildThumbnailIdentity(item);
  303. const hashName: string = MD5.digestSync(identity);
  304. return `${cacheDir}/${hashName}.jpg`;
  305. }
  306. private buildFfmpegHeadersArg(headers: Map<string, string>): string {
  307. if (!headers || headers.size <= 0) {
  308. return '';
  309. }
  310. const lines: string[] = [];
  311. headers.forEach((value: string, key: string): void => {
  312. if (StrUtil.isEmpty(key) || StrUtil.isEmpty(value)) {
  313. return;
  314. }
  315. lines.push(`${key}: ${value}`);
  316. });
  317. if (lines.length <= 0) {
  318. return '';
  319. }
  320. return `${lines.join('\r\n')}\r\n`;
  321. }
  322. private buildRemoteThumbFfmpegCmd(playUrl: string, headersArg: string, outputPath: string): string[] {
  323. const commands: string[] = ['ffmpeg', '-y', '-ss', REMOTE_THUMB_CAPTURE_SECONDS];
  324. const networkArgs: string[] = this.buildRemoteFfmpegNetworkArgs(playUrl);
  325. for (let index: number = 0; index < networkArgs.length; index += 1) {
  326. commands.push(networkArgs[index]);
  327. }
  328. if (StrUtil.isNotEmpty(headersArg)) {
  329. commands.push('-headers', headersArg);
  330. }
  331. commands.push(
  332. '-i', playUrl,
  333. '-frames:v', '1',
  334. '-vf', 'scale=320:-2',
  335. '-q:v', '4',
  336. outputPath
  337. );
  338. return commands;
  339. }
  340. private buildRemoteAudioCoverFfmpegCmd(playUrl: string, headersArg: string, outputPath: string): string[] {
  341. const commands: string[] = ['ffmpeg', '-y'];
  342. const networkArgs: string[] = this.buildRemoteFfmpegNetworkArgs(playUrl);
  343. for (let index: number = 0; index < networkArgs.length; index += 1) {
  344. commands.push(networkArgs[index]);
  345. }
  346. if (StrUtil.isNotEmpty(headersArg)) {
  347. commands.push('-headers', headersArg);
  348. }
  349. commands.push(
  350. '-i', playUrl,
  351. '-map', '0:v:0',
  352. '-frames:v', '1',
  353. '-q:v', '4',
  354. outputPath
  355. );
  356. return commands;
  357. }
  358. private buildRemoteFfmpegNetworkArgs(playUrl: string): string[] {
  359. if (StrUtil.isEmpty(playUrl)) {
  360. return [];
  361. }
  362. const lowerPlayUrl: string = playUrl.toLowerCase();
  363. if (!lowerPlayUrl.startsWith('http://') && !lowerPlayUrl.startsWith('https://')) {
  364. return [];
  365. }
  366. return ['-rw_timeout', '15000000', '-probesize', '1048576', '-analyzeduration', '2000000'];
  367. }
  368. private sanitizeFfmpegCommands(commands: string[]): string[] {
  369. const safeCommands: string[] = [];
  370. for (let index: number = 0; index < commands.length; index++) {
  371. const value: string = commands[index];
  372. if (value === undefined || value === null) {
  373. Logger.warn(TAG, `缩略图命令存在空参数 index=${index}`);
  374. continue;
  375. }
  376. const text: string = `${value}`;
  377. if (text.length <= 0) {
  378. Logger.warn(TAG, `缩略图命令存在空字符串参数 index=${index}`);
  379. continue;
  380. }
  381. safeCommands.push(text);
  382. }
  383. return safeCommands;
  384. }
  385. private async executeSafeFfmpeg(commands: string[]): Promise<void> {
  386. // 兼容部分版本 @sj/ffmpeg 对第二个参数空值处理不安全的问题
  387. await FFmpeg.execute(commands, {});
  388. }
  389. private normalizeThumbImageSource(path: string): string {
  390. if (StrUtil.isEmpty(path)) {
  391. return '';
  392. }
  393. if (path.startsWith('http://') || path.startsWith('https://') || path.startsWith('file://')) {
  394. return path;
  395. }
  396. if (path.startsWith('/')) {
  397. return fileUri.getUriFromPath(path);
  398. }
  399. return path;
  400. }
  401. private normalizeThumbLocalPath(path: string): string {
  402. if (StrUtil.isEmpty(path)) {
  403. return '';
  404. }
  405. if (path.startsWith('file://')) {
  406. try {
  407. return new fileUri.FileUri(path).path;
  408. } catch (error) {
  409. const err: Error = error as Error;
  410. Logger.warn(TAG, `缩略图URI转路径失败 uri=${path}, error=${err.message}`);
  411. return '';
  412. }
  413. }
  414. return path;
  415. }
  416. private isThumbCacheValid(localPath: string): boolean {
  417. if (StrUtil.isEmpty(localPath)) {
  418. return false;
  419. }
  420. if (!FileUtil.accessSync(localPath)) {
  421. return false;
  422. }
  423. try {
  424. const stat: fileIo.Stat = fileIo.statSync(localPath);
  425. if (stat.size <= 1024) {
  426. Logger.warn(TAG, `缩略图缓存文件过小,判定无效 path=${localPath}, size=${stat.size}`);
  427. return false;
  428. }
  429. if (this.isImageMagicValid(localPath)) {
  430. return true;
  431. }
  432. Logger.warn(TAG, `缩略图缓存文件头无效,判定无效 path=${localPath}, size=${stat.size}`);
  433. } catch (error) {
  434. const err: Error = error as Error;
  435. Logger.warn(TAG, `缩略图缓存校验失败 path=${localPath}, error=${err.message}`);
  436. }
  437. return false;
  438. }
  439. private isImageMagicValid(localPath: string): boolean {
  440. let file: fileIo.File | undefined = undefined;
  441. try {
  442. file = fileIo.openSync(localPath, fileIo.OpenMode.READ_ONLY);
  443. const headerBuffer: ArrayBuffer = new ArrayBuffer(12);
  444. const readLen: number = fileIo.readSync(file.fd, headerBuffer);
  445. if (readLen < 4) {
  446. return false;
  447. }
  448. const bytes: Uint8Array = new Uint8Array(headerBuffer);
  449. const isJpg: boolean = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
  450. const isPng: boolean = readLen >= 8 &&
  451. bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47 &&
  452. bytes[4] === 0x0D && bytes[5] === 0x0A && bytes[6] === 0x1A && bytes[7] === 0x0A;
  453. const isGif: boolean = readLen >= 4 &&
  454. bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38;
  455. const isBmp: boolean = bytes[0] === 0x42 && bytes[1] === 0x4D;
  456. const isWebp: boolean = readLen >= 12 &&
  457. bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
  458. bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
  459. return isJpg || isPng || isGif || isBmp || isWebp;
  460. } catch (error) {
  461. const err: Error = error as Error;
  462. Logger.warn(TAG, `缩略图文件头校验失败 path=${localPath}, error=${err.message}`);
  463. return false;
  464. } finally {
  465. if (file !== undefined) {
  466. try {
  467. fileIo.closeSync(file);
  468. } catch (error) {
  469. const err: Error = error as Error;
  470. Logger.warn(TAG, `关闭缩略图文件失败 path=${localPath}, error=${err.message}`);
  471. }
  472. }
  473. }
  474. }
  475. private removeInvalidThumbCache(localPath: string): void {
  476. if (StrUtil.isEmpty(localPath) || !FileUtil.accessSync(localPath)) {
  477. return;
  478. }
  479. try {
  480. fileIo.unlinkSync(localPath);
  481. Logger.info(TAG, `已删除无效缩略图缓存 path=${localPath}`);
  482. } catch (error) {
  483. const err: Error = error as Error;
  484. Logger.warn(TAG, `删除无效缩略图缓存失败 path=${localPath}, error=${err.message}`);
  485. }
  486. }
  487. private resolveFileNameForTypeCheck(item: VideoItem): string {
  488. const rawName: string = item.fileName ?? item.remote_rel_path ?? item.filePath;
  489. if (StrUtil.isEmpty(rawName)) {
  490. return '';
  491. }
  492. const hashIndex = rawName.indexOf('#');
  493. const queryIndex = rawName.indexOf('?');
  494. let endIndex = rawName.length;
  495. if (queryIndex >= 0) {
  496. endIndex = queryIndex;
  497. }
  498. if (hashIndex >= 0 && hashIndex < endIndex) {
  499. endIndex = hashIndex;
  500. }
  501. return rawName.substring(0, endIndex);
  502. }
  503. private isVideoMediaItem(item: VideoItem): boolean {
  504. if (item.mimeType && item.mimeType.toLowerCase().startsWith('video/')) {
  505. return true;
  506. }
  507. const fileNameForCheck: string = this.resolveFileNameForTypeCheck(item);
  508. return Utility.isVideoByExtension(fileNameForCheck);
  509. }
  510. private refreshThumbItem(item: VideoItem): void {
  511. const dataIndex: number = this.dataSource.dataArray.indexOf(item);
  512. if (dataIndex >= 0) {
  513. this.dataSource.notifyDataChange(dataIndex);
  514. }
  515. }
  516. private async prepareRemoteThumbSource(item: VideoItem): Promise<RemoteThumbSource> {
  517. let playUrl: string = item.filePath;
  518. if (StrUtil.isNotEmpty(playUrl)) {
  519. const lowerPath = playUrl.toLowerCase();
  520. const isDirectPath = lowerPath.startsWith('http://') || lowerPath.startsWith('https://') ||
  521. lowerPath.startsWith('/') || lowerPath.startsWith('file://');
  522. if (!isDirectPath) {
  523. try {
  524. playUrl = await setVideoUrlForSong(item, {
  525. extractCover: false,
  526. extractLyric: false,
  527. extractAudioInfo: false
  528. });
  529. } catch (error) {
  530. Logger.warn(TAG, `解析缩略图播放地址失败 name=${item.name}, error=${(error as Error).message}`);
  531. }
  532. }
  533. }
  534. const headers: Map<string, string> = await this.webdavManager.buildHttpHeadersWithAccountId(item, playUrl);
  535. const source: RemoteThumbSource = {
  536. playUrl: playUrl,
  537. headers: headers
  538. };
  539. return source;
  540. }
  541. private async tryResolveRemoteMusicCover(item: VideoItem, token: number): Promise<void> {
  542. if (item.isCustomCover === 1 && StrUtil.isNotEmpty(item.pixelMapPath)) {
  543. this.refreshThumbItem(item);
  544. return;
  545. }
  546. const coverPath: string = await this.resolveRemoteThumbPath(item);
  547. if (this.isThumbCacheValid(coverPath)) {
  548. mergeCoverWithPriority(item, this.normalizeThumbImageSource(coverPath));
  549. this.refreshThumbItem(item);
  550. return;
  551. }
  552. this.removeInvalidThumbCache(coverPath);
  553. const source: RemoteThumbSource = await this.prepareRemoteThumbSource(item);
  554. if (StrUtil.isEmpty(source.playUrl)) {
  555. return;
  556. }
  557. const headersArg: string = this.buildFfmpegHeadersArg(source.headers);
  558. const rawCommands: string[] = this.buildRemoteAudioCoverFfmpegCmd(source.playUrl, headersArg, coverPath);
  559. const commands: string[] = this.sanitizeFfmpegCommands(rawCommands);
  560. if (commands.length < 2) {
  561. return;
  562. }
  563. await this.runSerializedThumbnailFfmpeg(async (): Promise<void> => {
  564. if (token !== this.thumbnailTaskToken) {
  565. return;
  566. }
  567. await this.executeSafeFfmpeg(commands);
  568. });
  569. if (token !== this.thumbnailTaskToken) {
  570. return;
  571. }
  572. if (this.isThumbCacheValid(coverPath)) {
  573. mergeCoverWithPriority(item, this.normalizeThumbImageSource(coverPath));
  574. this.refreshThumbItem(item);
  575. }
  576. }
  577. private async generateRemoteVideoThumb(item: VideoItem, token: number): Promise<void> {
  578. if (token !== this.thumbnailTaskToken) {
  579. return;
  580. }
  581. const identity: string = this.buildThumbnailIdentity(item);
  582. if (this.thumbnailRunningKeys.has(identity)) {
  583. return;
  584. }
  585. this.thumbnailRunningKeys.add(identity);
  586. try {
  587. const isVideoFile: boolean = this.isVideoMediaItem(item);
  588. if (!isVideoFile) {
  589. await this.tryResolveRemoteMusicCover(item, token);
  590. return;
  591. }
  592. const thumbPath: string = await this.resolveRemoteThumbPath(item);
  593. if (this.isThumbCacheValid(thumbPath)) {
  594. mergeCoverWithPriority(item, this.normalizeThumbImageSource(thumbPath));
  595. this.refreshThumbItem(item);
  596. return;
  597. }
  598. this.removeInvalidThumbCache(thumbPath);
  599. const source = await this.prepareRemoteThumbSource(item);
  600. if (StrUtil.isEmpty(source.playUrl)) {
  601. return;
  602. }
  603. const headersArg: string = this.buildFfmpegHeadersArg(source.headers);
  604. const rawCommands: string[] = this.buildRemoteThumbFfmpegCmd(source.playUrl, headersArg, thumbPath);
  605. const commands: string[] = this.sanitizeFfmpegCommands(rawCommands);
  606. if (commands.length < 2) {
  607. return;
  608. }
  609. await this.runSerializedThumbnailFfmpeg(async (): Promise<void> => {
  610. if (token !== this.thumbnailTaskToken) {
  611. return;
  612. }
  613. await this.executeSafeFfmpeg(commands);
  614. });
  615. if (token !== this.thumbnailTaskToken) {
  616. return;
  617. }
  618. if (this.isThumbCacheValid(thumbPath)) {
  619. mergeCoverWithPriority(item, this.normalizeThumbImageSource(thumbPath));
  620. this.refreshThumbItem(item);
  621. }
  622. } catch (error) {
  623. const err: Error = error as Error;
  624. Logger.info(TAG, `远程缩略图/封面生成失败 name=${item.name}, error=${err.message}`);
  625. } finally {
  626. this.thumbnailRunningKeys.delete(identity);
  627. }
  628. }
  629. private scheduleRemoteThumbPrefetch(): void {
  630. const token: number = this.thumbnailTaskToken + 1;
  631. this.thumbnailTaskToken = token;
  632. this.thumbnailRunningKeys.clear();
  633. const mediaItems: VideoItem[] = this.songs.slice();
  634. if (mediaItems.length <= 0) {
  635. return;
  636. }
  637. const isLargeList: boolean = mediaItems.length >= REMOTE_THUMB_LARGE_LIST_THRESHOLD;
  638. const limitCount: number = isLargeList ?
  639. Math.min(mediaItems.length, REMOTE_THUMB_LARGE_LIST_PREFETCH_COUNT) :
  640. Math.min(mediaItems.length, REMOTE_THUMB_MAX_TASK_COUNT);
  641. const targetItems: VideoItem[] = mediaItems.slice(0, limitCount);
  642. const delayMs: number = isLargeList ? REMOTE_THUMB_LARGE_LIST_DELAY_MS : 120;
  643. setTimeout((): void => {
  644. void this.runRemoteThumbPrefetchQueue(targetItems, token);
  645. }, delayMs);
  646. }
  647. private async runRemoteThumbPrefetchQueue(items: VideoItem[], token: number): Promise<void> {
  648. for (let index: number = 0; index < items.length; index += 1) {
  649. if (token !== this.thumbnailTaskToken) {
  650. return;
  651. }
  652. const item: VideoItem = items[index];
  653. if (StrUtil.isNotEmpty(item.pixelMapPath)) {
  654. const currentThumbRef: string = item.pixelMapPath ? item.pixelMapPath : '';
  655. if (currentThumbRef.startsWith('http')) {
  656. continue;
  657. }
  658. const currentThumbPath: string = this.normalizeThumbLocalPath(currentThumbRef);
  659. if (StrUtil.isNotEmpty(currentThumbPath) && this.isThumbCacheValid(currentThumbPath)) {
  660. continue;
  661. }
  662. }
  663. await this.generateRemoteVideoThumb(item, token);
  664. }
  665. }
  666. // 更新可见文件夹列表
  667. private updateVisibleFolders(): void {
  668. try {
  669. // 安全检查webDavFiles
  670. if (!this.webDavFiles || !Array.isArray(this.webDavFiles)) {
  671. this.visibleFoldersState = [];
  672. return;
  673. }
  674. const allFolders = this.webDavFiles.filter(f => f.isDirectory);
  675. const isFlatFolderAccount = this.selectedAccount?.webType === RemoteDriveType.Navidrome
  676. || this.selectedAccount?.webType === RemoteDriveType.Jellyfin
  677. || this.selectedAccount?.webType === RemoteDriveType.Emby;
  678. if (isFlatFolderAccount) {
  679. const sortedNavFolders = allFolders.sort((a, b) => a.fileName.localeCompare(b.fileName));
  680. this.visibleFoldersState = sortedNavFolders;
  681. return;
  682. }
  683. const visible: FileInfo[] = [];
  684. for (let i = 0; i < allFolders.length; i++) {
  685. const folder = allFolders[i];
  686. // 安全检查folder对象
  687. if (!folder || typeof folder.fileName !== 'string') {
  688. continue;
  689. }
  690. let shouldShow = this.isDirectChildOfCurrentPath(folder);
  691. if (shouldShow) {
  692. visible.push(folder);
  693. }
  694. }
  695. console.log('更新文件夹列表:', visible);
  696. this.visibleFoldersState = visible;
  697. } catch (error) {
  698. Logger.error(TAG, '更新文件夹列表失败:', error.toString());
  699. this.visibleFoldersState = [];
  700. }
  701. this.isShowTitleBar = true
  702. }
  703. private isSongSelected(song: VideoItem): boolean {
  704. return this.selectedSongs.some(item => item.filePath === song.filePath);
  705. }
  706. private resolveFolderSelectionKey(folder: FileInfo): string {
  707. return folder.href;
  708. }
  709. private isFolderSelected(folder: FileInfo): boolean {
  710. const key = this.resolveFolderSelectionKey(folder);
  711. return this.selectedFolders.some(item => this.resolveFolderSelectionKey(item) === key);
  712. }
  713. private getSelectableSongs(): VideoItem[] {
  714. return this.isSearchMode ? this.filteredList : this.songs;
  715. }
  716. private getSelectableFolders(): FileInfo[] {
  717. return this.isSearchMode ? this.filteredFolderList : this.visibleFoldersState;
  718. }
  719. private getSelectedEntryCount(): number {
  720. return this.selectedSongs.length + this.selectedFolders.length;
  721. }
  722. private hasSelectedFolders(): boolean {
  723. return this.selectedFolders.length > 0;
  724. }
  725. private canOperateOnSelectedSongs(): boolean {
  726. return this.selectedSongs.length > 0 && !this.hasSelectedFolders() && !this.isDeletingSelection;
  727. }
  728. private updateAllSelectedState(): void {
  729. const totalSelectable = this.getSelectableSongs().length + this.getSelectableFolders().length;
  730. const totalSelected = this.getSelectedEntryCount();
  731. this.isAllSelected = totalSelected > 0 && totalSelected === totalSelectable;
  732. }
  733. private enterMultiSelect(song: VideoItem): void {
  734. if (!this.isMultiSelect) {
  735. this.isMultiSelect = true;
  736. this.selectedSongs = [song];
  737. this.selectedFolders = [];
  738. this.updateAllSelectedState();
  739. return;
  740. }
  741. if (!this.isSongSelected(song)) {
  742. this.toggleSongSelection(song);
  743. }
  744. }
  745. private toggleSongSelection(song: VideoItem): void {
  746. if (!this.isMultiSelect) {
  747. this.isMultiSelect = true;
  748. }
  749. const exists = this.isSongSelected(song);
  750. if (exists) {
  751. const next = this.selectedSongs.filter(item => item.filePath !== song.filePath);
  752. this.selectedSongs = next;
  753. this.updateAllSelectedState();
  754. if (next.length === 0) {
  755. if (this.selectedFolders.length === 0) {
  756. this.exitMultiSelect();
  757. }
  758. }
  759. } else {
  760. const next = [...this.selectedSongs, song];
  761. this.selectedSongs = next;
  762. this.updateAllSelectedState();
  763. }
  764. }
  765. private handleCheckboxSelection(song: VideoItem, checked: boolean): void {
  766. if (this.ignoreTapAfterExitMultiSelect) {
  767. return;
  768. }
  769. if (!this.isMultiSelect) {
  770. this.isMultiSelect = true;
  771. }
  772. const exists = this.isSongSelected(song);
  773. if (checked && !exists) {
  774. this.toggleSongSelection(song);
  775. } else if (!checked && exists) {
  776. this.toggleSongSelection(song);
  777. }
  778. }
  779. private enterFolderMultiSelect(folder: FileInfo): void {
  780. if (!this.isMultiSelect) {
  781. this.isMultiSelect = true;
  782. this.selectedFolders = [folder];
  783. this.selectedSongs = [];
  784. this.updateAllSelectedState();
  785. return;
  786. }
  787. if (!this.isFolderSelected(folder)) {
  788. this.toggleFolderSelection(folder);
  789. }
  790. }
  791. private toggleFolderSelection(folder: FileInfo): void {
  792. if (!this.isMultiSelect) {
  793. this.isMultiSelect = true;
  794. }
  795. const key = this.resolveFolderSelectionKey(folder);
  796. const exists = this.selectedFolders.some(item => this.resolveFolderSelectionKey(item) === key);
  797. if (exists) {
  798. const next = this.selectedFolders.filter(item => this.resolveFolderSelectionKey(item) !== key);
  799. this.selectedFolders = next;
  800. this.updateAllSelectedState();
  801. if (next.length === 0 && this.selectedSongs.length === 0) {
  802. this.exitMultiSelect();
  803. }
  804. } else {
  805. this.selectedFolders = [...this.selectedFolders, folder];
  806. this.updateAllSelectedState();
  807. }
  808. }
  809. private handleFolderCheckboxSelection(folder: FileInfo, checked: boolean): void {
  810. if (this.ignoreTapAfterExitMultiSelect) {
  811. return;
  812. }
  813. if (!this.isMultiSelect) {
  814. this.isMultiSelect = true;
  815. }
  816. const exists = this.isFolderSelected(folder);
  817. if (checked && !exists) {
  818. this.toggleFolderSelection(folder);
  819. } else if (!checked && exists) {
  820. this.toggleFolderSelection(folder);
  821. }
  822. }
  823. private toggleSelectAll(): void {
  824. if (this.isAllSelected) {
  825. this.selectedSongs = [];
  826. this.selectedFolders = [];
  827. this.isAllSelected = false;
  828. return;
  829. }
  830. if (!this.isMultiSelect) {
  831. this.isMultiSelect = true;
  832. }
  833. this.selectedSongs = this.getSelectableSongs().slice();
  834. this.selectedFolders = this.getSelectableFolders().slice();
  835. this.updateAllSelectedState();
  836. }
  837. private exitMultiSelect(): void {
  838. this.ignoreTapAfterExitMultiSelect = true;
  839. this.isMultiSelect = false;
  840. this.selectedSongs = [];
  841. this.selectedFolders = [];
  842. this.isAllSelected = false;
  843. setTimeout(() => {
  844. this.isMultiSelect = false;
  845. this.ignoreTapAfterExitMultiSelect = false;
  846. }, 180);
  847. console.info('onecold this.isMultiSelect ' + this.isMultiSelect)
  848. }
  849. private syncSelectionAfterRefresh(): void {
  850. if (!this.isMultiSelect) {
  851. return;
  852. }
  853. const currentSongKeys: Set<string> = new Set(this.getSelectableSongs().map(item => item.filePath));
  854. const filteredSongs = this.selectedSongs.filter(item => currentSongKeys.has(item.filePath));
  855. if (filteredSongs.length !== this.selectedSongs.length) {
  856. this.selectedSongs = filteredSongs;
  857. }
  858. const currentFolderKeys: Set<string> = new Set(
  859. this.getSelectableFolders().map(item => this.resolveFolderSelectionKey(item))
  860. );
  861. const filteredFolders = this.selectedFolders.filter(item =>
  862. currentFolderKeys.has(this.resolveFolderSelectionKey(item))
  863. );
  864. if (filteredFolders.length !== this.selectedFolders.length) {
  865. this.selectedFolders = filteredFolders;
  866. }
  867. this.updateAllSelectedState();
  868. if (filteredSongs.length === 0 && filteredFolders.length === 0) {
  869. this.exitMultiSelect();
  870. }
  871. }
  872. private confirmDeleteSelected(): void {
  873. if (!this.selectedAccount) {
  874. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  875. return;
  876. }
  877. const totalSelected = this.getSelectedEntryCount();
  878. if (!this.isMultiSelect || totalSelected === 0) {
  879. this.getUIContext().getPromptAction().showToast({ message: '请先选择要删除的文件或文件夹' });
  880. return;
  881. }
  882. this.getUIContext().showAlertDialog({
  883. title: '删除项目',
  884. message: `确定删除选中的${totalSelected}项吗?`,
  885. primaryButton: {
  886. value: '取消',
  887. action: () => {}
  888. },
  889. secondaryButton: {
  890. value: '删除',
  891. fontColor: Color.Red,
  892. action: () => {
  893. void this.performDeleteSelected();
  894. }
  895. }
  896. });
  897. }
  898. private async performDeleteSelected(): Promise<void> {
  899. if (!this.selectedAccount || this.getSelectedEntryCount() === 0) {
  900. return;
  901. }
  902. this.isDeletingSelection = true;
  903. try {
  904. if (this.selectedSongs.length > 0) {
  905. await this.webdavManager.deleteRemoteSongs(this.selectedAccount, this.selectedSongs.slice());
  906. }
  907. if (this.selectedFolders.length > 0) {
  908. await this.webdavManager.deleteRemoteFolders(this.selectedAccount, this.selectedFolders.slice());
  909. }
  910. this.removeSongsLocally(this.selectedSongs.slice())
  911. this.removeFoldersLocally(this.selectedFolders.slice())
  912. this.exitMultiSelect();
  913. this.getUIContext().getPromptAction().showToast({ message: '删除成功' });
  914. } catch (error) {
  915. const err = error as Error;
  916. this.getUIContext().getPromptAction().showToast({ message: `删除失败: ${err.message}` });
  917. } finally {
  918. this.isDeletingSelection = false;
  919. }
  920. }
  921. private confirmDeleteSingleSong(song: VideoItem): void {
  922. if (!this.selectedAccount) {
  923. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  924. return;
  925. }
  926. this.getUIContext().showAlertDialog({
  927. title: '删除文件',
  928. message: `确定删除 "${song.name}" 吗?`,
  929. primaryButton: {
  930. value: '取消',
  931. action: () => {}
  932. },
  933. secondaryButton: {
  934. value: '删除',
  935. fontColor: Color.Red,
  936. action: () => {
  937. void this.performDeleteSingleSong(song);
  938. }
  939. }
  940. });
  941. }
  942. private async performDeleteSingleSong(song: VideoItem): Promise<void> {
  943. if (!this.selectedAccount) {
  944. return;
  945. }
  946. this.isDeletingSelection = true;
  947. try {
  948. await this.webdavManager.deleteRemoteSongs(this.selectedAccount, [song]);
  949. this.removeSongsLocally([song])
  950. this.getUIContext().getPromptAction().showToast({ message: '删除成功' });
  951. } catch (error) {
  952. const err = error as Error;
  953. this.getUIContext().getPromptAction().showToast({ message: `删除失败: ${err.message}` });
  954. } finally {
  955. this.isDeletingSelection = false;
  956. }
  957. }
  958. private getRemoteFolderDisplayName(folder: FileInfo): string {
  959. return decodeUrlEncodedString((folder.fileName ?? folder.name ?? '').replace(/\/+$/, ''));
  960. }
  961. private confirmDeleteSingleFolder(folder: FileInfo): void {
  962. if (!this.selectedAccount) {
  963. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  964. return;
  965. }
  966. this.getUIContext().showAlertDialog({
  967. title: '删除文件夹',
  968. message: `确定删除 "${this.getRemoteFolderDisplayName(folder)}" 吗?`,
  969. primaryButton: {
  970. value: '取消',
  971. action: () => {}
  972. },
  973. secondaryButton: {
  974. value: '删除',
  975. fontColor: Color.Red,
  976. action: () => {
  977. void this.performDeleteSingleFolder(folder);
  978. }
  979. }
  980. });
  981. }
  982. private async performDeleteSingleFolder(folder: FileInfo): Promise<void> {
  983. if (!this.selectedAccount) {
  984. return;
  985. }
  986. this.isDeletingSelection = true;
  987. try {
  988. await this.webdavManager.deleteRemoteFolders(this.selectedAccount, [folder]);
  989. this.removeFoldersLocally([folder])
  990. this.getUIContext().getPromptAction().showToast({ message: '删除成功' });
  991. } catch (error) {
  992. const err = error as Error;
  993. this.getUIContext().getPromptAction().showToast({ message: `删除失败: ${err.message}` });
  994. } finally {
  995. this.isDeletingSelection = false;
  996. }
  997. }
  998. private removeDisplayedRemoteSongsByKeys(keys: Set<string>): void {
  999. for (let index = this.dataSource.dataArray.length - 1; index >= 0; index--) {
  1000. const item = this.dataSource.dataArray[index]
  1001. if (item && keys.has(item.filePath)) {
  1002. this.dataSource.deleteData(index)
  1003. }
  1004. }
  1005. }
  1006. private syncGlobalWebdavQueueAfterSongRemoval(keys: Set<string>): void {
  1007. if (keys.size <= 0 || !ArrayUtil.isNotEmpty(globalWebdavVideoItems)) {
  1008. return
  1009. }
  1010. const nextQueue = globalWebdavVideoItems.filter((item: VideoItem) => !keys.has(item.filePath))
  1011. if (nextQueue.length === globalWebdavVideoItems.length) {
  1012. return
  1013. }
  1014. const currentPath = this.currentSong?.filePath ?? '';
  1015. let nextIndex = nextQueue.findIndex((item: VideoItem) => item.filePath === currentPath)
  1016. if (nextIndex < 0) {
  1017. nextIndex = nextQueue.length > 0 ? Math.min(globalWebdavCurrentPlayIndex, nextQueue.length - 1) : 0
  1018. }
  1019. globalWebdavVideoItems = nextQueue
  1020. globalWebdavCurrentPlayIndex = nextIndex
  1021. const eventQueueRefresh: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAY_QUEUE_REFRESH }
  1022. emitter.emit(eventQueueRefresh, { data: { source: 'webdav-delete' } })
  1023. }
  1024. private removeSongsLocally(songs: VideoItem[]): void {
  1025. if (ArrayUtil.isEmpty(songs)) {
  1026. return
  1027. }
  1028. const keys: Set<string> = new Set<string>(songs.map((item: VideoItem) => item.filePath))
  1029. this.songs = this.songs.filter((item: VideoItem) => !keys.has(item.filePath))
  1030. this.webdavManager.webDavSongs = this.songs.slice()
  1031. this.filteredList = this.filteredList.filter((item: VideoItem) => !keys.has(item.filePath))
  1032. this.removeDisplayedRemoteSongsByKeys(keys)
  1033. this.syncGlobalWebdavQueueAfterSongRemoval(keys)
  1034. if (this.isSearchMode && this.searchText.length > 0) {
  1035. this.syncSelectionAfterRefresh()
  1036. } else {
  1037. this.listRefreshKey += 1
  1038. this.syncSelectionAfterRefresh()
  1039. }
  1040. this.scheduleRemoteThumbPrefetch()
  1041. }
  1042. private removeFoldersLocally(folders: FileInfo[]): void {
  1043. if (ArrayUtil.isEmpty(folders)) {
  1044. return
  1045. }
  1046. const keys: Set<string> = new Set<string>(folders.map((item: FileInfo) => this.resolveFolderSelectionKey(item)))
  1047. this.webDavFiles = this.webDavFiles.filter((item: FileInfo) => !keys.has(this.resolveFolderSelectionKey(item)))
  1048. this.webdavManager.webDavFiles = this.webDavFiles.slice()
  1049. this.visibleFoldersState = this.visibleFoldersState.filter((item: FileInfo) =>
  1050. !keys.has(this.resolveFolderSelectionKey(item)))
  1051. this.filteredFolderList = this.filteredFolderList.filter((item: FileInfo) =>
  1052. !keys.has(this.resolveFolderSelectionKey(item)))
  1053. if (this.isSearchMode && this.searchText.length > 0) {
  1054. this.refreshDisplaySongs(this.filteredList)
  1055. } else {
  1056. this.updateVisibleFolders()
  1057. }
  1058. this.syncSelectionAfterRefresh()
  1059. }
  1060. @Builder
  1061. private buildSwipeDeleteAction(onDelete: () => void) {
  1062. Button() {
  1063. Image($r('app.media.delete2'))
  1064. .fillColor(Color.White)
  1065. .width(20)
  1066. .height(20)
  1067. }
  1068. .width(56)
  1069. .height(56)
  1070. .type(ButtonType.Circle)
  1071. .backgroundColor('#E53935')
  1072. .margin(8)
  1073. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.86 })
  1074. .onClick(() => {
  1075. onDelete()
  1076. })
  1077. }
  1078. private canRenameRemoteSong(song: VideoItem): boolean {
  1079. return song.type === CommonConstants.TYPE_WEBDAV ||
  1080. song.type === CommonConstants.TYPE_SMB ||
  1081. song.type === CommonConstants.TYPE_FTP ||
  1082. song.type === CommonConstants.TYPE_BAIDU;
  1083. }
  1084. private canRenameRemoteFolder(): boolean {
  1085. return !!this.selectedAccount &&
  1086. (this.selectedAccount.webType === RemoteDriveType.WebDav ||
  1087. this.selectedAccount.webType === RemoteDriveType.Smb ||
  1088. this.selectedAccount.webType === RemoteDriveType.Ftp ||
  1089. this.selectedAccount.webType === RemoteDriveType.Baidu);
  1090. }
  1091. private getRemoteSongRenameFileName(song: VideoItem): string {
  1092. const rawName = StrUtil.isNotEmpty(song.fileName) ? song.fileName as string : song.name;
  1093. return decodeUrlEncodedString(rawName ?? '');
  1094. }
  1095. private getRemoteSongRenameExtension(song: VideoItem): string {
  1096. const fileName = this.getRemoteSongRenameFileName(song);
  1097. const lastDotIndex = fileName.lastIndexOf('.');
  1098. if (lastDotIndex <= 0) {
  1099. return '';
  1100. }
  1101. return fileName.substring(lastDotIndex);
  1102. }
  1103. private async resolveSongAccountForAction(song: VideoItem): Promise<WebDavAccount | null> {
  1104. if (song.webdav_account_id) {
  1105. const account = await this.webdavManager.getWebDavAccountById(song.webdav_account_id);
  1106. if (account) {
  1107. return account;
  1108. }
  1109. }
  1110. return this.selectedAccount ?? null;
  1111. }
  1112. private showRenameSongDialog(song: VideoItem): void {
  1113. if (!this.canRenameRemoteSong(song)) {
  1114. this.getUIContext().getPromptAction().showToast({ message: '当前文件暂不支持重命名' });
  1115. return;
  1116. }
  1117. const extension = this.getRemoteSongRenameExtension(song);
  1118. const currentThemeColor = this.themeColor;
  1119. this.renameSongDialogController = new CustomDialogController({
  1120. builder: CreateFolderDialog({
  1121. onConfirm: (fileName: string) => {
  1122. if (fileName.trim().length > 0) {
  1123. void this.performRenameRemoteSong(song, fileName.trim());
  1124. } else {
  1125. this.getUIContext().getPromptAction().showToast({ message: '文件名称不能为空' });
  1126. }
  1127. },
  1128. onCancel: () => {
  1129. },
  1130. initialName: this.getRemoteSongRenameFileName(song),
  1131. title: '重命名',
  1132. placeholder: extension.length > 0 ? `请输入文件名称,保留 ${extension} 格式` : '请输入文件名称',
  1133. confirmText: '确定',
  1134. cancelText: '取消',
  1135. themeColor: currentThemeColor
  1136. }),
  1137. autoCancel: true,
  1138. alignment: DialogAlignment.Center,
  1139. customStyle: false
  1140. });
  1141. this.renameSongDialogController.open();
  1142. }
  1143. private showRenameFolderDialog(folder: FileInfo): void {
  1144. if (!this.canRenameRemoteFolder()) {
  1145. this.getUIContext().getPromptAction().showToast({ message: '当前文件夹暂不支持重命名' });
  1146. return;
  1147. }
  1148. const currentThemeColor = this.themeColor;
  1149. this.renameSongDialogController = new CustomDialogController({
  1150. builder: CreateFolderDialog({
  1151. onConfirm: (folderName: string) => {
  1152. if (folderName.trim().length > 0) {
  1153. void this.performRenameRemoteFolder(folder, folderName.trim());
  1154. } else {
  1155. this.getUIContext().getPromptAction().showToast({ message: '文件夹名称不能为空' });
  1156. }
  1157. },
  1158. onCancel: () => {
  1159. },
  1160. initialName: this.getRemoteFolderDisplayName(folder),
  1161. title: '重命名',
  1162. placeholder: '请输入文件夹名称',
  1163. confirmText: '确定',
  1164. cancelText: '取消',
  1165. themeColor: currentThemeColor
  1166. }),
  1167. autoCancel: true,
  1168. alignment: DialogAlignment.Center,
  1169. customStyle: false
  1170. });
  1171. this.renameSongDialogController.open();
  1172. }
  1173. private async performRenameRemoteSong(song: VideoItem, content: string): Promise<void> {
  1174. const nextName = content ? content.trim() : '';
  1175. if (!nextName) {
  1176. this.getUIContext().getPromptAction().showToast({ message: '文件名称不能为空' });
  1177. return;
  1178. }
  1179. const currentFileName = this.getRemoteSongRenameFileName(song);
  1180. if (nextName === currentFileName) {
  1181. return;
  1182. }
  1183. const account = await this.resolveSongAccountForAction(song);
  1184. if (!account) {
  1185. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  1186. return;
  1187. }
  1188. this.isLoading = true;
  1189. try {
  1190. await this.webdavManager.renameRemoteSong(account, song, nextName);
  1191. await this.webdavManager.loadFilesInfoFromAccount(account, this.currentDirectoryPath || account.filepath || '/');
  1192. this.getUIContext().getPromptAction().showToast({ message: '重命名成功' });
  1193. } catch (error) {
  1194. const err = error as Error;
  1195. this.getUIContext().getPromptAction().showToast({ message: `重命名失败: ${err.message}` });
  1196. } finally {
  1197. this.isLoading = false;
  1198. }
  1199. }
  1200. private async performRenameRemoteFolder(folder: FileInfo, content: string): Promise<void> {
  1201. const nextName = content ? content.trim().replace(/\/+$/, '') : '';
  1202. if (!nextName) {
  1203. this.getUIContext().getPromptAction().showToast({ message: '文件夹名称不能为空' });
  1204. return;
  1205. }
  1206. const currentFolderName = this.getRemoteFolderDisplayName(folder);
  1207. if (nextName === currentFolderName) {
  1208. return;
  1209. }
  1210. if (!this.selectedAccount) {
  1211. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  1212. return;
  1213. }
  1214. this.isLoading = true;
  1215. try {
  1216. await this.webdavManager.renameRemoteFolder(this.selectedAccount, folder, nextName);
  1217. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount,
  1218. this.currentDirectoryPath || this.selectedAccount.filepath || '/');
  1219. this.getUIContext().getPromptAction().showToast({ message: '重命名成功' });
  1220. } catch (error) {
  1221. const err = error as Error;
  1222. this.getUIContext().getPromptAction().showToast({ message: `重命名失败: ${err.message}` });
  1223. } finally {
  1224. this.isLoading = false;
  1225. }
  1226. }
  1227. private addSongToNextPlay(song: VideoItem): void {
  1228. if (!song) {
  1229. return;
  1230. }
  1231. const displayQueue: VideoItem[] = this.getCurrentPlaybackQueue();
  1232. const queue: VideoItem[] = globalWebdavVideoItems && globalWebdavVideoItems.length > 0 ?
  1233. globalWebdavVideoItems : displayQueue;
  1234. if (!queue || queue.length === 0) {
  1235. this.getUIContext().getPromptAction().showToast({ message: '当前列表为空' });
  1236. return;
  1237. }
  1238. const currentPath = this.currentSong?.filePath;
  1239. if (StrUtil.isEmpty(currentPath)) {
  1240. const targetIndex = displayQueue.findIndex((item: VideoItem) => item.filePath === song.filePath);
  1241. if (targetIndex >= 0) {
  1242. this.playSong(song, targetIndex, false);
  1243. this.getUIContext().getPromptAction().showToast({ message: '已开始播放' });
  1244. }
  1245. return;
  1246. }
  1247. if (currentPath === song.filePath) {
  1248. this.getUIContext().getPromptAction().showToast({ message: '当前正在播放这首歌' });
  1249. return;
  1250. }
  1251. let currentIndex = queue.findIndex((item: VideoItem) => item.filePath === currentPath);
  1252. if (currentIndex < 0 && globalWebdavCurrentPlayIndex >= 0 && globalWebdavCurrentPlayIndex < queue.length) {
  1253. currentIndex = globalWebdavCurrentPlayIndex;
  1254. }
  1255. if (currentIndex < 0) {
  1256. currentIndex = 0;
  1257. }
  1258. const existIndex = queue.findIndex((item: VideoItem) => item.filePath === song.filePath);
  1259. if (existIndex >= 0) {
  1260. const movedSong = queue.splice(existIndex, 1)[0];
  1261. if (existIndex < currentIndex) {
  1262. currentIndex -= 1;
  1263. }
  1264. queue.splice(currentIndex + 1, 0, movedSong);
  1265. } else {
  1266. queue.splice(currentIndex + 1, 0, song);
  1267. }
  1268. globalWebdavCurrentPlayIndex = currentIndex;
  1269. globalWebdavVideoItems = queue;
  1270. this.songs = [...queue];
  1271. this.dataSource.pushArrayData(this.songs);
  1272. const eventQueueRefresh: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAY_QUEUE_REFRESH };
  1273. emitter.emit(eventQueueRefresh, { data: { source: 'webdav' } });
  1274. this.getUIContext().getPromptAction().showToast({ message: '已添加到下一首播放' });
  1275. }
  1276. private cloneSongForDb(song: VideoItem): VideoItem {
  1277. const dbSong = new VideoItem(
  1278. song.name || '',
  1279. song.id || song.filePath,
  1280. song.filePath,
  1281. song.type,
  1282. song.videoSize || 0,
  1283. song.cTime || Utility.getFormatDateStr(new Date(), 'yyyy-MM-dd HH:mm')
  1284. );
  1285. dbSong.parentPath = song.parentPath;
  1286. dbSong.isFav = song.isFav;
  1287. dbSong.size = song.size;
  1288. dbSong.pixelMapPath = song.pixelMapPath;
  1289. dbSong.isCustomCover = song.isCustomCover;
  1290. dbSong.artist = song.artist;
  1291. dbSong.album = song.album;
  1292. dbSong.fileName = song.fileName;
  1293. dbSong.duration = song.duration;
  1294. dbSong.mimeType = song.mimeType;
  1295. dbSong.sampleRate = song.sampleRate;
  1296. dbSong.trackCount = song.trackCount;
  1297. dbSong.lastPlayedStr = song.lastPlayedStr;
  1298. dbSong.playCount = song.playCount;
  1299. dbSong.lyricContent = song.lyricContent;
  1300. dbSong.md5Str = song.md5Str;
  1301. dbSong.extra_json = song.extra_json;
  1302. dbSong.pyStr = song.pyStr;
  1303. dbSong.bit_rate = song.bit_rate;
  1304. dbSong.probe_score = song.probe_score;
  1305. dbSong.year = song.year;
  1306. dbSong.nb_streams = song.nb_streams;
  1307. dbSong.nb_programs = song.nb_programs;
  1308. dbSong.genre = song.genre;
  1309. dbSong.track = song.track;
  1310. dbSong.bits_per_raw_sample = song.bits_per_raw_sample;
  1311. dbSong.channels = song.channels;
  1312. dbSong.channel_layout = song.channel_layout;
  1313. dbSong.start_time = song.start_time;
  1314. dbSong.ALBUMARTIST = song.ALBUMARTIST;
  1315. dbSong.COMPOSER = song.COMPOSER;
  1316. dbSong.LYRICIST = song.LYRICIST;
  1317. dbSong.COMMENT = song.COMMENT;
  1318. dbSong.disc = song.disc;
  1319. dbSong.webdav_account_id = song.webdav_account_id;
  1320. dbSong.remote_rel_path = song.remote_rel_path;
  1321. dbSong.navArtistId = song.navArtistId;
  1322. dbSong.navAlbumId = song.navAlbumId;
  1323. dbSong.baiduFsId = song.baiduFsId;
  1324. dbSong.webdav_id = song.webdav_id;
  1325. dbSong.lyricIndex = song.lyricIndex;
  1326. return dbSong;
  1327. }
  1328. private emitPlaylistRefresh(): void {
  1329. emitter.emit({ eventId: EventConstants.EVENT_PLAYLIST_REFRESH }, {});
  1330. }
  1331. private buildPlaylistSongCandidates(songs: VideoItem[]): VideoItem[] {
  1332. if (!songs || songs.length <= 0) {
  1333. return [];
  1334. }
  1335. const result: VideoItem[] = [];
  1336. const existedPaths: Set<string> = new Set<string>();
  1337. for (let index: number = 0; index < songs.length; index += 1) {
  1338. const song = songs[index];
  1339. if (!song || StrUtil.isEmpty(song.filePath)) {
  1340. continue;
  1341. }
  1342. if (existedPaths.has(song.filePath)) {
  1343. continue;
  1344. }
  1345. existedPaths.add(song.filePath);
  1346. result.push(song);
  1347. }
  1348. return result;
  1349. }
  1350. private async resolvePlaylistSongPaths(mediaTable: MediaTable, songs: VideoItem[]): Promise<string[]> {
  1351. const paths: string[] = [];
  1352. for (let index: number = 0; index < songs.length; index += 1) {
  1353. const song: VideoItem = songs[index];
  1354. const songForDb: VideoItem = this.cloneSongForDb(song);
  1355. if (!songForDb.webdav_account_id && this.selectedAccount?.id) {
  1356. songForDb.webdav_account_id = this.selectedAccount.id.toString();
  1357. }
  1358. if (StrUtil.isEmpty(songForDb.id)) {
  1359. songForDb.id = songForDb.remote_rel_path || songForDb.filePath;
  1360. }
  1361. if (StrUtil.isEmpty(songForDb.remote_rel_path)) {
  1362. songForDb.remote_rel_path = songForDb.id || songForDb.filePath;
  1363. }
  1364. if (StrUtil.isEmpty(songForDb.parentPath) && songForDb.filePath.includes('/')) {
  1365. const pathIndex = songForDb.filePath.lastIndexOf('/');
  1366. if (pathIndex > 0) {
  1367. songForDb.parentPath = songForDb.filePath.substring(0, pathIndex);
  1368. }
  1369. }
  1370. const saved: boolean = await mediaTable.saveOrUpdateWebDavItem(songForDb);
  1371. if (!saved) {
  1372. Logger.warn(TAG, `歌曲入库失败,跳过加入歌单: ${songForDb.filePath}`);
  1373. continue;
  1374. }
  1375. const storedSong: VideoItem | null = await mediaTable.queryVideoByFilePath(songForDb.filePath);
  1376. if (storedSong && StrUtil.isNotEmpty(storedSong.filePath)) {
  1377. paths.push(storedSong.filePath);
  1378. }
  1379. }
  1380. const uniquePaths: string[] = [];
  1381. const pathSet: Set<string> = new Set<string>();
  1382. for (let index: number = 0; index < paths.length; index += 1) {
  1383. const path = paths[index];
  1384. if (StrUtil.isEmpty(path) || pathSet.has(path)) {
  1385. continue;
  1386. }
  1387. pathSet.add(path);
  1388. uniquePaths.push(path);
  1389. }
  1390. return uniquePaths;
  1391. }
  1392. private async openSongsAddToPlaylistDialog(songs: VideoItem[]): Promise<void> {
  1393. const candidateSongs: VideoItem[] = this.buildPlaylistSongCandidates(songs);
  1394. if (candidateSongs.length <= 0) {
  1395. this.getUIContext().getPromptAction().showToast({ message: '请选择要加入歌单的歌曲' });
  1396. return;
  1397. }
  1398. try {
  1399. const uiContext = this.getUIContext();
  1400. const hostCtx = uiContext ? uiContext.getHostContext() : undefined;
  1401. if (!hostCtx) {
  1402. this.getUIContext().getPromptAction().showToast({ message: '无法获取上下文' });
  1403. return;
  1404. }
  1405. const playlistTable: PlaylistTable = new PlaylistTable(hostCtx);
  1406. const mediaTable: MediaTable = new MediaTable(hostCtx);
  1407. await new Promise<void>((resolve) => {
  1408. mediaTable.getRdbStore(hostCtx, () => resolve());
  1409. });
  1410. const playlistSongPaths: string[] = await this.resolvePlaylistSongPaths(mediaTable, candidateSongs);
  1411. if (playlistSongPaths.length <= 0) {
  1412. this.getUIContext().getPromptAction().showToast({ message: '歌曲入库失败,无法添加到歌单' });
  1413. return;
  1414. }
  1415. const resolveSuccessToast = (): string => {
  1416. if (playlistSongPaths.length > 1) {
  1417. return `已添加${playlistSongPaths.length}首到歌单`;
  1418. }
  1419. return '已添加到歌单';
  1420. };
  1421. const openDialog = async (): Promise<void> => {
  1422. const playlists: Playlist[] = await playlistTable.queryAllPlaylists();
  1423. showAddToPlaylistDialog(
  1424. candidateSongs,
  1425. playlists,
  1426. async (playlistId: string) => {
  1427. const success = await playlistTable.addSongsToPlaylist(playlistId, playlistSongPaths);
  1428. if (success) {
  1429. this.emitPlaylistRefresh();
  1430. }
  1431. this.getUIContext().getPromptAction().showToast({ message: success ? resolveSuccessToast() : '歌曲已在该歌单中' });
  1432. },
  1433. () => {},
  1434. () => {
  1435. showCreatePlaylistDialog(
  1436. async (name: string, description: string, coverPath?: string) => {
  1437. const created = await playlistTable.createPlaylist(name, description, coverPath);
  1438. if (!created) {
  1439. this.getUIContext().getPromptAction().showToast({ message: '歌单创建失败' });
  1440. return;
  1441. }
  1442. const target = await playlistTable.queryPlaylistByName(name);
  1443. if (!target) {
  1444. this.getUIContext().getPromptAction().showToast({ message: '未找到新建歌单' });
  1445. return;
  1446. }
  1447. const addSuccess = await playlistTable.addSongsToPlaylist(target.id, playlistSongPaths);
  1448. if (addSuccess) {
  1449. this.emitPlaylistRefresh();
  1450. }
  1451. this.getUIContext().getPromptAction().showToast({ message: addSuccess ? resolveSuccessToast() : '歌曲已在该歌单中' });
  1452. },
  1453. () => {}
  1454. );
  1455. }
  1456. );
  1457. };
  1458. await openDialog();
  1459. } catch (error) {
  1460. const err = error as Error;
  1461. Logger.error(TAG, `打开添加到歌单失败: ${err.message}`);
  1462. this.getUIContext().getPromptAction().showToast({ message: '添加到歌单失败' });
  1463. }
  1464. }
  1465. private async openSelectedSongsAddToPlaylistDialog(): Promise<void> {
  1466. if (!this.isMultiSelect || this.selectedSongs.length <= 0) {
  1467. this.getUIContext().getPromptAction().showToast({ message: '请先选择歌曲' });
  1468. return;
  1469. }
  1470. await this.openSongsAddToPlaylistDialog(this.selectedSongs.slice());
  1471. }
  1472. private async openSongAddToPlaylistDialog(song: VideoItem): Promise<void> {
  1473. if (!song) {
  1474. return;
  1475. }
  1476. await this.openSongsAddToPlaylistDialog([song]);
  1477. }
  1478. private sanitizeDownloadFileName(fileName: string): string {
  1479. const trimmed = (fileName || '').trim();
  1480. const sanitized = trimmed.replace(/[\\/:*?"<>|]/g, '_');
  1481. return sanitized.length > 0 ? sanitized : '未知歌曲.mp3';
  1482. }
  1483. private resolveDownloadFileName(song: VideoItem, sourceUrlOrPath: string): string {
  1484. if (StrUtil.isNotEmpty(song.fileName)) {
  1485. return this.sanitizeDownloadFileName(song.fileName as string);
  1486. }
  1487. if (StrUtil.isNotEmpty(song.name) && (song.name as string).includes('.')) {
  1488. return this.sanitizeDownloadFileName(song.name as string);
  1489. }
  1490. if (StrUtil.isNotEmpty(sourceUrlOrPath)) {
  1491. const purePath = sourceUrlOrPath.split('?')[0];
  1492. const lastSlash = purePath.lastIndexOf('/');
  1493. if (lastSlash >= 0 && lastSlash < purePath.length - 1) {
  1494. const urlName = purePath.substring(lastSlash + 1);
  1495. if (StrUtil.isNotEmpty(urlName)) {
  1496. return this.sanitizeDownloadFileName(urlName);
  1497. }
  1498. }
  1499. }
  1500. const baseName = StrUtil.isNotEmpty(song.name) ? song.name as string : '未知歌曲';
  1501. return this.sanitizeDownloadFileName(`${baseName}.mp3`);
  1502. }
  1503. private buildUniqueDownloadPath(downloadDir: string, fileName: string): string {
  1504. const safeName = this.sanitizeDownloadFileName(fileName);
  1505. const dotIndex = safeName.lastIndexOf('.');
  1506. const hasExt = dotIndex > 0;
  1507. const namePart = hasExt ? safeName.substring(0, dotIndex) : safeName;
  1508. const extPart = hasExt ? safeName.substring(dotIndex) : '';
  1509. let targetPath = `${downloadDir}/${safeName}`;
  1510. let suffix = 1;
  1511. while (FileUtil.accessSync(targetPath)) {
  1512. targetPath = `${downloadDir}/${namePart}(${suffix})${extPart}`;
  1513. suffix += 1;
  1514. }
  1515. return targetPath;
  1516. }
  1517. private async pickDownloadRootPath(): Promise<string> {
  1518. try {
  1519. const documentViewPicker = new picker.DocumentViewPicker();
  1520. const documentSaveResult = await documentViewPicker.save({ pickerMode: picker.DocumentPickerMode.DOWNLOAD });
  1521. if (!documentSaveResult || documentSaveResult.length === 0) {
  1522. return '';
  1523. }
  1524. const resolvedPath = new fileUri.FileUri(documentSaveResult[0]).path;
  1525. this.rootPath = resolvedPath;
  1526. return resolvedPath;
  1527. } catch (error) {
  1528. const err = error as Error;
  1529. Logger.error(TAG, `选择下载目录失败: ${err.message}`);
  1530. return '';
  1531. }
  1532. }
  1533. private async ensureDownloadDirectory(): Promise<string> {
  1534. let basePath = this.rootPath;
  1535. if (StrUtil.isEmpty(basePath) || !FileUtil.accessSync(basePath)) {
  1536. basePath = await this.pickDownloadRootPath();
  1537. }
  1538. if (StrUtil.isEmpty(basePath)) {
  1539. return '';
  1540. }
  1541. const downloadDir = `${basePath}/${this.downloadFolderName}`;
  1542. if (!FileUtil.accessSync(downloadDir)) {
  1543. await fileIo.mkdir(downloadDir);
  1544. }
  1545. return downloadDir;
  1546. }
  1547. private async upsertDownloadedSongToLibrary(targetPath: string, sourceSong: VideoItem): Promise<void> {
  1548. if (StrUtil.isEmpty(targetPath) || !FileUtil.accessSync(targetPath)) {
  1549. return;
  1550. }
  1551. try {
  1552. const pageContext = getContext(this);
  1553. const mediaModule = await import('../common/util/MediaTable');
  1554. const mediaTable = new mediaModule.default(pageContext);
  1555. await new Promise<void>((resolve) => {
  1556. mediaTable.getRdbStore(pageContext, () => resolve());
  1557. });
  1558. const exists = await mediaTable.isRecordExists(targetPath);
  1559. if (exists) {
  1560. return;
  1561. }
  1562. let mediaItem: VideoItem;
  1563. try {
  1564. mediaItem = await Utility.readMetaInfoFFmpeg(pageContext, targetPath, CommonConstants.TYPE_LOCAL, false);
  1565. } catch (error) {
  1566. Logger.warn(TAG, `下载歌曲读取元数据失败,使用兜底入库: ${(error as Error).message}`);
  1567. mediaItem = new VideoItem(
  1568. sourceSong.name || FileUtil.getFileName(targetPath),
  1569. targetPath,
  1570. targetPath,
  1571. CommonConstants.TYPE_LOCAL,
  1572. 0,
  1573. Utility.getFormatDateStr(new Date(), 'yyyy-MM-dd HH:mm')
  1574. );
  1575. mediaItem.fileName = FileUtil.getFileName(targetPath);
  1576. mediaItem.artist = sourceSong.artist;
  1577. mediaItem.album = sourceSong.album;
  1578. }
  1579. mediaItem.id = targetPath;
  1580. mediaItem.filePath = targetPath;
  1581. mediaItem.type = CommonConstants.TYPE_LOCAL;
  1582. if (StrUtil.isEmpty(mediaItem.fileName)) {
  1583. mediaItem.fileName = FileUtil.getFileName(targetPath);
  1584. }
  1585. if (StrUtil.isEmpty(mediaItem.name)) {
  1586. mediaItem.name = mediaItem.fileName as string;
  1587. }
  1588. if (StrUtil.isEmpty(mediaItem.parentPath)) {
  1589. mediaItem.parentPath = FileUtil.getParentPath(targetPath);
  1590. }
  1591. await new Promise<void>((resolve) => {
  1592. mediaTable.insert(mediaItem, () => resolve());
  1593. });
  1594. emitter.emit({ eventId: EventConstants.EVENT_SCAN_UPDATE }, {});
  1595. } catch (error) {
  1596. Logger.error(TAG, `下载歌曲入库失败: ${(error as Error).message}`);
  1597. }
  1598. }
  1599. private openDownloadCenter(): void {
  1600. this.syncDownloadCenterTasks();
  1601. this.isShowDownloadCenter = true;
  1602. }
  1603. private syncDownloadCenterTasks(): void {
  1604. this.downloadCenterActiveTasks = this.downloadCenterManager.getActiveTasks();
  1605. this.downloadCenterCompletedTasks = this.downloadCenterManager.getCompletedTasks();
  1606. }
  1607. private scheduleDownloadCenterRefresh(): void {
  1608. if (this.downloadCenterRefreshTimer >= 0) {
  1609. return;
  1610. }
  1611. this.downloadCenterRefreshTimer = setTimeout((): void => {
  1612. this.downloadCenterRefreshTimer = -1;
  1613. this.syncDownloadCenterTasks();
  1614. this.downloadCenterRefreshVersion += 1;
  1615. }, 0);
  1616. }
  1617. private stopDownloadCenterRefresh(): void {
  1618. if (this.downloadCenterRefreshTimer >= 0) {
  1619. clearTimeout(this.downloadCenterRefreshTimer);
  1620. this.downloadCenterRefreshTimer = -1;
  1621. }
  1622. }
  1623. private resolveSongDownloadSizeText(song: VideoItem): string {
  1624. if (StrUtil.isNotEmpty(song.size)) {
  1625. return decodeUrlEncodedString(song.size as string);
  1626. }
  1627. if (song.videoSize > 0) {
  1628. return this.formatDownloadBytes(song.videoSize);
  1629. }
  1630. return '';
  1631. }
  1632. private parseSizeTextToBytes(sizeText: string): number {
  1633. if (StrUtil.isEmpty(sizeText)) {
  1634. return 0;
  1635. }
  1636. const normalized = sizeText.trim();
  1637. const match = normalized.match(/([0-9]+(?:\.[0-9]+)?)\s*(B|KB|MB|GB|TB)/i);
  1638. if (!match) {
  1639. return 0;
  1640. }
  1641. const value = parseFloat(match[1]);
  1642. if (!Number.isFinite(value) || value <= 0) {
  1643. return 0;
  1644. }
  1645. const unit = match[2].toUpperCase();
  1646. const factor = this.resolveUnitFactor(unit);
  1647. return Math.floor(value * factor);
  1648. }
  1649. private resolveUnitFactor(unit: string): number {
  1650. switch (unit) {
  1651. case 'KB':
  1652. return 1024;
  1653. case 'MB':
  1654. return 1024 * 1024;
  1655. case 'GB':
  1656. return 1024 * 1024 * 1024;
  1657. case 'TB':
  1658. return 1024 * 1024 * 1024 * 1024;
  1659. case 'B':
  1660. default:
  1661. return 1;
  1662. }
  1663. }
  1664. private formatDownloadBytes(bytes: number): string {
  1665. if (!Number.isFinite(bytes) || bytes <= 0) {
  1666. return '';
  1667. }
  1668. const units: string[] = ['B', 'KB', 'MB', 'GB', 'TB'];
  1669. let size: number = bytes;
  1670. let index: number = 0;
  1671. while (size >= 1024 && index < units.length - 1) {
  1672. size /= 1024;
  1673. index += 1;
  1674. }
  1675. const precision: number = index === 0 ? 0 : 2;
  1676. return `${size.toFixed(precision)} ${units[index]}`;
  1677. }
  1678. private pauseDownloadTask(taskId: string): void {
  1679. if (StrUtil.isEmpty(taskId)) {
  1680. return;
  1681. }
  1682. this.downloadCenterManager.pauseTask(taskId);
  1683. }
  1684. private resumeDownloadTask(taskId: string): void {
  1685. if (StrUtil.isEmpty(taskId)) {
  1686. return;
  1687. }
  1688. this.downloadCenterManager.resumeTask(taskId);
  1689. }
  1690. private deleteDownloadTask(taskId: string): void {
  1691. if (StrUtil.isEmpty(taskId)) {
  1692. return;
  1693. }
  1694. this.downloadCenterManager.deleteTask(taskId);
  1695. this.syncDownloadCenterTasks();
  1696. this.downloadCenterRefreshVersion += 1;
  1697. }
  1698. private buildDownloadedVideoItem(task: DownloadCenterTask): VideoItem {
  1699. const fileName = StrUtil.isNotEmpty(task.fileName) ? task.fileName : FileUtil.getFileName(task.targetPath);
  1700. const title = StrUtil.isNotEmpty(task.title) ? task.title : fileName;
  1701. const item = new VideoItem(
  1702. title,
  1703. task.targetPath,
  1704. task.targetPath,
  1705. CommonConstants.TYPE_LOCAL,
  1706. task.totalBytes,
  1707. '',
  1708. task.sizeText,
  1709. task.coverPath,
  1710. undefined,
  1711. undefined,
  1712. fileName
  1713. );
  1714. item.parentPath = StrUtil.isNotEmpty(task.downloadDir) ? task.downloadDir : FileUtil.getParentPath(task.targetPath);
  1715. item.pixelMapPath = task.coverPath;
  1716. item.size = task.sizeText;
  1717. item.videoSize = task.totalBytes;
  1718. item.type = CommonConstants.TYPE_LOCAL;
  1719. return item;
  1720. }
  1721. private playCompletedDownloadTask(taskId: string): void {
  1722. if (StrUtil.isEmpty(taskId)) {
  1723. return;
  1724. }
  1725. const playableTasks = this.downloadCenterCompletedTasks.filter((task: DownloadCenterTask): boolean => {
  1726. return StrUtil.isNotEmpty(task.targetPath)
  1727. });
  1728. const playlist: VideoItem[] = playableTasks.map((task: DownloadCenterTask): VideoItem => {
  1729. return this.buildDownloadedVideoItem(task)
  1730. });
  1731. if (playlist.length <= 0) {
  1732. this.getUIContext().getPromptAction().showToast({ message: '没有可播放的下载文件' });
  1733. return;
  1734. }
  1735. const startIndex = playableTasks.findIndex((task: DownloadCenterTask): boolean => {
  1736. return task.taskId === taskId;
  1737. });
  1738. if (startIndex < 0 || startIndex >= playlist.length) {
  1739. this.getUIContext().getPromptAction().showToast({ message: '未找到对应下载任务' });
  1740. return;
  1741. }
  1742. globalWebdavVideoItems = playlist;
  1743. globalWebdavCurrentPlayIndex = startIndex;
  1744. const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
  1745. const playlistData: PlaylistEventData = {
  1746. playlistId: 'webdav-playlist',
  1747. playlistName: '下载中心',
  1748. songCount: playlist.length,
  1749. startIndex,
  1750. isJump: true,
  1751. songFilePaths: playlist.map((item: VideoItem): string => item.filePath)
  1752. };
  1753. emitter.emit(eventPlaylistPlay, { data: playlistData });
  1754. }
  1755. private async resolveSongDownloadSourceUrl(song: VideoItem): Promise<string> {
  1756. if (song.type === CommonConstants.TYPE_BAIDU && song.webdav_account_id) {
  1757. try {
  1758. const account = await this.webdavManager.getWebDavAccountById(song.webdav_account_id);
  1759. if (account) {
  1760. const dlink = await this.webdavManager.getBaiduDownloadUrl(account, song);
  1761. if (StrUtil.isNotEmpty(dlink)) {
  1762. Logger.info(TAG, `下载中心使用百度直链下载: ${song.name}`);
  1763. return dlink;
  1764. }
  1765. }
  1766. } catch (error) {
  1767. Logger.warn(TAG, `百度下载直链获取失败,回退播放地址: ${(error as Error).message}`);
  1768. }
  1769. }
  1770. if (song.type === CommonConstants.TYPE_WEBDAV) {
  1771. try {
  1772. let account: WebDavAccount | null = null;
  1773. if (song.webdav_account_id) {
  1774. account = await this.webdavManager.getWebDavAccountById(song.webdav_account_id);
  1775. }
  1776. if (!account && this.selectedAccount?.webType === RemoteDriveType.WebDav) {
  1777. account = this.selectedAccount;
  1778. }
  1779. if (account) {
  1780. const relativePath = song.remote_rel_path || song.filePath;
  1781. const fullUrl = WebDavUrlUtil.buildFullUrl(account, relativePath);
  1782. if (StrUtil.isNotEmpty(fullUrl)) {
  1783. Logger.info(TAG, `下载中心使用WebDAV直链下载: ${song.name}, url=${fullUrl}`);
  1784. return fullUrl;
  1785. }
  1786. }
  1787. } catch (error) {
  1788. Logger.warn(TAG, `WebDAV下载直链构建失败,回退播放地址: ${(error as Error).message}`);
  1789. }
  1790. }
  1791. return setVideoUrlForSong(song, {
  1792. context: getContext(this),
  1793. autoParseMusicName: false,
  1794. extractCover: false,
  1795. extractLyric: false,
  1796. extractAudioInfo: false
  1797. });
  1798. }
  1799. private async buildDownloadRequestHeaders(song: VideoItem, sourceUrl: string): Promise<Map<string, string>> {
  1800. if (song.type === CommonConstants.TYPE_BAIDU) {
  1801. const headers = new Map<string, string>();
  1802. // 参考 BaiduFileCache 下载链路,避免复用播放头导致 Range 行为异常
  1803. headers.set('User-Agent', 'pan.baidu.com');
  1804. headers.set('Accept', '*/*');
  1805. headers.set('Connection', 'Keep-Alive');
  1806. return headers;
  1807. }
  1808. return this.webdavManager.buildHttpHeadersWithAccountId(song, sourceUrl);
  1809. }
  1810. private async enqueueSongDownloadWithDirectory(song: VideoItem, downloadDir: string): Promise<string> {
  1811. if (!song) {
  1812. throw new Error('歌曲不存在');
  1813. }
  1814. if (StrUtil.isEmpty(downloadDir)) {
  1815. throw new Error('未选择下载目录');
  1816. }
  1817. const sourceUrl = await this.resolveSongDownloadSourceUrl(song);
  1818. const normalizedSource = sourceUrl.startsWith('file://') ? new fileUri.FileUri(sourceUrl).path : sourceUrl;
  1819. const fileName = this.resolveDownloadFileName(song, normalizedSource);
  1820. const targetPath = this.buildUniqueDownloadPath(downloadDir, fileName);
  1821. const sizeText = this.resolveSongDownloadSizeText(song);
  1822. const expectedBytes = this.parseSizeTextToBytes(sizeText);
  1823. const requestHeaders = await this.buildDownloadRequestHeaders(song, sourceUrl);
  1824. const coverPath = song.pixelMapPath ?? '';
  1825. this.downloadCenterManager.enqueueDownload({
  1826. title: song.name || fileName,
  1827. fileName: fileName,
  1828. coverPath: coverPath,
  1829. sizeText: sizeText,
  1830. expectedBytes: expectedBytes,
  1831. sourceUrl: sourceUrl,
  1832. targetPath: targetPath,
  1833. downloadDir: downloadDir,
  1834. headers: requestHeaders,
  1835. onCompleted: async () => {
  1836. await this.upsertDownloadedSongToLibrary(targetPath, song);
  1837. }
  1838. });
  1839. return fileName;
  1840. }
  1841. private async enqueueSongDownload(song: VideoItem): Promise<void> {
  1842. if (!song) {
  1843. return;
  1844. }
  1845. try {
  1846. const downloadDir = await this.ensureDownloadDirectory();
  1847. if (StrUtil.isEmpty(downloadDir)) {
  1848. this.getUIContext().getPromptAction().showToast({ message: '未选择下载目录' });
  1849. return;
  1850. }
  1851. const fileName: string = await this.enqueueSongDownloadWithDirectory(song, downloadDir);
  1852. this.openDownloadCenter();
  1853. this.getUIContext().getPromptAction().showToast({ message: `已加入下载队列: ${fileName}` });
  1854. } catch (error) {
  1855. const err = error as Error;
  1856. Logger.error(TAG, `加入下载队列失败: ${err.message}`);
  1857. this.getUIContext().getPromptAction().showToast({ message: `下载任务创建失败: ${err.message}` });
  1858. }
  1859. }
  1860. private async enqueueSelectedSongsDownload(): Promise<void> {
  1861. if (!this.isMultiSelect || this.selectedSongs.length <= 0) {
  1862. this.getUIContext().getPromptAction().showToast({ message: '请先选择要下载的歌曲' });
  1863. return;
  1864. }
  1865. const candidates: VideoItem[] = this.buildPlaylistSongCandidates(this.selectedSongs.slice());
  1866. if (candidates.length <= 0) {
  1867. this.getUIContext().getPromptAction().showToast({ message: '没有可下载的歌曲' });
  1868. return;
  1869. }
  1870. try {
  1871. const downloadDir = await this.ensureDownloadDirectory();
  1872. if (StrUtil.isEmpty(downloadDir)) {
  1873. this.getUIContext().getPromptAction().showToast({ message: '未选择下载目录' });
  1874. return;
  1875. }
  1876. let successCount: number = 0;
  1877. let failedCount: number = 0;
  1878. for (let index: number = 0; index < candidates.length; index += 1) {
  1879. const song: VideoItem = candidates[index];
  1880. try {
  1881. await this.enqueueSongDownloadWithDirectory(song, downloadDir);
  1882. successCount += 1;
  1883. } catch (error) {
  1884. failedCount += 1;
  1885. const err = error as Error;
  1886. Logger.error(TAG, `批量下载入队失败: ${(song.name || song.fileName || song.filePath)}, ${err.message}`);
  1887. }
  1888. }
  1889. if (successCount > 0) {
  1890. this.openDownloadCenter();
  1891. this.exitMultiSelect();
  1892. }
  1893. if (successCount > 0 && failedCount <= 0) {
  1894. this.getUIContext().getPromptAction().showToast({ message: `已加入下载队列: ${successCount}首` });
  1895. return;
  1896. }
  1897. if (successCount > 0) {
  1898. this.getUIContext().getPromptAction().showToast({ message: `已加入下载队列${successCount}首,失败${failedCount}首` });
  1899. return;
  1900. }
  1901. this.getUIContext().getPromptAction().showToast({ message: '下载任务创建失败' });
  1902. } catch (error) {
  1903. const err = error as Error;
  1904. Logger.error(TAG, `批量下载失败: ${err.message}`);
  1905. this.getUIContext().getPromptAction().showToast({ message: `批量下载失败: ${err.message}` });
  1906. }
  1907. }
  1908. private buildSongMetaLine(song: VideoItem): string {
  1909. const parts: string[] = [];
  1910. if (StrUtil.isNotEmpty(song.duration)) {
  1911. parts.push(song.duration as string);
  1912. } else if (StrUtil.isNotEmpty(song.size)) {
  1913. parts.push(decodeUrlEncodedString(song.size as string));
  1914. }
  1915. if (parts.length === 0 && StrUtil.isNotEmpty(song.cTime)) {
  1916. parts.push(song.cTime as string);
  1917. }
  1918. return parts.join(' · ');
  1919. }
  1920. private buildSongQualityLabel(song: VideoItem): string {
  1921. if (!song || StrUtil.isEmpty(song.md5Str)) {
  1922. return '';
  1923. }
  1924. const quality = song.md5Str as string;
  1925. return quality.includes('Lossless') ? '无损' : quality;
  1926. }
  1927. // 对话框控制器
  1928. private accountDialogController: CustomDialogController | null = null;
  1929. // 保存事件处理器引用,用于取消订阅
  1930. private eventHandler: (event: string) => void = (event: string) => {
  1931. this.handleWebdavEvent(event);
  1932. };
  1933. initSetting(){
  1934. this.isCustomizeBg = PreferencesUtil.getBooleanSync(SettingPage.IS_CUSTOMIZE_BG, false)
  1935. this.customizeBgPath = PreferencesUtil.getStringSync(SettingPage.IS_CUSTOMIZE_BG_PATH, '')
  1936. this.blurValue = PreferencesUtil.getNumberSync(SettingPage.CUSTOMIZE_BG_BLUR, 0)
  1937. this.bgBrightness = PreferencesUtil.getNumberSync(SettingPage.BG_BRIGHTNESS, 0)
  1938. this.isShowFileName = PreferencesUtil.getBooleanSync('isShowFileName', false)
  1939. this.isLongNameRoLL = PreferencesUtil.getBooleanSync('isLongNameRoLL', true)
  1940. this.sortType = PreferencesUtil.getNumberSync('webDavSortType', 0)
  1941. this.isShowTitleBar = PreferencesUtil.getBooleanSync(SettingPage.IS_SHOW_TITLTBAR, true)
  1942. this.autoHideTitle = PreferencesUtil.getBooleanSync('autoHideTitle', true)
  1943. }
  1944. aboutToAppear(): void {
  1945. Utility.getAppName(getContext(this)).then((appName: string) => {
  1946. this.appName = appName
  1947. })
  1948. this.initSetting()
  1949. let eventSetting: emitter.InnerEvent = { eventId: 333 }
  1950. // 监听广播事件(通用设置配置更新)
  1951. emitter.on(eventSetting, (eventData: emitter.EventData) => {
  1952. this.initSetting()
  1953. });
  1954. // 加载账户列表
  1955. this.loadAccounts();
  1956. this.loadFiles()
  1957. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  1958. // 订阅WebDAV状态变化
  1959. this.webdavManager.subscribe(this.eventHandler);
  1960. this.downloadCenterManager.subscribe(this.downloadCenterListener);
  1961. this.subscribeWebDavMetadataUpdates();
  1962. // 监听手势返回事件
  1963. let eventBackSwipeBack: emitter.InnerEvent = { eventId: EventConstants.EVENT_SWIPE_BACK_DISK }
  1964. emitter.on(eventBackSwipeBack, (eventData: emitter.EventData) => {
  1965. console.info('onecold', ' 收到 EVENT_SWIPE_BACK_DISK 事件');
  1966. // 如果在详情视图模式,退出详情视图
  1967. this.goBack()
  1968. });
  1969. }
  1970. aboutToDisappear(): void {
  1971. // 取消订阅
  1972. this.cancelPendingDirectoryRefresh();
  1973. this.thumbnailTaskToken += 1;
  1974. this.thumbnailRunningKeys.clear();
  1975. this.webdavManager.unsubscribe(this.eventHandler);
  1976. this.downloadCenterManager.unsubscribe(this.downloadCenterListener);
  1977. this.stopDownloadCenterRefresh();
  1978. emitter.off(EventConstants.EVENT_WEBDAV_METADATA_UPDATED);
  1979. }
  1980. // 处理WebDAV事件
  1981. private handleWebdavEvent(event: string): void {
  1982. switch (event) {
  1983. case RemoteDriveManagerStates.LoadFilesInfoStart:
  1984. if (this.dataSource.totalCount() > 0 || this.visibleFoldersState.length > 0) {
  1985. this.isLoading = false;
  1986. this.isRefreshingCache = true;
  1987. } else {
  1988. this.isLoading = true;
  1989. this.isRefreshingCache = false;
  1990. }
  1991. break;
  1992. case RemoteDriveManagerStates.LoadFilesInfoSucceed:
  1993. const shouldScrollToTop: boolean = !this.isRefreshingCache;
  1994. this.songs = this.webdavManager.webDavSongs;
  1995. // 直接引用webdavManager的数组,避免@Observed序列化问题
  1996. this.webDavFiles = this.webdavManager.webDavFiles;
  1997. this.currentDirectoryPath = this.webdavManager.currentPath || '/';
  1998. this.isLoading = false;
  1999. this.isRefreshingCache = false;
  2000. // 更新可见文件夹列表
  2001. this.updateVisibleFolders();
  2002. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  2003. this.syncSelectionAfterRefresh();
  2004. this.scheduleRemoteThumbPrefetch();
  2005. if (this.isSearchMode && this.searchText.length > 0) {
  2006. void this.applyGlobalSearch(this.searchText, ++this.searchTicket);
  2007. } else {
  2008. this.restoreCurrentDirectorySearchView(shouldScrollToTop);
  2009. }
  2010. // promptAction.showToast({
  2011. // message: '加载成功: ' + this.webDavFiles.filter(f => f.isDirectory).length + '个文件夹, ' + this.songs.length + '首歌曲'
  2012. // });
  2013. break;
  2014. case RemoteDriveManagerStates.LoadFilesInfoFailed:
  2015. this.isLoading = false;
  2016. this.isRefreshingCache = false;
  2017. // this.getUIContext().getPromptAction().showToast({ message: '加载失败' });
  2018. break;
  2019. case RemoteDriveManagerStates.InsertAccountSucceed:
  2020. case RemoteDriveManagerStates.EditAccountSucceed:
  2021. case RemoteDriveManagerStates.RemoveAccountSucceed:
  2022. this.loadAccounts();
  2023. break;
  2024. case RemoteDriveManagerStates.GlobalSearchIndexBuildStart:
  2025. case RemoteDriveManagerStates.GlobalSearchIndexUpdated:
  2026. case RemoteDriveManagerStates.GlobalSearchIndexReady:
  2027. if (this.isSearchMode && this.searchText.length > 0) {
  2028. void this.applyGlobalSearch(this.searchText, ++this.searchTicket);
  2029. }
  2030. break;
  2031. case RemoteDriveManagerStates.GlobalSearchIndexFailed:
  2032. this.isSearchLoading = false;
  2033. break;
  2034. }
  2035. }
  2036. private subscribeWebDavMetadataUpdates(): void {
  2037. emitter.on(this.metadataUpdateEvent, (eventData: emitter.EventData) => {
  2038. const payloads = eventData?.data as WebDavMetadataUpdatePayload[] | undefined;
  2039. if (!payloads || payloads.length === 0) {
  2040. Logger.info(TAG, 'WebDav metadata event received but payload empty');
  2041. return;
  2042. }
  2043. Logger.info(TAG, `WebDav metadata event payload count: ${payloads.length}`);
  2044. this.handleWebDavMetadataUpdates(payloads);
  2045. });
  2046. }
  2047. private handleWebDavMetadataUpdates(payloads: WebDavMetadataUpdatePayload[]): void {
  2048. if (!payloads || payloads.length === 0) {
  2049. return;
  2050. }
  2051. Logger.info(TAG, 'handleWebDavMetadataUpdates start');
  2052. let hasChanges = false;
  2053. for (let i = 0; i < payloads.length; i++) {
  2054. const payload = payloads[i];
  2055. if (!payload || !payload.filePath) {
  2056. continue;
  2057. }
  2058. Logger.info(TAG, `WebDav metadata detail path=${payload.pixelMapPath ?? 'null'}`);
  2059. const songUpdated = this.updateSongMetadata(payload);
  2060. if (songUpdated) {
  2061. hasChanges = true;
  2062. Logger.info(TAG, `metadata updated for ${payload.filePath}`);
  2063. }
  2064. }
  2065. if (!hasChanges) {
  2066. Logger.info(TAG, 'handleWebDavMetadataUpdates no changes detected');
  2067. return;
  2068. }
  2069. // 更新dataSource的数据,不滚动,只触发刷新
  2070. this.dataSource.pushArrayData(this.songs);
  2071. this.listRefreshKey++;
  2072. Logger.info(TAG, 'handleWebDavMetadataUpdates trigger refresh');
  2073. }
  2074. private updateSongMetadata(payload: WebDavMetadataUpdatePayload): boolean {
  2075. if (!payload.filePath) {
  2076. return false;
  2077. }
  2078. const targetIndex = this.findSongIndex(payload.filePath);
  2079. if (targetIndex < 0) {
  2080. return false;
  2081. }
  2082. const targetSong = this.songs[targetIndex];
  2083. let mutated = false;
  2084. if (mergeCoverWithPriority(targetSong, payload.pixelMapPath, payload.isCustomCover ?? 0)) {
  2085. mutated = true;
  2086. }
  2087. if (payload.md5Str && payload.md5Str.length > 0 && targetSong.md5Str !== payload.md5Str) {
  2088. targetSong.md5Str = payload.md5Str;
  2089. mutated = true;
  2090. }
  2091. if (payload.name && payload.name.length > 0) {
  2092. targetSong.name = payload.name;
  2093. mutated = true;
  2094. }
  2095. if (payload.artist && payload.artist.length > 0) {
  2096. targetSong.artist = payload.artist;
  2097. mutated = true;
  2098. }
  2099. return mutated;
  2100. }
  2101. private findSongIndex(filePath: string): number {
  2102. for (let i = 0; i < this.songs.length; i++) {
  2103. if (this.songs[i].filePath === filePath) {
  2104. return i;
  2105. }
  2106. }
  2107. return -1;
  2108. }
  2109. private cloneSong(item: VideoItem): VideoItem {
  2110. const clone = new VideoItem(
  2111. item.name,
  2112. item.id,
  2113. item.filePath,
  2114. item.type,
  2115. item.videoSize,
  2116. item.cTime,
  2117. item.size,
  2118. item.pixelMapPath,
  2119. item.artist,
  2120. item.album,
  2121. item.fileName,
  2122. item.lastPlayed
  2123. );
  2124. clone.duration = item.duration;
  2125. clone.mimeType = item.mimeType;
  2126. clone.trackCount = item.trackCount;
  2127. clone.sampleRate = item.sampleRate;
  2128. clone.size = item.size;
  2129. clone.webdav_account_id = item.webdav_account_id;
  2130. clone.remote_rel_path = item.remote_rel_path;
  2131. clone.artist = item.artist;
  2132. clone.album = item.album;
  2133. clone.lyricContent = item.lyricContent;
  2134. clone.pixelMapPath = item.pixelMapPath;
  2135. clone.isCustomCover = item.isCustomCover;
  2136. clone.cTime = item.cTime;
  2137. clone.fileName = item.fileName;
  2138. clone.md5Str = item.md5Str;
  2139. clone.bit_rate = item.bit_rate;
  2140. clone.probe_score = item.probe_score;
  2141. clone.year = item.year;
  2142. clone.nb_streams = item.nb_streams;
  2143. clone.nb_programs = item.nb_programs;
  2144. clone.genre = item.genre;
  2145. clone.track = item.track;
  2146. clone.disc = item.disc;
  2147. clone.channels = item.channels;
  2148. clone.channel_layout = item.channel_layout;
  2149. clone.start_time = item.start_time;
  2150. clone.ALBUMARTIST = item.ALBUMARTIST;
  2151. clone.COMPOSER = item.COMPOSER;
  2152. clone.COMMENT = item.COMMENT;
  2153. clone.LYRICIST = item.LYRICIST;
  2154. clone.pyStr = item.pyStr;
  2155. clone.extra_json = item.extra_json;
  2156. clone.parentPath = item.parentPath;
  2157. clone.isFav = item.isFav;
  2158. clone.playCount = item.playCount;
  2159. clone.lastPlayed = item.lastPlayed;
  2160. clone.videoSize = item.videoSize;
  2161. clone.isFav = item.isFav;
  2162. return clone;
  2163. }
  2164. private cloneFolderInfo(item: FileInfo): FileInfo {
  2165. const clone = new FileInfo(item.rootpath, item.name, item.totalSize, item.time);
  2166. clone.readOnly = item.readOnly;
  2167. clone.fileName = item.fileName;
  2168. clone.href = item.href;
  2169. clone.contentLength = item.contentLength;
  2170. clone.isDirectory = item.isDirectory;
  2171. return clone;
  2172. }
  2173. private applyDirectoryPreviewSnapshot(snapshot: RemoteDirectorySnapshot, targetPath: string): void {
  2174. const previewSongs: VideoItem[] = snapshot.songs.map((item: VideoItem) => this.cloneSong(item));
  2175. const previewFiles: FileInfo[] = snapshot.files.map((item: FileInfo) => this.cloneFolderInfo(item));
  2176. this.songs = previewSongs;
  2177. this.webDavFiles = previewFiles;
  2178. this.currentDirectoryPath = targetPath;
  2179. this.webdavManager.webDavSongs = previewSongs.slice();
  2180. this.webdavManager.webDavFiles = previewFiles.slice();
  2181. this.webdavManager.currentPath = targetPath;
  2182. this.updateVisibleFolders();
  2183. this.breadcrumbs = this.webdavManager.getBreadcrumbsForPreview(this.selectedAccount, targetPath);
  2184. if (this.isSearchMode && this.searchText.length > 0) {
  2185. void this.applyGlobalSearch(this.searchText, ++this.searchTicket);
  2186. return;
  2187. }
  2188. this.restoreCurrentDirectorySearchView();
  2189. }
  2190. private showDirectoryPreviewCache(targetPath?: string): boolean {
  2191. if (!this.selectedAccount) {
  2192. return false;
  2193. }
  2194. const resolvedPath = targetPath && targetPath.length > 0 ? targetPath : (this.selectedAccount.filepath || '/');
  2195. const preview = this.webdavManager.getDirectoryPreview(this.selectedAccount, resolvedPath);
  2196. if (!preview) {
  2197. return false;
  2198. }
  2199. this.applyDirectoryPreviewSnapshot(preview, resolvedPath);
  2200. return true;
  2201. }
  2202. // 加载账户列表
  2203. private loadAccounts(): void {
  2204. this.accounts = this.webdavManager.getAllWebDavAccounts();
  2205. }
  2206. // 加载文件列表
  2207. private loadFiles(): void {
  2208. if (!this.selectedAccount) {
  2209. this.getUIContext().getPromptAction().showToast({ message: '请先选择账户' });
  2210. return;
  2211. }
  2212. console.info('heanup '+this.selectedAccount.name+'type '+this.selectedAccount.webType)
  2213. this.scheduleDirectoryRefresh(this.selectedAccount.filepath,
  2214. () => this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount), '加载文件失败')
  2215. }
  2216. // 进入文件夹
  2217. private enterFolder(folder: FileInfo): void {
  2218. this.scheduleDirectoryRefresh(folder.href, () => this.webdavManager.enterFolder(folder), '进入文件夹失败')
  2219. }
  2220. // 检查是否为当前目录的直接子项
  2221. private isDirectChildOfCurrentPath(folder: FileInfo): boolean {
  2222. const currentPath = this.currentDirectoryPath || '';
  2223. // 如果在根目录(currentPath为空或'/'),显示所有第一级文件夹
  2224. if (currentPath === '' || currentPath === '/') {
  2225. const folderPath = folder.href.replace(/\/$/, ''); // 去掉尾部斜杠
  2226. return folder.href.startsWith('/') &&
  2227. folder.href !== '/' &&
  2228. !folderPath.substring(1).includes('/');
  2229. }
  2230. // 非根目录情况,计算相对路径
  2231. let relativePath = folder.href;
  2232. if (currentPath !== '/') {
  2233. relativePath = folder.href.replace(currentPath, '');
  2234. }
  2235. relativePath = relativePath.replace(/^\//, '').replace(/\/$/, '');
  2236. // 只有相对路径不为空且不包含/时才认为是直接子项
  2237. return relativePath !== '' && !relativePath.includes('/');
  2238. }
  2239. // 返回上级目录
  2240. private goBack(): void {
  2241. if(this.webdavManager.canGoBack()){
  2242. const previousPath: string | undefined = this.webdavManager.pathHistory[this.webdavManager.pathHistory.length - 1];
  2243. this.scheduleDirectoryRefresh(previousPath, () => this.webdavManager.goBack(), '返回失败')
  2244. }else{
  2245. this.getUIContext().animateTo({ duration: 555 }, () => {
  2246. // 动画闭包内控制Image组件的出现和消失
  2247. this.isShowDrawer = !this.isShowDrawer
  2248. this.offsetX = 0
  2249. })
  2250. }
  2251. }
  2252. // 切换账户
  2253. private switchAccount(account: WebDavAccount): void {
  2254. this.selectedAccount = account;
  2255. this.songs = [];
  2256. this.updateListData(this.songs)
  2257. }
  2258. private getCurrentPlaybackQueue(): VideoItem[] {
  2259. if (this.isSearchMode && this.searchText.length > 0 && this.filteredList.length > 0) {
  2260. return this.filteredList;
  2261. }
  2262. return this.songs;
  2263. }
  2264. private resolveSongPlayIndex(song: VideoItem, fallbackIndex: number, queue: VideoItem[]): number {
  2265. if (queue.length <= 0) {
  2266. return -1;
  2267. }
  2268. if (song && StrUtil.isNotEmpty(song.filePath)) {
  2269. for (let i = 0; i < queue.length; i++) {
  2270. if (queue[i].filePath === song.filePath) {
  2271. return i;
  2272. }
  2273. }
  2274. }
  2275. if (fallbackIndex >= 0 && fallbackIndex < queue.length) {
  2276. return fallbackIndex;
  2277. }
  2278. return 0;
  2279. }
  2280. // 播放WebDAV歌曲
  2281. private playSong(song: VideoItem, index: number,isJump:boolean=false): void {
  2282. try {
  2283. const queue: VideoItem[] = this.getCurrentPlaybackQueue();
  2284. Logger.info(TAG, `heanup === WebDAV playSong 方法被调用 ===`);
  2285. Logger.info(TAG, 'heanup 播放指定歌曲: ' + song.name + ', 索引: ' + index);
  2286. Logger.info(TAG, 'heanup 播放队列长度: ' + queue.length);
  2287. const playIndex = this.resolveSongPlayIndex(song, index, queue);
  2288. if (playIndex < 0 || playIndex >= queue.length) {
  2289. this.getUIContext().getPromptAction().showToast({ message: '播放失败,未找到歌曲' });
  2290. Logger.warn(TAG, `heanup 无法定位播放索引: fallback=${index}, songPath=${song.filePath}`);
  2291. return;
  2292. }
  2293. if (playIndex !== index) {
  2294. Logger.info(TAG, `heanup 修正播放索引: fallback=${index}, real=${playIndex}, song=${song.name}`);
  2295. }
  2296. // 检查歌曲是否有webdav_account_id
  2297. Logger.info(TAG, `heanup 播放歌曲的webdav_account_id: ${song.webdav_account_id || '未设置'}`);
  2298. // 确保所有歌曲都设置了正确的webdav_account_id
  2299. if (this.selectedAccount && this.selectedAccount.id) {
  2300. const accountIds = queue.map(s => s.webdav_account_id).filter(id => id);
  2301. Logger.info(TAG, `heanup 当前歌曲列表中有 ${accountIds.length}/${queue.length} 首歌曲设置了webdav_account_id`);
  2302. // 如果发现歌曲缺少webdav_account_id,立即设置
  2303. queue.forEach((item) => {
  2304. if (!item.webdav_account_id) {
  2305. item.webdav_account_id = this.selectedAccount!.id.toString();
  2306. Logger.info(TAG, `heanup 为歌曲 "${item.name}" 设置webdav_account_id: ${item.webdav_account_id}`);
  2307. }
  2308. });
  2309. }
  2310. // 直接使用当前的VideoItem数组
  2311. const videoItems: VideoItem[] = queue.slice();
  2312. const songFilePaths: string[] = [];
  2313. for (let i = 0; i < videoItems.length; i++) {
  2314. const item = videoItems[i];
  2315. songFilePaths.push(item.filePath); // 使用filePath作为文件路径
  2316. }
  2317. // 直接通过事件传递videoItems数据,不使用GlobalContext
  2318. const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
  2319. const playlistData: PlaylistEventData = {
  2320. playlistId: 'webdav-playlist', // 使用特殊的ID标识网盘播放列表
  2321. playlistName: `${getRemoteDriveDisplayLabel(this.selectedAccount?.webType)} - ${this.selectedAccount?.name || '未知账户'}`,
  2322. songCount: videoItems.length,
  2323. startIndex: playIndex,
  2324. isJump: isJump,//设置true会弹出播放页
  2325. songFilePaths: songFilePaths
  2326. };
  2327. // 保存videoItems到全局内存
  2328. globalWebdavVideoItems = videoItems;
  2329. globalWebdavCurrentPlayIndex = playIndex;
  2330. Logger.info(TAG, `heanup 保存WebDAV歌曲到全局内存,长度: ${videoItems.length}, 索引: ${playIndex}`);
  2331. // 发送播放请求事件,只传递索引信息
  2332. emitter.emit(eventPlaylistPlay, { data: playlistData });
  2333. Logger.info(TAG, `heanup 发送WebDAV播放事件,只传递索引: ${playIndex}`);
  2334. } catch (error) {
  2335. const err = error as Error;
  2336. Logger.error(TAG, '播放歌曲失败: ' + err.message);
  2337. this.getUIContext().getPromptAction().showToast({ message: '播放失败' });
  2338. }
  2339. }
  2340. /**
  2341. * 一键创建歌单:将当前WebDAV歌曲全部加入新歌单
  2342. */
  2343. private async createPlaylistFromCurrentWebDav(): Promise<void> {
  2344. try {
  2345. Logger.info(TAG, 'heanup 一键创建歌单开始');
  2346. if (!this.selectedAccount || !this.selectedAccount.id) {
  2347. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  2348. return;
  2349. }
  2350. if (!this.songs || this.songs.length === 0) {
  2351. // 回退到manager内的歌曲(可能还未复制到页面state)
  2352. this.songs = this.webdavManager.webDavSongs;
  2353. Logger.info(TAG, `heanup 页面songs为空,回退webdavManager.webDavSongs,长度=${this.songs.length}`);
  2354. if (this.songs.length === 0) {
  2355. this.getUIContext().getPromptAction().showToast({ message: '当前目录没有可添加的歌曲' });
  2356. return;
  2357. }
  2358. }
  2359. Logger.info(TAG, `heanup 歌单创建前歌曲数量: ${this.songs.length}`);
  2360. // 确保每首歌都有webdav_account_id
  2361. for (let i = 0; i < this.songs.length; i++) {
  2362. if (!this.songs[i].webdav_account_id) {
  2363. this.songs[i].webdav_account_id = this.selectedAccount.id.toString();
  2364. }
  2365. }
  2366. // 构建歌单名称:账户名 + 当前路径(简化)
  2367. const rawPath = this.currentDirectoryPath || '/';
  2368. const shortPath = rawPath === '/' ? '根目录' : rawPath.replace(/\/$/, '').split('/').slice(-1)[0];
  2369. const playlistName = `${getRemoteDrivePlaylistPrefix(this.selectedAccount.webType)}-${this.selectedAccount.name}-${shortPath}`;
  2370. // 创建歌单
  2371. // 安全获取HostContext
  2372. const uiContext = this.getUIContext();
  2373. const hostCtx = uiContext ? uiContext.getHostContext() : undefined;
  2374. if (!hostCtx) {
  2375. this.getUIContext().getPromptAction().showToast({ message: '无法获取上下文' });
  2376. return;
  2377. }
  2378. const playlistModule = await import('../common/util/PlaylistTable');
  2379. const mediaModule = await import('../common/util/MediaTable');
  2380. const playlistTable = new playlistModule.default(hostCtx);
  2381. const mediaTable = new mediaModule.default(hostCtx);
  2382. // 等待MediaTable底层RDB初始化完成
  2383. await new Promise<void>((resolve) => {
  2384. mediaTable.getRdbStore(hostCtx, () => {
  2385. Logger.info(TAG, 'heanup mediaTable RDB 初始化完成');
  2386. resolve();
  2387. });
  2388. });
  2389. // 先将WebDAV歌曲入库(若不存在)
  2390. let upsertSuccess = 0;
  2391. for (let i = 0; i < this.songs.length; i++) {
  2392. const v = this.songs[i];
  2393. if (!v.id || v.id === '') {
  2394. // 使用filePath作为唯一ID
  2395. v.id = v.filePath;
  2396. }
  2397. if (!v.parentPath) {
  2398. const idxp = v.filePath.lastIndexOf('/');
  2399. if (idxp > 0) {
  2400. v.parentPath = v.filePath.substring(0, idxp);
  2401. }
  2402. }
  2403. const ok = await mediaTable.upsertWebDavVideoItem(v);
  2404. Logger.info(TAG, `heanup upsert 第${i+1}/${this.songs.length}首: ${v.filePath} => ${ok}`);
  2405. if (ok) {
  2406. upsertSuccess++;
  2407. }
  2408. }
  2409. Logger.info(TAG, `heanup WebDAV歌曲入库完成: 成功 ${upsertSuccess}/${this.songs.length}`);
  2410. // 先查询是否已有同名歌单,避免重复创建导致混淆
  2411. const existing = await playlistTable.queryPlaylistByName(playlistName);
  2412. if (existing) {
  2413. this.getUIContext().getPromptAction().showToast({ message: '歌单已存在,直接追加歌曲' });
  2414. const filePathsExist: string[] = this.songs.map(s => s.filePath);
  2415. await playlistTable.addSongsToPlaylist(existing.id, filePathsExist);
  2416. router.pushUrl({ url: 'pages/PlaylistDetailPage', params: { playlist: existing } });
  2417. return;
  2418. }
  2419. Logger.info(TAG, `heanup 准备创建歌单: ${playlistName}`);
  2420. const created = await playlistTable.createPlaylist(playlistName, `来自${getRemoteDriveDisplayLabel(this.selectedAccount.webType)}: ${this.selectedAccount.name} 路径: ${rawPath}`);
  2421. if (!created) {
  2422. this.getUIContext().getPromptAction().showToast({ message: '歌单创建失败' });
  2423. return;
  2424. }
  2425. // 查询刚创建的歌单ID
  2426. const target = await playlistTable.queryPlaylistByName(playlistName);
  2427. if (!target) {
  2428. this.getUIContext().getPromptAction().showToast({ message: '无法找到新建歌单' });
  2429. return;
  2430. }
  2431. // 批量添加歌曲
  2432. const filePaths: string[] = this.songs.map(s => s.filePath);
  2433. Logger.info(TAG, `heanup 开始批量添加歌曲到歌单: ${target.id}`);
  2434. const addResult = await playlistTable.addSongsToPlaylist(target.id, filePaths);
  2435. Logger.info(TAG, `heanup 批量添加结果: ${addResult}`);
  2436. if (!addResult) {
  2437. Logger.warn(TAG, '批量添加歌曲返回false,可能全部已存在或写入失败');
  2438. }
  2439. this.getUIContext().getPromptAction().showToast({ message: `歌单创建成功: ${playlistName}` });
  2440. Logger.info(TAG, `heanup 一键创建歌单成功: ${playlistName}, 添加 ${filePaths.length} 首歌曲`);
  2441. // 跳转到歌单详情页面
  2442. router.pushUrl({ url: 'pages/PlaylistDetailPage', params: { playlist: target } });
  2443. } catch (error) {
  2444. Logger.error(TAG, '一键创建歌单失败: ' + (error as Error).message);
  2445. this.getUIContext().getPromptAction().showToast({ message: '一键创建歌单失败' });
  2446. }
  2447. }
  2448. // 导航到指定层级的面包屑路径
  2449. private async navigateToBreadcrumb(crumb: BreadcrumbItem): Promise<void> {
  2450. if (!crumb) {
  2451. return;
  2452. }
  2453. this.scheduleDirectoryRefresh(crumb.path, () => this.webdavManager.enterFolderFromPath(crumb.path),
  2454. '导航到面包屑路径失败')
  2455. }
  2456. @Builder
  2457. MoreMenuBuilder() {
  2458. Menu() {
  2459. MenuItem({
  2460. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.music_note_list')),
  2461. content: $r('app.string.onekey_add_playlist')
  2462. })
  2463. .onClick(async () => {
  2464. this.createPlaylistFromCurrentWebDav();
  2465. })
  2466. MenuItem({
  2467. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.arrow_up_to_line')),
  2468. content: $r('app.string.upload_music_file')
  2469. })
  2470. .onClick(async () => {
  2471. // this.navigateToUploadPage();
  2472. this.isShowUploadFile = !this.isShowUploadFile
  2473. })
  2474. MenuItem({
  2475. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.download')),
  2476. content: '下载中心'
  2477. })
  2478. .onClick(() => {
  2479. this.openDownloadCenter();
  2480. })
  2481. MenuItem({
  2482. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.folder_badge_plus')),
  2483. content: $r('app.string.create_foder')
  2484. })
  2485. .onClick(async () => {
  2486. this.showCreateFolderDialog();
  2487. })
  2488. }.attributeModifier(new MenuModifier())
  2489. }
  2490. @Builder
  2491. SortMenuBuilder() {
  2492. Menu() {
  2493. MenuItem({
  2494. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  2495. content: $r('app.string.sort_by_name'),
  2496. symbolEndIcon: this.sortType === 0 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined
  2497. })
  2498. .backgroundColor(this.getSortItemBackground(0))
  2499. .onClick(async () => {
  2500. this.doSortType(0)
  2501. PreferencesUtil.put("webDavSortType", 0)
  2502. })
  2503. MenuItem({
  2504. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  2505. content: '按名称降序',
  2506. symbolEndIcon: this.sortType === 1 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined
  2507. })
  2508. .backgroundColor(this.getSortItemBackground(1))
  2509. .onClick(async () => {
  2510. this.doSortType(1)
  2511. PreferencesUtil.put("webDavSortType", 1)
  2512. })
  2513. MenuItem({
  2514. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')),
  2515. content:'按时间升序',
  2516. symbolEndIcon: this.sortType === 2 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined
  2517. })
  2518. .backgroundColor(this.getSortItemBackground(2))
  2519. .onClick(async () => {
  2520. this.doSortType(2)
  2521. PreferencesUtil.put("webDavSortType", 2)
  2522. })
  2523. MenuItem({
  2524. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.timer')),
  2525. content: '按时间降序',
  2526. symbolEndIcon: this.sortType === 3 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined
  2527. })
  2528. .backgroundColor(this.getSortItemBackground(3))
  2529. .onClick(async () => {
  2530. this.doSortType(3)
  2531. PreferencesUtil.put("webDavSortType", 3)
  2532. })
  2533. MenuItem({
  2534. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  2535. content:'按大小升序',
  2536. symbolEndIcon: this.sortType === 4 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined
  2537. })
  2538. .backgroundColor(this.getSortItemBackground(4))
  2539. .onClick(async () => {
  2540. this.doSortType(4)
  2541. PreferencesUtil.put("webDavSortType", 4)
  2542. })
  2543. MenuItem({
  2544. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  2545. content: '按大小降序',
  2546. symbolEndIcon: this.sortType === 5 ? new SymbolGlyphModifier($r('sys.symbol.checkmark')) : undefined
  2547. })
  2548. .backgroundColor(this.getSortItemBackground(5))
  2549. .onClick(async () => {
  2550. this.doSortType(5)
  2551. PreferencesUtil.put("webDavSortType", 5)
  2552. })
  2553. }.attributeModifier(new MenuModifier())
  2554. }
  2555. private sortSongsForType(target: Array<VideoItem>, index: number): Array<VideoItem> {
  2556. switch (index) {
  2557. case 0:
  2558. Utility.doSortListAscending(target, this.isShowFileName)
  2559. break;
  2560. case 1:
  2561. Utility.doSortListDescending(target, this.isShowFileName)
  2562. break;
  2563. case 2:
  2564. target.sort((a, b) => {
  2565. return a.cTime.localeCompare(b.cTime);
  2566. });
  2567. break;
  2568. case 3:
  2569. target.sort((a, b) => {
  2570. return b.cTime.localeCompare(a.cTime);
  2571. });
  2572. break;
  2573. case 4:
  2574. target.sort((a, b) => {
  2575. return a.videoSize - b.videoSize;
  2576. });
  2577. break;
  2578. case 5:
  2579. target.sort((a, b) => {
  2580. return b.videoSize - a.videoSize;
  2581. });
  2582. break;
  2583. }
  2584. return target;
  2585. }
  2586. private sortFoldersForType(target: Array<FileInfo>, index: number): Array<FileInfo> {
  2587. switch (index) {
  2588. case 0:
  2589. target.sort((a, b) => {
  2590. return a.fileName.localeCompare(b.fileName);
  2591. });
  2592. break;
  2593. case 1:
  2594. target.sort((a, b) => {
  2595. return b.fileName.localeCompare(a.fileName);
  2596. });
  2597. break;
  2598. case 2:
  2599. target.sort((a, b) => {
  2600. return a.time - b.time;
  2601. });
  2602. break;
  2603. case 3:
  2604. target.sort((a, b) => {
  2605. return b.time - a.time;
  2606. });
  2607. break;
  2608. }
  2609. return target;
  2610. }
  2611. doSortType(index: number, shouldScrollToTop: boolean = true) {
  2612. this.sortType = index;
  2613. if (this.isSearchMode && this.searchText.length > 0) {
  2614. if (this.filteredList.length > 0) {
  2615. this.filteredList = this.sortSongsForType([...this.filteredList], index)
  2616. }
  2617. if (this.filteredFolderList.length > 0) {
  2618. this.filteredFolderList = this.sortFoldersForType([...this.filteredFolderList], index)
  2619. }
  2620. this.refreshDisplaySongs(this.filteredList, shouldScrollToTop)
  2621. return
  2622. }
  2623. this.songs = this.sortSongsForType([...this.songs], index)
  2624. this.visibleFoldersState = this.sortFoldersForType([...this.visibleFoldersState], index)
  2625. this.updateListData(this.songs,true, shouldScrollToTop)
  2626. }
  2627. private getSortItemBackground(sortType: number): ResourceColor {
  2628. if (this.sortType !== sortType) {
  2629. return Color.Transparent;
  2630. }
  2631. const isAsc: boolean = sortType % 2 === 0;
  2632. if (isAsc) {
  2633. return this.isDarkMode ? '#295B8A' : '#DCEEFF';
  2634. }
  2635. return this.isDarkMode ? '#7A4D22' : '#FFE9D5';
  2636. }
  2637. @Builder
  2638. topTitleBar(){
  2639. Column() {
  2640. Row({ space: 15 }) {
  2641. if (!this.isSearchMode &&!(this.webdavManager.canGoBack())) {
  2642. //左侧滑动按钮
  2643. Button({ type: ButtonType.Circle, stateEffect: true }) {
  2644. SymbolGlyph($r('sys.symbol.sort'))
  2645. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  2646. }
  2647. .attributeModifier(new ButtonFancyModifier(40, 40))
  2648. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  2649. .animation({ duration: 300, curve: Curve.Ease })
  2650. .onClick(() => {
  2651. this.getUIContext().animateTo({ duration: 555 }, () => {
  2652. // 动画闭包内控制Image组件的出现和消失
  2653. this.isShowDrawer = !this.isShowDrawer
  2654. this.offsetX = 0
  2655. })
  2656. })
  2657. .attributeModifier(new ShadowModifier())
  2658. .zIndex(0)
  2659. }else{
  2660. //左侧搜索返回按钮
  2661. Button({ type: ButtonType.Circle, stateEffect: true }) {
  2662. SymbolGlyph($r('sys.symbol.chevron_left'))
  2663. .attributeModifier(new SymbolGlyphFancyModifier(24, '', ''))
  2664. }
  2665. .attributeModifier(new ButtonFancyModifier(40, 40))
  2666. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  2667. .animation({ duration: 300, curve: Curve.Ease })
  2668. .onClick(() => {
  2669. if(this.isSearchMode){
  2670. this.isSearchMode = false
  2671. this.searchController.stopEditing()
  2672. this.onSearchInput('')
  2673. }else{
  2674. if(this.webdavManager.canGoBack()){
  2675. this.goBack()
  2676. }
  2677. }
  2678. })
  2679. .attributeModifier(new ShadowModifier())
  2680. .zIndex(0)
  2681. }
  2682. if(!this.isSearchMode){
  2683. Text(this.selectedAccount.name)
  2684. .margin({left:3,right:10})
  2685. .fontColor($r('app.color.text_color'))
  2686. .fontSize(19)
  2687. .maxLines(1)
  2688. .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
  2689. .layoutWeight(1)
  2690. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  2691. }
  2692. //搜索框
  2693. Search({ controller: this.searchController,value: this.searchText, placeholder: '输入名称...' })
  2694. .searchButton('搜索',{fontColor:this.themeColor})
  2695. .searchIcon({
  2696. src: $r('sys.media.ohos_ic_public_search_filled')
  2697. })
  2698. .cancelButton({
  2699. style: CancelButtonStyle.CONSTANT,
  2700. icon: {
  2701. src: $r('sys.media.ohos_ic_public_cancel_filled')
  2702. }
  2703. })
  2704. .layoutWeight(1)
  2705. .height(35)
  2706. .maxLength(20)
  2707. .backgroundColor(this.isDarkMode?Color.Black:'#F5F5F5')
  2708. .placeholderColor(Color.Grey)
  2709. .placeholderFont({ size: 14, weight: 400 })
  2710. .textFont({ size: 14, weight: 400 })
  2711. .onSubmit((value: string) => {
  2712. console.log('onecold onSubmit ='+value)
  2713. this.searchController.stopEditing()
  2714. this.commitSearchHistory(value)
  2715. this.onSearchInput(value);
  2716. })
  2717. .onChange((value: string) => {
  2718. console.log('onecold onChange ='+value)
  2719. this.onSearchInput(value);
  2720. })
  2721. .visibility(this.isSearchMode?Visibility.Visible:Visibility.None)
  2722. .animation({ duration: 300, curve: Curve.Ease })
  2723. //搜索按钮
  2724. if (!this.isSearchMode) {
  2725. Button({ type: ButtonType.Circle, stateEffect: true }) {
  2726. SymbolGlyph($r('sys.symbol.magnifyingglass'))
  2727. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  2728. }
  2729. .attributeModifier(new ButtonFancyModifier(40, 40))
  2730. .animation({ duration: 300, curve: Curve.Ease })
  2731. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  2732. .attributeModifier(new ShadowModifier())
  2733. .zIndex(0)
  2734. .onClick(()=>{
  2735. this.isSearchMode = true
  2736. this.loadSearchHistory()
  2737. this.restoreCurrentDirectorySearchView()
  2738. void this.webdavManager.ensureGlobalSearchIndex(this.selectedAccount)
  2739. })
  2740. //排序按钮
  2741. Button({ type: ButtonType.Circle, stateEffect: true }) {
  2742. SymbolGlyph($r('sys.symbol.list_number'))
  2743. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  2744. }
  2745. .attributeModifier(new ButtonFancyModifier(40, 40))
  2746. .animation({ duration: 300, curve: Curve.Ease })
  2747. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  2748. .bindMenu(this.SortMenuBuilder)
  2749. .attributeModifier(new ShadowModifier())
  2750. .zIndex(0)
  2751. //添加/上传综合按钮
  2752. Button({ type: ButtonType.Circle, stateEffect: true }) {
  2753. SymbolGlyph($r('sys.symbol.plus'))
  2754. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  2755. }
  2756. .attributeModifier(new ButtonFancyModifier(40, 40))
  2757. .animation({ duration: 300, curve: Curve.Ease })
  2758. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  2759. .bindMenu(this.MoreMenuBuilder)
  2760. .attributeModifier(new ShadowModifier())
  2761. .zIndex(0)
  2762. .bindContentCover($$this.isShowUploadFile, this.UploadFielBuilder(), {
  2763. transition: TransitionEffect.translate({ x: 500 }).animation({ curve: curves.springMotion(0.6, 0.8) })
  2764. })
  2765. }
  2766. }
  2767. }
  2768. .padding({ top: this.topSafeHeight + 10, left: 10, right: 10, bottom: 2 })
  2769. .width('100%')
  2770. }
  2771. @Builder
  2772. UploadFielBuilder() {
  2773. Scroll() {
  2774. Column() {
  2775. UploadMusicPage({
  2776. accountId: this.selectedAccount.id,
  2777. isShowUploadFile:this.isShowUploadFile,
  2778. onResult:(result:boolean)=>{
  2779. console.info("heanup onResult,上传成功,刷新列表")
  2780. if(result){
  2781. // 上传文件成功后刷新当前路径的列表
  2782. if(this.selectedAccount.webType==RemoteDriveType.Baidu){
  2783. ToastUtil.showToast(`由于百度网盘限制,百度网盘的上传路径是apps/${this.appName}`)
  2784. }
  2785. this.refreshFileListAfterUpload()
  2786. }
  2787. },
  2788. })
  2789. }
  2790. }
  2791. .width('100%')
  2792. .height('100%')
  2793. }
  2794. @Builder
  2795. DownloadCenterBuilder() {
  2796. DownloadCenter({
  2797. themeColor: this.themeColor,
  2798. isDarkMode: this.isDarkMode,
  2799. appName: this.appName,
  2800. topSafeHeight: this.topSafeHeight,
  2801. bottomSafeHeight: this.bottomSafeHeight,
  2802. tasksVersion: this.downloadCenterRefreshVersion,
  2803. activeTasksProp: this.downloadCenterActiveTasks,
  2804. completedTasksProp: this.downloadCenterCompletedTasks,
  2805. selectedIndexes: $downloadCenterTabIndex,
  2806. onPauseTask: (taskId: string) => {
  2807. this.pauseDownloadTask(taskId);
  2808. },
  2809. onResumeTask: (taskId: string) => {
  2810. this.resumeDownloadTask(taskId);
  2811. },
  2812. onDeleteTask: (taskId: string) => {
  2813. this.deleteDownloadTask(taskId);
  2814. },
  2815. onPlayCompletedTask: (taskId: string) => {
  2816. this.playCompletedDownloadTask(taskId);
  2817. },
  2818. onClose: () => {
  2819. this.isShowDownloadCenter = false;
  2820. }
  2821. })
  2822. .width('100%')
  2823. .height('100%')
  2824. }
  2825. //搜索功能的实现
  2826. @State searchText: string = ''; // 用户输入内容
  2827. @State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
  2828. @State filteredFolderList: Array<FileInfo> = []; // 过滤后的文件夹结果
  2829. @State searchHistoryItems: string[] = [];
  2830. private readonly searchHistoryScope: string = 'webdav_music';
  2831. private loadSearchHistory(): void {
  2832. this.searchHistoryItems = SearchHistoryUtil.load(this.searchHistoryScope);
  2833. }
  2834. private saveSearchHistory(keyword: string): void {
  2835. this.searchHistoryItems = SearchHistoryUtil.save(this.searchHistoryScope, keyword);
  2836. }
  2837. private commitSearchHistory(keyword: string): void {
  2838. const normalized = keyword.trim();
  2839. if (normalized.length === 0) {
  2840. return;
  2841. }
  2842. this.saveSearchHistory(normalized);
  2843. }
  2844. private applySearchHistory(keyword: string): void {
  2845. this.searchText = keyword;
  2846. this.searchController.stopEditing();
  2847. this.onSearchInput(keyword);
  2848. }
  2849. private restoreCurrentDirectorySearchView(shouldScrollToTop: boolean = true): void {
  2850. this.filteredList = []
  2851. this.filteredFolderList = []
  2852. this.songs = this.sortSongsForType([...this.songs], this.sortType)
  2853. this.visibleFoldersState = this.sortFoldersForType([...this.visibleFoldersState], this.sortType)
  2854. this.isSearchLoading = false
  2855. this.refreshDisplaySongs(this.songs, shouldScrollToTop)
  2856. this.syncSelectionAfterRefresh()
  2857. }
  2858. private shouldShowSearchLocation(): boolean {
  2859. return this.isSearchMode && this.searchText.length > 0
  2860. }
  2861. private getSongSearchLocation(song: VideoItem): string {
  2862. if (!this.shouldShowSearchLocation() || !this.selectedAccount) {
  2863. return ''
  2864. }
  2865. return this.webdavManager.getSongSearchLocationLabel(this.selectedAccount, song)
  2866. }
  2867. private getFolderSearchLocation(folder: FileInfo): string {
  2868. if (!this.shouldShowSearchLocation() || !this.selectedAccount) {
  2869. return ''
  2870. }
  2871. return this.webdavManager.getFolderSearchLocationLabel(this.selectedAccount, folder)
  2872. }
  2873. private async applyGlobalSearch(keyword: string, ticket: number): Promise<void> {
  2874. if (!this.selectedAccount) {
  2875. if (ticket === this.searchTicket) {
  2876. this.isSearchLoading = false
  2877. this.filteredList = []
  2878. this.filteredFolderList = []
  2879. this.refreshDisplaySongs([])
  2880. }
  2881. return
  2882. }
  2883. try {
  2884. const result: RemoteDriveGlobalSearchResult = await this.webdavManager.searchGlobalIndex(this.selectedAccount, keyword);
  2885. if (ticket !== this.searchTicket) {
  2886. return
  2887. }
  2888. this.filteredList = this.sortSongsForType([...result.songs], this.sortType)
  2889. this.filteredFolderList = this.sortFoldersForType([...result.folders], this.sortType)
  2890. this.isSearchLoading = result.isIndexing
  2891. this.refreshDisplaySongs(this.filteredList)
  2892. this.syncSelectionAfterRefresh()
  2893. } catch (error) {
  2894. if (ticket !== this.searchTicket) {
  2895. return
  2896. }
  2897. this.isSearchLoading = false
  2898. this.filteredList = []
  2899. this.filteredFolderList = []
  2900. this.refreshDisplaySongs([])
  2901. this.syncSelectionAfterRefresh()
  2902. ToastUtil.showToast(`搜索失败: ${(error as Error).message}`)
  2903. }
  2904. }
  2905. private onSearchInput(value: string) {
  2906. const keyword = value.trim();
  2907. this.searchText = keyword;
  2908. const ticket = ++this.searchTicket;
  2909. if (keyword.length === 0) {
  2910. this.loadSearchHistory();
  2911. this.restoreCurrentDirectorySearchView();
  2912. return;
  2913. }
  2914. if (!this.isSearchMode) {
  2915. this.isSearchMode = true;
  2916. }
  2917. this.isSearchLoading = true;
  2918. void this.applyGlobalSearch(keyword, ticket);
  2919. }
  2920. @Builder
  2921. private SearchHistoryView(): void {
  2922. Column({ space: 10 }) {
  2923. Row() {
  2924. Text('搜索历史')
  2925. .fontSize(14)
  2926. .fontWeight(FontWeight.Medium)
  2927. .fontColor($r('app.color.text_color'))
  2928. Blank()
  2929. Button('清空')
  2930. .fontSize(12)
  2931. .fontColor(this.themeColor)
  2932. .backgroundColor(Color.Transparent)
  2933. .padding({ left: 8, right: 8, top: 4, bottom: 4 })
  2934. .onClick(() => {
  2935. SearchHistoryUtil.clear(this.searchHistoryScope);
  2936. this.searchHistoryItems = [];
  2937. })
  2938. }
  2939. .width('100%')
  2940. Flex({ wrap: FlexWrap.Wrap }) {
  2941. ForEach(this.searchHistoryItems, (keyword: string) => {
  2942. Button(keyword)
  2943. .fontSize(12)
  2944. .fontColor($r('app.color.text_color'))
  2945. .backgroundColor(this.isDarkMode ? '#222222' : '#F2F3F5')
  2946. .borderRadius(16)
  2947. .margin({ right: 8, bottom: 8 })
  2948. .padding({ left: 12, right: 12, top: 8, bottom: 8 })
  2949. .onClick(() => {
  2950. this.applySearchHistory(keyword);
  2951. })
  2952. })
  2953. }
  2954. .width('100%')
  2955. }
  2956. .width('100%')
  2957. .padding({ left: 20, right: 20, top: this.getSearchLoadingTopOffset(), bottom: 10 })
  2958. }
  2959. private openSongPropertySheet(song: VideoItem): void {
  2960. this.songForPropertySheet = song;
  2961. this.isShowSongPropertySheet = true;
  2962. }
  2963. private getSongPropertyValue(value: string | number | undefined): string {
  2964. if (value === undefined) {
  2965. return '--';
  2966. }
  2967. if (typeof value === 'number') {
  2968. return `${value}`;
  2969. }
  2970. if (StrUtil.isEmpty(value)) {
  2971. return '--';
  2972. }
  2973. return value;
  2974. }
  2975. private getSongTypeLabel(song: VideoItem | undefined): string {
  2976. if (!song) {
  2977. return '--';
  2978. }
  2979. if (song.type === CommonConstants.TYPE_LOCAL) {
  2980. return '本地';
  2981. }
  2982. if (song.type === CommonConstants.TYPE_WEBDAV) {
  2983. return 'WebDAV';
  2984. }
  2985. if (song.type === CommonConstants.TYPE_JELLYFIN) {
  2986. return 'Jellyfin';
  2987. }
  2988. if (song.type === CommonConstants.TYPE_EMBY) {
  2989. return 'Emby';
  2990. }
  2991. if (song.type === CommonConstants.TYPE_AUDIOSTATION) {
  2992. return 'AudioStation';
  2993. }
  2994. if (song.type === CommonConstants.TYPE_BAIDU) {
  2995. return '百度网盘';
  2996. }
  2997. if (song.type === CommonConstants.TYPE_NAVIDROME) {
  2998. return 'Navidrome';
  2999. }
  3000. if (song.type === CommonConstants.TYPE_PLEX) {
  3001. return 'Plex';
  3002. }
  3003. return `${song.type}`;
  3004. }
  3005. @Builder
  3006. private SongPropertyRow(label: string, value: string) {
  3007. Column({ space: 4 }) {
  3008. Text(label)
  3009. .fontSize(12)
  3010. .fontColor($r('app.color.index_tab_font_color'))
  3011. .opacity(0.7)
  3012. Text(value)
  3013. .fontSize(14)
  3014. .fontColor($r('app.color.text_color'))
  3015. .maxLines(6)
  3016. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3017. }
  3018. .width('100%')
  3019. .alignItems(HorizontalAlign.Start)
  3020. .padding({ left: 2, right: 2, top: 4, bottom: 4 })
  3021. }
  3022. @Builder
  3023. private SongPropertyCoverRow(label: string, coverPath: string | undefined) {
  3024. Column({ space: 8 }) {
  3025. Text(label)
  3026. .fontSize(12)
  3027. .fontColor($r('app.color.index_tab_font_color'))
  3028. .opacity(0.7)
  3029. if (StrUtil.isNotEmpty(coverPath)) {
  3030. Image(coverPath as string)
  3031. .width(136)
  3032. .height(136)
  3033. .borderRadius(10)
  3034. .sourceSize({ width: 136, height: 136 })
  3035. .objectFit(ImageFit.Cover)
  3036. .alt($r('app.media.alt'))
  3037. .fillColor(this.themeColor)
  3038. } else {
  3039. Text('--')
  3040. .fontSize(14)
  3041. .fontColor($r('app.color.text_color'))
  3042. }
  3043. }
  3044. .width('100%')
  3045. .alignItems(HorizontalAlign.Start)
  3046. .padding({ left: 2, right: 2, top: 4, bottom: 4 })
  3047. }
  3048. @Builder
  3049. private SongPropertyLyricRow(label: string, lyricContent: string | undefined) {
  3050. Column({ space: 4 }) {
  3051. Text(label)
  3052. .fontSize(12)
  3053. .fontColor($r('app.color.index_tab_font_color'))
  3054. .opacity(0.7)
  3055. Text(this.getSongPropertyValue(lyricContent))
  3056. .fontSize(14)
  3057. .fontColor($r('app.color.text_color'))
  3058. .textAlign(TextAlign.Start)
  3059. }
  3060. .width('100%')
  3061. .alignItems(HorizontalAlign.Start)
  3062. .padding({ left: 2, right: 2, top: 4, bottom: 4 })
  3063. }
  3064. @Builder
  3065. private SongPropertySheetBuilder() {
  3066. if (this.songForPropertySheet) {
  3067. Scroll() {
  3068. Column({ space: 10 }) {
  3069. this.SongPropertyCoverRow('音乐封面', this.songForPropertySheet?.pixelMapPath)
  3070. this.SongPropertyRow('歌曲名称', this.getSongPropertyValue(this.songForPropertySheet?.name))
  3071. this.SongPropertyRow('文件名称', this.getSongPropertyValue(this.songForPropertySheet?.fileName))
  3072. this.SongPropertyRow('艺术家', this.getSongPropertyValue(this.songForPropertySheet?.artist))
  3073. this.SongPropertyRow('专辑', this.getSongPropertyValue(this.songForPropertySheet?.album))
  3074. this.SongPropertyRow('时长', this.getSongPropertyValue(this.songForPropertySheet?.duration))
  3075. this.SongPropertyRow('文件大小', this.getSongPropertyValue(this.songForPropertySheet?.size))
  3076. this.SongPropertyRow('比特率', this.getSongPropertyValue(this.songForPropertySheet?.bit_rate))
  3077. this.SongPropertyRow('采样率', this.getSongPropertyValue(this.songForPropertySheet?.sampleRate))
  3078. this.SongPropertyLyricRow('内嵌歌词', this.songForPropertySheet?.lyricContent)
  3079. this.SongPropertyRow('创建时间', this.getSongPropertyValue(this.songForPropertySheet?.cTime))
  3080. this.SongPropertyRow('来源类型', this.getSongTypeLabel(this.songForPropertySheet))
  3081. this.SongPropertyRow('文件路径', this.getSongPropertyValue(this.songForPropertySheet?.filePath))
  3082. }
  3083. .width('100%')
  3084. .padding({ left: 12, right: 12, top: 8, bottom: 12 })
  3085. }
  3086. .width('100%')
  3087. .height('100%')
  3088. .scrollBar(BarState.Auto)
  3089. } else {
  3090. Column() {
  3091. Text('暂无歌曲属性信息')
  3092. .fontSize(14)
  3093. .fontColor($r('app.color.text_color'))
  3094. .opacity(0.65)
  3095. }
  3096. .width('100%')
  3097. .height('100%')
  3098. .justifyContent(FlexAlign.Center)
  3099. .alignItems(HorizontalAlign.Center)
  3100. }
  3101. }
  3102. build() {
  3103. Stack() {
  3104. if (this.accounts.length === 0) {
  3105. this.buildEmptyView();
  3106. } else {
  3107. this.buildContentView();
  3108. }
  3109. // 顶部区域:标题栏在上,面包屑在下
  3110. Column() {
  3111. this.topTitleBar()
  3112. this.breaker()
  3113. }
  3114. .width('100%')
  3115. .visibility(this.isShowTitleBar?Visibility.Visible:
  3116. this.autoHideTitle?Visibility.None:Visibility.Visible)
  3117. .animation({
  3118. duration: 500,
  3119. curve: Curve.Friction // 可选动画曲线
  3120. })
  3121. .position({ top: 0, left: 0 })
  3122. // .backgroundColor($r('app.color.start_window_background'))
  3123. // 多选操作条,位于播放条之上(仅在多选模式下显示)
  3124. if (this.isMultiSelect || this.isDeletingSelection) {
  3125. this.buildSelectionOverlay()
  3126. }
  3127. }
  3128. .width('100%')
  3129. .height('100%')
  3130. .alignContent(Alignment.Bottom)
  3131. .backgroundImage(this.isCustomizeBg?this.customizeBgPath:$r('app.color.start_window_background'))
  3132. .backgroundImageSize(this.isLandscape?{width:'100%'}:{ height: '100%'})
  3133. .backgroundImagePosition(Alignment.Center)
  3134. .backdropBlur(this.blurValue)
  3135. .backgroundBrightness({rate:this.isCustomizeBg?0.1:0,lightUpDegree:this.bgBrightness})
  3136. .bindSheet($$this.isShowSongPropertySheet, this.SongPropertySheetBuilder(), {
  3137. height: '99%',
  3138. dragBar: true,
  3139. showClose: true,
  3140. preferType: SheetType.CENTER,
  3141. blurStyle: BlurStyle.Thin,
  3142. title: { title: '歌曲属性' }
  3143. })
  3144. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  3145. }
  3146. private getSearchLoadingTopOffset(): number {
  3147. return this.topSafeHeight + (this.currentDirectoryPath !== '' ? 100 : 56)
  3148. }
  3149. private shouldShowSearchIndexingTip(): boolean {
  3150. return this.isSearchMode && this.searchText.length > 0 && this.isSearchLoading
  3151. }
  3152. private getListContentStartOffset(): number {
  3153. if (this.shouldShowSearchIndexingTip()) {
  3154. return 8
  3155. }
  3156. if(this.isLoading)
  3157. return 8
  3158. return this.topSafeHeight + 85
  3159. }
  3160. @Builder
  3161. breaker() {
  3162. // 面包屑导航
  3163. Column({ space: 8 }) {
  3164. // 面包屑导航
  3165. if (this.currentDirectoryPath !== '') {
  3166. Row({ space: 8 }) {
  3167. Button({ type: ButtonType.Circle }) {
  3168. Image(this.selectedAccount.coverPath?
  3169. this.selectedAccount.coverPath:getCloudDiskIcon(this.selectedAccount.webType))
  3170. .width(18)
  3171. .height(18)
  3172. .alt($r('app.media.cloudDisk'))
  3173. }
  3174. .width(24)
  3175. .height(24)
  3176. .backgroundColor('#EFEFEF')
  3177. .margin({left:5})
  3178. .onClick(() => this.goBack())
  3179. .bindContentCover($$this.isShowDownloadCenter, this.DownloadCenterBuilder(),
  3180. {
  3181. modalTransition:ModalTransition.DEFAULT,
  3182. onWillDisappear: () => {
  3183. this.isShowDownloadCenter = false
  3184. },
  3185. })
  3186. Row({ space: 4 }) {
  3187. ForEach(this.breadcrumbs, (crumb: BreadcrumbItem, index: number) => {
  3188. Row() {
  3189. Text(crumb.label)
  3190. .fontSize(15)
  3191. .fontColor($r('app.color.text_color'))
  3192. .maxLines(1)
  3193. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3194. }
  3195. .onClick(() => {
  3196. this.navigateToBreadcrumb(crumb);
  3197. })
  3198. // 添加分隔符(除了最后一个元素)
  3199. if (index < this.breadcrumbs.length - 1) {
  3200. Text('/')
  3201. .fontSize(15)
  3202. .fontColor($r('app.color.index_tab_font_color'))
  3203. .opacity(0.6)
  3204. }
  3205. })
  3206. }
  3207. .layoutWeight(1)
  3208. }
  3209. .width('100%')
  3210. .padding({ left: 4, right: 4 })
  3211. }
  3212. }
  3213. .width('100%')
  3214. .padding({ left: 12, right: 12, top: 8, bottom: 5 })
  3215. }
  3216. // 空状态视图
  3217. @Builder
  3218. buildEmptyView() {
  3219. Column({ space: 20 }) {
  3220. Image($r('app.media.cloudDisk'))
  3221. .width(120)
  3222. .height(120)
  3223. .opacity(0.3)
  3224. Text(`暂无${getRemoteDriveAccountLabel()}`)
  3225. .fontSize(16)
  3226. .fontColor($r('app.color.index_tab_font_color'))
  3227. .opacity(0.6)
  3228. }
  3229. .justifyContent(FlexAlign.Center)
  3230. .width('100%')
  3231. .height('100%')
  3232. }
  3233. // 内容视图
  3234. @Builder
  3235. buildContentView() {
  3236. Column() {
  3237. // 加载状态
  3238. Row() {
  3239. LoadingProgress()
  3240. .width(30)
  3241. .height(30)
  3242. .color(this.themeColor)
  3243. Text('加载中...')
  3244. .fontSize(14)
  3245. .fontColor($r('app.color.index_tab_font_color'))
  3246. .margin({ left: 12 })
  3247. }
  3248. .padding({top: this.topSafeHeight + 90, left: 20, right: 20})
  3249. .visibility(this.isLoading?Visibility.Visible:Visibility.None)
  3250. .opacity(this.isLoading ? 1 : 0)
  3251. .animation({
  3252. duration: 800,
  3253. curve: Curve.Smooth // 可选动画曲线
  3254. })
  3255. if (this.shouldShowSearchIndexingTip()) {
  3256. Row({ space: 8 }) {
  3257. LoadingProgress()
  3258. .width(18)
  3259. .height(18)
  3260. .color(this.themeColor)
  3261. Text('正在构建全局索引,结果会持续补全')
  3262. .fontSize(12)
  3263. .fontColor($r('app.color.index_tab_font_color'))
  3264. .opacity(0.65)
  3265. }
  3266. .alignSelf(ItemAlign.Center)
  3267. .padding({ left: 20, right: 20, top: this.getSearchLoadingTopOffset() })
  3268. }
  3269. // 文件列表(文件夹 + 歌曲)
  3270. if ((this.isSearchMode ? this.filteredFolderList.length : this.visibleFoldersState.length) > 0 ||
  3271. this.dataSource.totalCount() > 0) {
  3272. List({ scroller: this.listScroller ,space: 0 }) {
  3273. // 显示文件夹 - 只显示当前目录下的直接子文件夹
  3274. ForEach(this.isSearchMode ? this.filteredFolderList : this.visibleFoldersState, (folder: FileInfo) => {
  3275. if (this.isMultiSelect) {
  3276. ListItem() {
  3277. this.buildFolderItem(folder)
  3278. }
  3279. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  3280. } else {
  3281. ListItem() {
  3282. this.buildFolderItem(folder)
  3283. }
  3284. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  3285. .swipeAction({
  3286. end: this.buildSwipeDeleteAction(() => {
  3287. this.confirmDeleteSingleFolder(folder)
  3288. }),
  3289. edgeEffect: SwipeEdgeEffect.None
  3290. })
  3291. }
  3292. }, (folder: FileInfo) => folder.name+folder.fileName)
  3293. // 显示歌曲
  3294. LazyForEach(this.dataSource, (song: VideoItem, index: number) => {
  3295. if (this.isMultiSelect) {
  3296. ListItem() {
  3297. this.buildSongItem(song, index)
  3298. }
  3299. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  3300. } else {
  3301. ListItem() {
  3302. this.buildSongItem(song, index)
  3303. }
  3304. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  3305. .swipeAction({
  3306. end: this.buildSwipeDeleteAction(() => {
  3307. this.confirmDeleteSingleSong(song)
  3308. }),
  3309. edgeEffect: SwipeEdgeEffect.None
  3310. })
  3311. }
  3312. }, (item: VideoItem, index: number) => item.filePath + '_' + index+this.listRefreshKey)
  3313. }
  3314. .scrollBar(BarState.Off)
  3315. .onScrollFrameBegin((offset: number) => {
  3316. // 获取当前滚动偏移量
  3317. if(this.autoHideTitle){
  3318. const currentOffsetY = this.listScroller.currentOffset().yOffset;
  3319. // 判断滚动方向
  3320. if (currentOffsetY > this.prevOffsetY) {
  3321. this.isShowTitleBar = false
  3322. } else if (currentOffsetY < this.prevOffsetY) {
  3323. this.isShowTitleBar = true
  3324. }
  3325. // 更新前一次偏移量
  3326. this.prevOffsetY = currentOffsetY;
  3327. }
  3328. return { offsetRemain: offset };
  3329. })
  3330. .contentStartOffset(this.getListContentStartOffset())
  3331. .contentEndOffset(this.bottomSafeHeight+70)
  3332. .layoutWeight(1)
  3333. .margin({ top: 4 })
  3334. } else if (this.isSearchMode && this.searchText.length === 0 && this.searchHistoryItems.length > 0) {
  3335. this.SearchHistoryView()
  3336. } else if (!this.isLoading) {
  3337. Column() {
  3338. Text(this.isSearchMode && this.searchText.length > 0 ? '没有找到匹配结果' : '暂无内容')
  3339. .fontSize(14)
  3340. .fontColor($r('app.color.index_tab_font_color'))
  3341. .opacity(0.6)
  3342. Text(this.isSearchMode && this.searchText.length > 0 ?
  3343. (this.isSearchLoading ? '正在继续扫描更多目录...' : '可以换个关键词再试') :
  3344. '点击"左侧菜单"网盘加载')
  3345. .fontSize(12)
  3346. .fontColor($r('app.color.index_tab_font_color'))
  3347. .opacity(0.4)
  3348. .margin({ top: 8 })
  3349. }
  3350. .justifyContent(FlexAlign.Center)
  3351. .layoutWeight(1)
  3352. }
  3353. }
  3354. .layoutWeight(1)
  3355. }
  3356. @Builder
  3357. private buildMultiSelectBar() {
  3358. Row({ space: 12 }) {
  3359. Button(this.isAllSelected ? '反选' : '全选', { type: ButtonType.Circle, stateEffect: true })
  3360. .width(55)
  3361. .height(55)
  3362. .fontSize(13)
  3363. .backgroundColor(this.themeColor)
  3364. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  3365. .onClick(() => this.toggleSelectAll())
  3366. Button('删除', { type: ButtonType.Circle, stateEffect: true })
  3367. .width(55)
  3368. .height(55)
  3369. .fontSize(13)
  3370. .backgroundColor(this.themeColor)
  3371. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  3372. .enabled(this.getSelectedEntryCount() > 0 && !this.isDeletingSelection)
  3373. .onClick(() => this.confirmDeleteSelected())
  3374. Button('+歌单', { type: ButtonType.Circle, stateEffect: true })
  3375. .width(55)
  3376. .height(55)
  3377. .fontSize(13)
  3378. .backgroundColor(this.themeColor)
  3379. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  3380. .enabled(this.canOperateOnSelectedSongs())
  3381. .onClick(() => {
  3382. void this.openSelectedSongsAddToPlaylistDialog();
  3383. })
  3384. Button('下载', { type: ButtonType.Circle, stateEffect: true })
  3385. .width(55)
  3386. .height(55)
  3387. .fontSize(13)
  3388. .backgroundColor(this.themeColor)
  3389. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  3390. .enabled(this.canOperateOnSelectedSongs())
  3391. .onClick(() => {
  3392. void this.enqueueSelectedSongsDownload();
  3393. })
  3394. Button('取消', { type: ButtonType.Circle, stateEffect: true })
  3395. .width(55)
  3396. .height(55)
  3397. .fontSize(13)
  3398. .backgroundColor(this.themeColor)
  3399. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  3400. .onClick(() => this.exitMultiSelect())
  3401. }
  3402. .width('100%')
  3403. .padding({ bottom: 10, top: 12 })
  3404. .justifyContent(FlexAlign.Center)
  3405. .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
  3406. .opacity(this.isMultiSelect ? 1 : 0)
  3407. .animation({ duration: 300, curve: 'ease-in-out' })
  3408. }
  3409. @Builder
  3410. private buildSelectionOverlay() {
  3411. Column() {
  3412. Row({ space: 10 }) {
  3413. LoadingProgress()
  3414. .width(22)
  3415. .height(22)
  3416. .color(this.themeColor)
  3417. Text('正在删除...')
  3418. .fontSize(14)
  3419. .fontColor($r('app.color.index_tab_font_color'))
  3420. }
  3421. .padding({ top: 12, bottom: 6 })
  3422. .justifyContent(FlexAlign.Center)
  3423. .visibility(this.isDeletingSelection ? Visibility.Visible : Visibility.None)
  3424. this.buildMultiSelectBar()
  3425. }
  3426. .width('100%')
  3427. .padding({ left: 12, right: 12, bottom: this.bottomSafeHeight })
  3428. }
  3429. // 文件夹列表项
  3430. @Builder
  3431. buildFolderItem(folder: FileInfo) {
  3432. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  3433. Row({ space: 2 }) {
  3434. SymbolGlyph($r('sys.symbol.folder'))
  3435. .fontSize(48)
  3436. .fontColor([this.themeColor])
  3437. .alignSelf(ItemAlign.Center)
  3438. .margin({ left: 8, right: 6 })
  3439. // 文件夹信息
  3440. Column({ space: 4 }) {
  3441. Text(decodeUrlEncodedString(folder.fileName.replace(/\/+$/, '')))
  3442. .fontSize(15)
  3443. .fontColor($r('app.color.index_tab_font_color'))
  3444. .maxLines(1)
  3445. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3446. Text(this.getFolderSearchLocation(folder))
  3447. .fontSize(12)
  3448. .fontColor($r('app.color.index_tab_font_color'))
  3449. .opacity(0.48)
  3450. .maxLines(1)
  3451. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3452. .visibility(this.getFolderSearchLocation(folder).length > 0 ? Visibility.Visible : Visibility.None)
  3453. Text('文件夹')
  3454. .fontSize(13)
  3455. .fontColor($r('app.color.index_tab_font_color'))
  3456. .opacity(0.6)
  3457. }
  3458. .alignItems(HorizontalAlign.Start)
  3459. .layoutWeight(1)
  3460. Checkbox({ name: `folder_checkbox_${this.resolveFolderSelectionKey(folder)}` })
  3461. .select(this.isFolderSelected(folder))
  3462. .selectedColor(this.themeColor)
  3463. .opacity(this.isMultiSelect ? 1 : 0)
  3464. .animation({
  3465. duration: 300,
  3466. curve: 'Smooth'
  3467. })
  3468. .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
  3469. .onChange((checked: boolean) => this.handleFolderCheckboxSelection(folder, checked))
  3470. .margin({ left: 10, top: 8, bottom: 8, right: 12 })
  3471. .width(22)
  3472. .height(22)
  3473. }
  3474. }
  3475. .reuseId('dir_item')
  3476. .width('100%')
  3477. .padding(12)
  3478. .backgroundColor(Color.Transparent)
  3479. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  3480. .onClick(() => {
  3481. if (this.ignoreTapAfterExitMultiSelect) {
  3482. return;
  3483. }
  3484. if (this.isMultiSelect) {
  3485. this.toggleFolderSelection(folder);
  3486. return;
  3487. }
  3488. if (this.isSearchMode && this.searchText.length > 0) {
  3489. this.isSearchMode = false
  3490. this.searchController.stopEditing()
  3491. this.onSearchInput('')
  3492. }
  3493. this.enterFolder(folder);
  3494. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  3495. })
  3496. .bindContextMenu(this.FolderLongPressMenuBuilder(folder), ResponseType.LongPress,
  3497. {
  3498. preview: MenuPreviewMode.IMAGE
  3499. })
  3500. .bindContextMenu(this.FolderLongPressMenuBuilder(folder), ResponseType.RightClick,
  3501. {
  3502. preview: MenuPreviewMode.IMAGE
  3503. })
  3504. }
  3505. // 歌曲列表项
  3506. @Builder
  3507. buildSongItem(song: VideoItem, index: number) {
  3508. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  3509. Row({ space: 12 }) {
  3510. // 序号
  3511. // 歌曲封面
  3512. Image(song.pixelMapPath ? song.pixelMapPath : $r('app.media.alt'))
  3513. .width(52)
  3514. .height(52)
  3515. .borderRadius(9)
  3516. .sourceSize({width:38, height:38})
  3517. .alt($r('app.media.alt'))
  3518. .fillColor(this.themeColor)
  3519. .objectFit(ImageFit.Cover)
  3520. .shadow({
  3521. radius: StrUtil.isEmpty(song.pixelMapPath) ?6:14,
  3522. type: ShadowType.BLUR,
  3523. color: 'on_primary'
  3524. })
  3525. .margin({ left: 8 })
  3526. .onClick(() => {
  3527. if (this.isMultiSelect) {
  3528. this.toggleSongSelection(song);
  3529. } else {
  3530. this.playSong(song, index, true);
  3531. }
  3532. })
  3533. // 歌曲信息
  3534. Column({ space: 4 }) {
  3535. Text(this.isShowFileName?song.fileName :song.name)
  3536. .fontSize(15)
  3537. .textOverflow({ overflow: this.isLongNameRoLL?TextOverflow.MARQUEE:TextOverflow.Ellipsis })//超长滚动
  3538. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:$r('app.color.index_tab_font_color'))
  3539. .maxLines(1)
  3540. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3541. Row(){
  3542. if (StrUtil.isNotEmpty(song.md5Str)) {
  3543. Text(this.buildSongQualityLabel(song))
  3544. .fontSize(10)
  3545. .fontColor($r('app.color.index_tab_font_color'))
  3546. .fontWeight(500)
  3547. .padding({ top: 2, right: 5, left: 5, bottom: 2 })
  3548. .borderRadius(6)
  3549. .margin({right:3})
  3550. .backgroundColor('#FFC107')
  3551. .opacity(0.92)
  3552. }
  3553. Text((song.artist ?? '') + " ")
  3554. .fontSize(13)
  3555. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:$r('app.color.index_tab_font_color'))
  3556. .opacity(0.6)
  3557. .maxLines(1)
  3558. .visibility(song.artist?Visibility.Visible:Visibility.None)
  3559. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3560. Text(this.buildSongMetaLine(song))
  3561. .fontSize(13)
  3562. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:
  3563. $r('app.color.index_tab_font_color'))
  3564. .opacity(0.6)
  3565. .maxLines(1)
  3566. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3567. }
  3568. .width('90%')
  3569. Text(this.getSongSearchLocation(song))
  3570. .fontSize(12)
  3571. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:$r('app.color.index_tab_font_color'))
  3572. .opacity(0.42)
  3573. .maxLines(1)
  3574. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3575. .visibility(this.getSongSearchLocation(song).length > 0 ? Visibility.Visible : Visibility.None)
  3576. }
  3577. .alignItems(HorizontalAlign.Start)
  3578. .layoutWeight(1)
  3579. .padding({ right: 20 })
  3580. Column() {
  3581. Checkbox({ name: 'checkbox_' + index })
  3582. .select(this.isSongSelected(song))
  3583. .selectedColor(this.themeColor)
  3584. .opacity(this.isMultiSelect ? 1 : 0)
  3585. .animation({
  3586. duration: 300,
  3587. curve: 'Smooth'
  3588. })
  3589. .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
  3590. .onChange((checked: boolean) => this.handleCheckboxSelection(song, checked))
  3591. .margin({ left: 10, top: 8, bottom: 8, right: 12 })
  3592. .width(22)
  3593. .height(22)
  3594. PlayingIndicator({
  3595. isActive: this.currentSong?.filePath == song.filePath && !this.isMultiSelect,
  3596. indicatorSize: 18,
  3597. marginRight: 12,
  3598. marginTop: 8,
  3599. marginBottom: 8
  3600. })
  3601. }
  3602. }
  3603. }
  3604. .width('100%')
  3605. .padding(12)
  3606. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  3607. .backgroundColor(Color.Transparent)
  3608. .onClick(() => {
  3609. if (this.ignoreTapAfterExitMultiSelect) {
  3610. return;
  3611. }
  3612. if (this.isMultiSelect) {
  3613. this.toggleSongSelection(song);
  3614. } else {
  3615. this.playSong(song, index);
  3616. }
  3617. })
  3618. // .gesture(LongPressGesture().onAction(() => {
  3619. // this.enterMultiSelect(song);
  3620. // }))
  3621. .bindContextMenu(this.LongPressMenuBuilder(song), ResponseType.LongPress,
  3622. {
  3623. preview: MenuPreviewMode.IMAGE
  3624. })
  3625. .bindContextMenu(this.LongPressMenuBuilder(song), ResponseType.RightClick,
  3626. {
  3627. preview: MenuPreviewMode.IMAGE
  3628. })
  3629. }
  3630. @Builder
  3631. LongPressMenuBuilder(song: VideoItem) {
  3632. Scroll() {
  3633. Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
  3634. Menu() {
  3635. MenuItem({
  3636. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.forward_end_fill')),
  3637. content: '下一首播放'
  3638. })
  3639. .onClick(() => {
  3640. this.addSongToNextPlay(song);
  3641. })
  3642. MenuItem({
  3643. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.music_note_list')),
  3644. content: '添加到歌单'
  3645. })
  3646. .onClick(async () => {
  3647. await this.openSongAddToPlaylistDialog(song);
  3648. })
  3649. //多选
  3650. MenuItem({
  3651. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.checkmark_square_on_square')),
  3652. content: $r('app.string.select_all')
  3653. })
  3654. .onClick(() => {
  3655. this.enterMultiSelect(song);
  3656. })
  3657. //下载
  3658. MenuItem({
  3659. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.download')),
  3660. content: '下载'
  3661. })
  3662. .onClick(async () => {
  3663. await this.enqueueSongDownload(song);
  3664. })
  3665. MenuItem({
  3666. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.rename')),
  3667. content: '重命名'
  3668. })
  3669. .visibility(this.canRenameRemoteSong(song) ? Visibility.Visible : Visibility.None)
  3670. .onClick(() => {
  3671. this.showRenameSongDialog(song);
  3672. })
  3673. //属性
  3674. MenuItem({
  3675. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.music_note_list')),
  3676. content: '属性'
  3677. })
  3678. .onClick(() => {
  3679. this.openSongPropertySheet(song);
  3680. })
  3681. //删除
  3682. MenuItem({
  3683. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trash')),
  3684. content: $r('app.string.delete')
  3685. })
  3686. .onClick(() => {
  3687. this.confirmDeleteSingleSong(song);
  3688. })
  3689. }
  3690. .font({ size: 14, weight: FontWeight.Normal })
  3691. }
  3692. .width(180)
  3693. }
  3694. .height('auto')
  3695. .enableScrollInteraction(true)
  3696. .scrollBar(BarState.Off)
  3697. }
  3698. @Builder
  3699. FolderLongPressMenuBuilder(folder: FileInfo) {
  3700. Scroll() {
  3701. Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
  3702. Menu() {
  3703. MenuItem({
  3704. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.checkmark_square_on_square')),
  3705. content: $r('app.string.select_all')
  3706. })
  3707. .onClick(() => {
  3708. this.enterFolderMultiSelect(folder);
  3709. })
  3710. MenuItem({
  3711. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.rename')),
  3712. content: '重命名'
  3713. })
  3714. .visibility(this.canRenameRemoteFolder() ? Visibility.Visible : Visibility.None)
  3715. .onClick(() => {
  3716. this.showRenameFolderDialog(folder);
  3717. })
  3718. MenuItem({
  3719. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trash')),
  3720. content: $r('app.string.delete')
  3721. })
  3722. .onClick(() => {
  3723. this.confirmDeleteSingleFolder(folder);
  3724. })
  3725. }
  3726. .font({ size: 14, weight: FontWeight.Normal })
  3727. }
  3728. .width(180)
  3729. }
  3730. .height('auto')
  3731. .enableScrollInteraction(true)
  3732. .scrollBar(BarState.Off)
  3733. }
  3734. // 对话框控制器
  3735. private createFolderDialogController: CustomDialogController | null = null;
  3736. private renameSongDialogController: CustomDialogController | null = null;
  3737. /**
  3738. * 显示创建文件夹对话框
  3739. */
  3740. private showCreateFolderDialog(): void {
  3741. if (!this.selectedAccount) {
  3742. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  3743. return;
  3744. }
  3745. // 获取当前主题色
  3746. const currentThemeColor = this.themeColor;
  3747. // 创建自定义对话框
  3748. this.createFolderDialogController = new CustomDialogController({
  3749. builder: CreateFolderDialog({
  3750. onConfirm: (folderName: string) => {
  3751. if (folderName.trim().length > 0) {
  3752. void this.createFolder(folderName.trim());
  3753. } else {
  3754. this.getUIContext().getPromptAction().showToast({ message: '文件夹名称不能为空' });
  3755. }
  3756. },
  3757. onCancel: () => {
  3758. // 用户取消创建
  3759. },
  3760. initialName: '',
  3761. themeColor: currentThemeColor
  3762. }),
  3763. autoCancel: true,
  3764. alignment: DialogAlignment.Center,
  3765. customStyle: false
  3766. });
  3767. this.createFolderDialogController.open();
  3768. }
  3769. /**
  3770. * 上传成功后刷新文件列表
  3771. */
  3772. private async refreshFileListAfterUpload(): Promise<void> {
  3773. if (!this.selectedAccount) {
  3774. Logger.warn(TAG, '刷新失败:未选择账户');
  3775. return;
  3776. }
  3777. try {
  3778. Logger.info(TAG, 'heanup 开始刷新上传后的文件列表');
  3779. // 显示加载状态
  3780. this.isLoading = true;
  3781. // 重新加载当前账户的文件信息
  3782. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount,
  3783. this.currentDirectoryPath || this.selectedAccount.filepath || '/');
  3784. Logger.info(TAG, 'heanup 文件列表刷新完成');
  3785. } catch (error) {
  3786. const err = error as Error;
  3787. Logger.error(TAG, `heanup 刷新文件列表失败: ${err.message}`);
  3788. // 显示错误提示
  3789. this.getUIContext().getPromptAction().showToast({
  3790. message: `刷新失败: ${err.message}`
  3791. });
  3792. } finally {
  3793. this.isLoading = false;
  3794. }
  3795. }
  3796. /**
  3797. * 创建文件夹的实际方法
  3798. * @param folderName 文件夹名称
  3799. */
  3800. private async createFolder(folderName: string): Promise<void> {
  3801. if (!this.selectedAccount) {
  3802. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  3803. return;
  3804. }
  3805. if (folderName.length === 0) {
  3806. this.getUIContext().getPromptAction().showToast({ message: '文件夹名称不能为空' });
  3807. return;
  3808. }
  3809. try {
  3810. Logger.info(TAG, `heanup 开始创建文件夹: ${folderName}`);
  3811. // 显示加载状态
  3812. this.isLoading = true;
  3813. if(this.selectedAccount.webType==RemoteDriveType.Baidu){
  3814. // 调用RemoteDriveManager的createBaiduFolder方法
  3815. await this.webdavManager.createBaiduFolder(this.selectedAccount, folderName);
  3816. }else if(this.selectedAccount.webType==RemoteDriveType.WebDav){
  3817. // 调用RemoteDriveManager的createWebDavFolder方法
  3818. await this.webdavManager.createWebDavFolder(this.selectedAccount, folderName);
  3819. }else if(this.selectedAccount.webType==RemoteDriveType.Smb){
  3820. // 调用RemoteDriveManager的createSMBFolder方法
  3821. await this.webdavManager.createSMBFolder(this.selectedAccount, folderName);
  3822. }else if(this.selectedAccount.webType==RemoteDriveType.Ftp){
  3823. // 调用RemoteDriveManager的createFTPFolder方法
  3824. this.getUIContext().getPromptAction().showToast({ message: 'FTP目前不支持创建文件夹' });
  3825. }else{
  3826. this.getUIContext().getPromptAction().showToast({ message: '当前账户类型不支持创建文件夹' });
  3827. }
  3828. // 创建成功后刷新当前目录
  3829. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount,
  3830. this.currentDirectoryPath || this.selectedAccount.filepath || '/');
  3831. this.getUIContext().getPromptAction().showToast({
  3832. message: `文件夹 "${folderName}" 创建成功`
  3833. });
  3834. Logger.info(TAG, `heanup 文件夹创建成功`);
  3835. } catch (error) {
  3836. const err = error as Error;
  3837. Logger.error(TAG, `heanup 创建文件夹失败: ${err.message}`);
  3838. // 显示具体的错误信息
  3839. this.getUIContext().getPromptAction().showToast({
  3840. message: `创建失败: ${err.message}`
  3841. });
  3842. } finally {
  3843. this.isLoading = false;
  3844. }
  3845. }
  3846. }