WebDavMainPage.ets 116 KB

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