WebDavMainPage.ets 58 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671
  1. import { BreadcrumbItem, 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 { promptAction, SymbolGlyphModifier, router, window } 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 { display } from '@kit.ArkUI';
  11. import { FileInfo } from '../viewmodel/FileInfo';
  12. import { emitter } from '@kit.BasicServicesKit';
  13. import { EventConstants } from '../common/constants/EventConstants';
  14. import { LazyDataSource } from '../common/util/LazyDataSource';
  15. import { ArrayUtil, PreferencesUtil, StrUtil } from '@pura/harmony-utils';
  16. import { DialogHelper } from '@pura/harmony-dialog';
  17. import { ButtonFancyModifier,
  18. MenuModifier,
  19. ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil';
  20. import { getRemoteDriveAccountLabel, getRemoteDriveDisplayLabel, getRemoteDrivePlaylistPrefix } from '../common/util/RemoteDriveLabel';
  21. import { RemoteDriveType } from '../common/enums/RemoteDriveType';
  22. import { Utility } from '../common/util/Utility';
  23. import { SettingPage } from './SettingPage';
  24. import { getCloudDiskIcon } from '../dialog/RemoteDriveAccountDialog';
  25. import { CreateFolderDialog } from '../dialog/CreateFolderDialog';
  26. /**
  27. * 歌单播放事件数据
  28. */
  29. interface PlaylistEventData {
  30. playlistId: string;
  31. playlistName: string;
  32. songCount: number;
  33. startIndex: number;
  34. isJump: boolean;
  35. songFilePaths: string[];
  36. // 移除webDavAuthInfo,现在直接使用videoItems中的webdav_account_id
  37. }
  38. interface WebDavMetadataUpdatePayload {
  39. filePath: string;
  40. pixelMapPath?: string;
  41. name?: string;
  42. artist?: string;
  43. }
  44. const TAG = 'heanup WebDavMainPage';
  45. // WebDAV歌曲数据全局内存存储
  46. let globalWebdavVideoItems: VideoItem[] = [];
  47. let globalWebdavCurrentPlayIndex: number = 0;
  48. // 导出函数供LocalMusic访问
  49. export function getWebdavVideoItems(): VideoItem[] {
  50. return globalWebdavVideoItems;
  51. }
  52. export function getWebdavCurrentPlayIndex(): number {
  53. return globalWebdavCurrentPlayIndex;
  54. }
  55. export function clearWebdavVideoItems(): void {
  56. globalWebdavVideoItems = [];
  57. globalWebdavCurrentPlayIndex = 0;
  58. }
  59. // URL解码函数
  60. function decodeUrlEncodedString(encodedStr: string): string {
  61. try {
  62. return decodeURIComponent(encodedStr);
  63. } catch (error) {
  64. // 如果解码失败,返回原始字符串
  65. return encodedStr;
  66. }
  67. }
  68. @Preview
  69. @Entry
  70. @Component
  71. export struct WebDavMainPage {
  72. @State isShowTitleBar: boolean = true //是否显示分类导航条
  73. private prevOffsetY: number = 0; // 记录上一次的Y轴偏移量
  74. @State autoHideTitle: boolean = true //滚动自动隐藏标题栏
  75. @State isCustomizeBg: boolean = false //自定义背景界面
  76. @StorageProp('isLandscape') isLandscape: boolean = false;
  77. @State blurValue: number = 0 //背景模糊
  78. @State bgBrightness: number = 0 //背景亮度
  79. @State customizeBgPath: string | undefined = '';
  80. private listScroller: ListScroller = new ListScroller()
  81. @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0;
  82. searchController: SearchController = new SearchController()
  83. @StorageProp('animationState') animationState: AnimationStatus= AnimationStatus.Running
  84. @State isNoJumpToHome: boolean = false //网盘播放不跳转首页
  85. @State isSearchMode: boolean = false
  86. @StorageLink('currentSong') currentSong: VideoItem | undefined = undefined;
  87. @StorageProp('topSafeHeight') topSafeHeight: number = 0;
  88. @State webdavManager: RemoteDriveManager = RemoteDriveManager.getInstance();
  89. @State accounts: WebDavAccount[] = [];
  90. @Prop @Watch('onSwitchAccount') selectedAccount: WebDavAccount;
  91. @State songs: VideoItem[] = [];
  92. @State dataSource:LazyDataSource<VideoItem> = new LazyDataSource(this.songs)
  93. @Link mType: number;
  94. @Link offsetX: number;
  95. @Link isShowDrawer: boolean;
  96. @State isLoading: boolean = false;
  97. @State webDavFiles: FileInfo[] = []; // 直接在页面中保存文件列表副本
  98. @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
  99. @StorageProp('isDarkMode') isDarkMode: boolean = false;
  100. @State breadcrumbs:BreadcrumbItem[] = []//面包屑导航
  101. @State visibleFoldersState: FileInfo[] = []; // 改为普通状态变量
  102. @State isShowFileName: boolean = false//是否显示文件名
  103. @State isLongNameRoLL: boolean = true//长歌名滚动
  104. @State sortType: number = 0 //默认排序方式
  105. @State listRefreshKey: number = 0 // 列表刷新标识
  106. @Consume isMultiSelect: boolean
  107. @State selectedSongs: VideoItem[] = []
  108. @State isAllSelected: boolean = false
  109. @State isDeletingSelection: boolean = false
  110. private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
  111. async onSwitchAccount(){
  112. console.log('onecold 切换账户:', this.selectedAccount.name);
  113. this.songs = [];
  114. this.visibleFoldersState = [];
  115. this.updateListData(this.songs)
  116. this.exitMultiSelect();
  117. // 注意:不再需要清空全局上下文,因为LocalMusic已改用实例变量缓存
  118. // 当新账户加载时,新的认证信息会自动覆盖旧的
  119. Logger.info(TAG, 'heanup 切换账户,准备加载新账户文件');
  120. this.isLoading = true;
  121. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount)
  122. .catch((error: Error) => {
  123. Logger.error(TAG, '加载文件失败: ' + error.message);
  124. this.isLoading = false;
  125. });
  126. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  127. }
  128. updateListData(mList:Array<VideoItem>, noSort?: boolean){
  129. if (!noSort) {
  130. this.sortType = PreferencesUtil.getNumberSync('webDavSortType', 0)
  131. this.doSortType(this.sortType)
  132. }
  133. this.dataSource.pushArrayData(mList)
  134. if(mList.length > 0){
  135. setTimeout(() => {
  136. this.listScroller.scrollToIndex(0)
  137. },200)
  138. }
  139. }
  140. // 更新可见文件夹列表
  141. private updateVisibleFolders(): void {
  142. try {
  143. // 安全检查webDavFiles
  144. if (!this.webDavFiles || !Array.isArray(this.webDavFiles)) {
  145. this.visibleFoldersState = [];
  146. return;
  147. }
  148. const allFolders = this.webDavFiles.filter(f => f.isDirectory);
  149. const isNavAccount = this.selectedAccount?.webType === RemoteDriveType.Navidrome;
  150. if (isNavAccount) {
  151. const sortedNavFolders = allFolders.sort((a, b) => a.fileName.localeCompare(b.fileName));
  152. this.visibleFoldersState = sortedNavFolders;
  153. return;
  154. }
  155. const visible: FileInfo[] = [];
  156. for (let i = 0; i < allFolders.length; i++) {
  157. const folder = allFolders[i];
  158. // 安全检查folder对象
  159. if (!folder || typeof folder.fileName !== 'string') {
  160. continue;
  161. }
  162. let shouldShow = this.isDirectChildOfCurrentPath(folder);
  163. if (shouldShow) {
  164. visible.push(folder);
  165. }
  166. }
  167. console.log('更新文件夹列表:', visible);
  168. this.visibleFoldersState = visible;
  169. } catch (error) {
  170. Logger.error(TAG, '更新文件夹列表失败:', error.toString());
  171. this.visibleFoldersState = [];
  172. }
  173. this.isShowTitleBar = true
  174. }
  175. private isSongSelected(song: VideoItem): boolean {
  176. return this.selectedSongs.some(item => item.filePath === song.filePath);
  177. }
  178. private enterMultiSelect(song: VideoItem): void {
  179. if (!this.isMultiSelect) {
  180. this.isMultiSelect = true;
  181. this.selectedSongs = [song];
  182. this.isAllSelected = this.selectedSongs.length === this.songs.length && this.songs.length > 0;
  183. return;
  184. }
  185. if (!this.isSongSelected(song)) {
  186. this.toggleSongSelection(song);
  187. }
  188. }
  189. private toggleSongSelection(song: VideoItem): void {
  190. if (!this.isMultiSelect) {
  191. this.isMultiSelect = true;
  192. }
  193. const exists = this.isSongSelected(song);
  194. if (exists) {
  195. const next = this.selectedSongs.filter(item => item.filePath !== song.filePath);
  196. this.selectedSongs = next;
  197. this.isAllSelected = next.length > 0 && next.length === this.songs.length;
  198. if (next.length === 0) {
  199. this.exitMultiSelect();
  200. }
  201. } else {
  202. const next = [...this.selectedSongs, song];
  203. this.selectedSongs = next;
  204. this.isAllSelected = next.length === this.songs.length && this.songs.length > 0;
  205. }
  206. }
  207. private handleCheckboxSelection(song: VideoItem, checked: boolean): void {
  208. if (!this.isMultiSelect) {
  209. this.isMultiSelect = true;
  210. }
  211. const exists = this.isSongSelected(song);
  212. if (checked && !exists) {
  213. this.toggleSongSelection(song);
  214. } else if (!checked && exists) {
  215. this.toggleSongSelection(song);
  216. }
  217. }
  218. private toggleSelectAll(): void {
  219. if (this.isAllSelected) {
  220. this.selectedSongs = [];
  221. this.isAllSelected = false;
  222. return;
  223. }
  224. if (!this.isMultiSelect) {
  225. this.isMultiSelect = true;
  226. }
  227. this.selectedSongs = this.songs.slice();
  228. this.isAllSelected = this.selectedSongs.length > 0 && this.selectedSongs.length === this.songs.length;
  229. }
  230. private exitMultiSelect(): void {
  231. this.isMultiSelect = false;
  232. this.selectedSongs = [];
  233. this.isAllSelected = false;
  234. console.info('onecold this.isMultiSelect '+this.isMultiSelect)
  235. }
  236. private syncSelectionAfterRefresh(): void {
  237. if (!this.isMultiSelect) {
  238. return;
  239. }
  240. const currentKeys: Set<string> = new Set(this.songs.map(item => item.filePath));
  241. const filtered = this.selectedSongs.filter(item => currentKeys.has(item.filePath));
  242. if (filtered.length !== this.selectedSongs.length) {
  243. this.selectedSongs = filtered;
  244. }
  245. this.isAllSelected = filtered.length > 0 && filtered.length === this.songs.length;
  246. if (filtered.length === 0) {
  247. this.exitMultiSelect();
  248. }
  249. }
  250. private confirmDeleteSelected(): void {
  251. if (!this.selectedAccount) {
  252. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  253. return;
  254. }
  255. if (!this.isMultiSelect || this.selectedSongs.length === 0) {
  256. this.getUIContext().getPromptAction().showToast({ message: '请先选择要删除的文件' });
  257. return;
  258. }
  259. this.getUIContext().showAlertDialog({
  260. title: '删除文件',
  261. message: `确定删除选中的${this.selectedSongs.length}个文件吗?`,
  262. primaryButton: {
  263. value: '取消',
  264. action: () => {}
  265. },
  266. secondaryButton: {
  267. value: '删除',
  268. fontColor: Color.Red,
  269. action: () => {
  270. void this.performDeleteSelected();
  271. }
  272. }
  273. });
  274. }
  275. private async performDeleteSelected(): Promise<void> {
  276. if (!this.selectedAccount || this.selectedSongs.length === 0) {
  277. return;
  278. }
  279. this.isDeletingSelection = true;
  280. try {
  281. await this.webdavManager.deleteRemoteSongs(this.selectedAccount, this.selectedSongs.slice());
  282. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount, this.webdavManager.currentPath);
  283. this.exitMultiSelect();
  284. this.getUIContext().getPromptAction().showToast({ message: '删除成功' });
  285. } catch (error) {
  286. const err = error as Error;
  287. this.getUIContext().getPromptAction().showToast({ message: `删除失败: ${err.message}` });
  288. } finally {
  289. this.isDeletingSelection = false;
  290. }
  291. }
  292. private buildSongMetaLine(song: VideoItem): string {
  293. const parts: string[] = [];
  294. if (StrUtil.isNotEmpty(song.duration)) {
  295. parts.push(song.duration as string);
  296. } else if (StrUtil.isNotEmpty(song.size)) {
  297. parts.push(decodeUrlEncodedString(song.size as string));
  298. }
  299. if (parts.length === 0 && StrUtil.isNotEmpty(song.cTime)) {
  300. parts.push(song.cTime as string);
  301. }
  302. return parts.join(' · ');
  303. }
  304. // 对话框控制器
  305. private accountDialogController: CustomDialogController | null = null;
  306. // 保存事件处理器引用,用于取消订阅
  307. private eventHandler: (event: string) => void = (event: string) => {
  308. this.handleWebdavEvent(event);
  309. };
  310. initSetting(){
  311. this.isCustomizeBg = PreferencesUtil.getBooleanSync(SettingPage.IS_CUSTOMIZE_BG, false)
  312. this.customizeBgPath = PreferencesUtil.getStringSync(SettingPage.IS_CUSTOMIZE_BG_PATH, '')
  313. this.blurValue = PreferencesUtil.getNumberSync(SettingPage.CUSTOMIZE_BG_BLUR, 0)
  314. this.bgBrightness = PreferencesUtil.getNumberSync(SettingPage.BG_BRIGHTNESS, 0)
  315. this.isShowFileName = PreferencesUtil.getBooleanSync('isShowFileName', false)
  316. this.isLongNameRoLL = PreferencesUtil.getBooleanSync('isLongNameRoLL', true)
  317. this.sortType = PreferencesUtil.getNumberSync('webDavSortType', 0)
  318. this.isNoJumpToHome = PreferencesUtil.getBooleanSync('isNoJumpToHome', true)
  319. this.isShowTitleBar = PreferencesUtil.getBooleanSync(SettingPage.IS_SHOW_TITLTBAR, true)
  320. this.autoHideTitle = PreferencesUtil.getBooleanSync('autoHideTitle', true)
  321. }
  322. aboutToAppear(): void {
  323. this.initSetting()
  324. let eventSetting: emitter.InnerEvent = { eventId: 333 }
  325. // 监听广播事件(通用设置配置更新)
  326. emitter.on(eventSetting, (eventData: emitter.EventData) => {
  327. this.initSetting()
  328. });
  329. // 加载账户列表
  330. this.loadAccounts();
  331. this.loadFiles()
  332. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  333. // 订阅WebDAV状态变化
  334. this.webdavManager.subscribe(this.eventHandler);
  335. this.subscribeWebDavMetadataUpdates();
  336. }
  337. aboutToDisappear(): void {
  338. // 取消订阅
  339. this.webdavManager.unsubscribe(this.eventHandler);
  340. emitter.off(EventConstants.EVENT_WEBDAV_METADATA_UPDATED);
  341. }
  342. // 处理WebDAV事件
  343. private handleWebdavEvent(event: string): void {
  344. switch (event) {
  345. case RemoteDriveManagerStates.LoadFilesInfoSucceed:
  346. this.songs = this.webdavManager.webDavSongs;
  347. this.updateListData(this.songs)
  348. // 直接引用webdavManager的数组,避免@Observed序列化问题
  349. this.webDavFiles = this.webdavManager.webDavFiles;
  350. this.isLoading = false;
  351. // 更新可见文件夹列表
  352. this.updateVisibleFolders();
  353. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  354. this.syncSelectionAfterRefresh();
  355. // promptAction.showToast({
  356. // message: '加载成功: ' + this.webDavFiles.filter(f => f.isDirectory).length + '个文件夹, ' + this.songs.length + '首歌曲'
  357. // });
  358. break;
  359. case RemoteDriveManagerStates.LoadFilesInfoFailed:
  360. this.isLoading = false;
  361. // this.getUIContext().getPromptAction().showToast({ message: '加载失败' });
  362. break;
  363. case RemoteDriveManagerStates.InsertAccountSucceed:
  364. case RemoteDriveManagerStates.EditAccountSucceed:
  365. case RemoteDriveManagerStates.RemoveAccountSucceed:
  366. this.loadAccounts();
  367. break;
  368. }
  369. }
  370. private subscribeWebDavMetadataUpdates(): void {
  371. emitter.on(this.metadataUpdateEvent, (eventData: emitter.EventData) => {
  372. const payloads = eventData?.data as WebDavMetadataUpdatePayload[] | undefined;
  373. if (!payloads || payloads.length === 0) {
  374. Logger.info(TAG, 'WebDav metadata event received but payload empty');
  375. return;
  376. }
  377. Logger.info(TAG, `WebDav metadata event payload count: ${payloads.length}`);
  378. this.handleWebDavMetadataUpdates(payloads);
  379. });
  380. }
  381. private handleWebDavMetadataUpdates(payloads: WebDavMetadataUpdatePayload[]): void {
  382. if (!payloads || payloads.length === 0) {
  383. return;
  384. }
  385. Logger.info(TAG, 'handleWebDavMetadataUpdates start');
  386. let hasChanges = false;
  387. for (let i = 0; i < payloads.length; i++) {
  388. const payload = payloads[i];
  389. if (!payload || !payload.filePath) {
  390. continue;
  391. }
  392. Logger.info(TAG, `WebDav metadata detail path=${payload.pixelMapPath ?? 'null'}`);
  393. const songUpdated = this.updateSongMetadata(payload);
  394. if (songUpdated) {
  395. hasChanges = true;
  396. Logger.info(TAG, `metadata updated for ${payload.filePath}`);
  397. }
  398. }
  399. if (!hasChanges) {
  400. Logger.info(TAG, 'handleWebDavMetadataUpdates no changes detected');
  401. return;
  402. }
  403. const clonedSongs: VideoItem[] = [];
  404. for (let i = 0; i < this.songs.length; i++) {
  405. clonedSongs.push(this.cloneSong(this.songs[i]));
  406. }
  407. this.songs = clonedSongs;
  408. this.dataSource.pushArrayData(clonedSongs);
  409. this.listRefreshKey++;
  410. Logger.info(TAG, 'handleWebDavMetadataUpdates trigger refresh');
  411. }
  412. private updateSongMetadata(payload: WebDavMetadataUpdatePayload): boolean {
  413. if (!payload.filePath) {
  414. return false;
  415. }
  416. const targetIndex = this.findSongIndex(payload.filePath);
  417. if (targetIndex < 0) {
  418. return false;
  419. }
  420. const targetSong = this.songs[targetIndex];
  421. let mutated = false;
  422. if (payload.pixelMapPath && payload.pixelMapPath.length > 0) {
  423. targetSong.pixelMapPath = payload.pixelMapPath;
  424. mutated = true;
  425. }
  426. if (payload.name && payload.name.length > 0) {
  427. targetSong.name = payload.name;
  428. mutated = true;
  429. }
  430. if (payload.artist && payload.artist.length > 0) {
  431. targetSong.artist = payload.artist;
  432. mutated = true;
  433. }
  434. return mutated;
  435. }
  436. private findSongIndex(filePath: string): number {
  437. for (let i = 0; i < this.songs.length; i++) {
  438. if (this.songs[i].filePath === filePath) {
  439. return i;
  440. }
  441. }
  442. return -1;
  443. }
  444. private cloneSong(item: VideoItem): VideoItem {
  445. const clone = new VideoItem(
  446. item.name,
  447. item.id,
  448. item.filePath,
  449. item.type,
  450. item.videoSize,
  451. item.cTime,
  452. item.size,
  453. item.pixelMapPath,
  454. item.artist,
  455. item.album,
  456. item.fileName,
  457. item.lastPlayed
  458. );
  459. clone.duration = item.duration;
  460. clone.mimeType = item.mimeType;
  461. clone.trackCount = item.trackCount;
  462. clone.sampleRate = item.sampleRate;
  463. clone.size = item.size;
  464. clone.webdav_account_id = item.webdav_account_id;
  465. clone.remote_rel_path = item.remote_rel_path;
  466. clone.artist = item.artist;
  467. clone.album = item.album;
  468. clone.lyricContent = item.lyricContent;
  469. clone.pixelMapPath = item.pixelMapPath;
  470. clone.cTime = item.cTime;
  471. clone.fileName = item.fileName;
  472. clone.md5Str = item.md5Str;
  473. clone.bit_rate = item.bit_rate;
  474. clone.probe_score = item.probe_score;
  475. clone.year = item.year;
  476. clone.nb_streams = item.nb_streams;
  477. clone.nb_programs = item.nb_programs;
  478. clone.genre = item.genre;
  479. clone.track = item.track;
  480. clone.disc = item.disc;
  481. clone.channels = item.channels;
  482. clone.channel_layout = item.channel_layout;
  483. clone.start_time = item.start_time;
  484. clone.ALBUMARTIST = item.ALBUMARTIST;
  485. clone.COMPOSER = item.COMPOSER;
  486. clone.COMMENT = item.COMMENT;
  487. clone.LYRICIST = item.LYRICIST;
  488. clone.pyStr = item.pyStr;
  489. clone.extra_json = item.extra_json;
  490. clone.parentPath = item.parentPath;
  491. clone.isFav = item.isFav;
  492. clone.playCount = item.playCount;
  493. clone.lastPlayed = item.lastPlayed;
  494. clone.videoSize = item.videoSize;
  495. clone.isFav = item.isFav;
  496. return clone;
  497. }
  498. // 加载账户列表
  499. private loadAccounts(): void {
  500. this.accounts = this.webdavManager.getAllWebDavAccounts();
  501. }
  502. // 加载文件列表
  503. private loadFiles(): void {
  504. if (!this.selectedAccount) {
  505. this.getUIContext().getPromptAction().showToast({ message: '请先选择账户' });
  506. return;
  507. }
  508. this.isLoading = true;
  509. this.webdavManager.loadFilesInfoFromWebdav()
  510. .catch((error: Error) => {
  511. Logger.error(TAG, '加载文件失败: ' + error.message);
  512. this.isLoading = false;
  513. });
  514. }
  515. // 进入文件夹
  516. private enterFolder(folder: FileInfo): void {
  517. this.isLoading = true;
  518. this.webdavManager.enterFolder(folder)
  519. .catch((error: Error) => {
  520. Logger.error(TAG, '进入文件夹失败: ' + error.message);
  521. this.isLoading = false;
  522. });
  523. }
  524. // 检查是否为当前目录的直接子项
  525. private isDirectChildOfCurrentPath(folder: FileInfo): boolean {
  526. const currentPath = this.webdavManager.currentPath || '';
  527. // 如果在根目录(currentPath为空或'/'),显示所有第一级文件夹
  528. if (currentPath === '' || currentPath === '/') {
  529. const folderPath = folder.href.replace(/\/$/, ''); // 去掉尾部斜杠
  530. return folder.href.startsWith('/') &&
  531. folder.href !== '/' &&
  532. !folderPath.substring(1).includes('/');
  533. }
  534. // 非根目录情况,计算相对路径
  535. let relativePath = folder.href;
  536. if (currentPath !== '/') {
  537. relativePath = folder.href.replace(currentPath, '');
  538. }
  539. relativePath = relativePath.replace(/^\//, '').replace(/\/$/, '');
  540. // 只有相对路径不为空且不包含/时才认为是直接子项
  541. return relativePath !== '' && !relativePath.includes('/');
  542. }
  543. // 返回上级目录
  544. private goBack(): void {
  545. if(this.webdavManager.canGoBack()){
  546. this.isLoading = true;
  547. this.webdavManager.goBack()
  548. .catch((error: Error) => {
  549. Logger.error(TAG, '返回失败: ' + error.message);
  550. this.isLoading = false;
  551. });
  552. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  553. }
  554. }
  555. // 切换账户
  556. private switchAccount(account: WebDavAccount): void {
  557. this.selectedAccount = account;
  558. this.songs = [];
  559. this.updateListData(this.songs)
  560. }
  561. // 播放WebDAV歌曲
  562. private playSong(song: VideoItem, index: number,isJump:boolean=false): void {
  563. try {
  564. Logger.info(TAG, `heanup === WebDAV playSong 方法被调用 ===`);
  565. Logger.info(TAG, 'heanup 播放指定歌曲: ' + song.name + ', 索引: ' + index);
  566. Logger.info(TAG, 'heanup 歌曲列表长度: ' + this.songs.length);
  567. // 检查歌曲是否有webdav_account_id
  568. Logger.info(TAG, `heanup 播放歌曲的webdav_account_id: ${song.webdav_account_id || '未设置'}`);
  569. // 确保所有歌曲都设置了正确的webdav_account_id
  570. if (this.selectedAccount && this.selectedAccount.id) {
  571. const accountIds = this.songs.map(s => s.webdav_account_id).filter(id => id);
  572. Logger.info(TAG, `heanup 当前歌曲列表中有 ${accountIds.length}/${this.songs.length} 首歌曲设置了webdav_account_id`);
  573. // 如果发现歌曲缺少webdav_account_id,立即设置
  574. this.songs.forEach((item, idx) => {
  575. if (!item.webdav_account_id) {
  576. item.webdav_account_id = this.selectedAccount!.id.toString();
  577. Logger.info(TAG, `heanup 为歌曲 "${item.name}" 设置webdav_account_id: ${item.webdav_account_id}`);
  578. }
  579. });
  580. }
  581. // 直接使用当前的VideoItem数组
  582. const videoItems: VideoItem[] = this.songs;
  583. const songFilePaths: string[] = [];
  584. for (let i = 0; i < this.songs.length; i++) {
  585. const item = this.songs[i];
  586. songFilePaths.push(item.filePath); // 使用filePath作为文件路径
  587. }
  588. // 直接通过事件传递videoItems数据,不使用GlobalContext
  589. const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY };
  590. const playlistData: PlaylistEventData = {
  591. playlistId: 'webdav-playlist', // 使用特殊的ID标识网盘播放列表
  592. playlistName: `${getRemoteDriveDisplayLabel(this.selectedAccount?.webType)} - ${this.selectedAccount?.name || '未知账户'}`,
  593. songCount: this.songs.length,
  594. startIndex: index,
  595. isJump: isJump,//设置true会弹出播放页
  596. songFilePaths: songFilePaths
  597. };
  598. // 保存videoItems到全局内存
  599. globalWebdavVideoItems = videoItems;
  600. globalWebdavCurrentPlayIndex = index;
  601. Logger.info(TAG, `heanup 保存WebDAV歌曲到全局内存,长度: ${videoItems.length}, 索引: ${index}`);
  602. // 发送播放请求事件,只传递索引信息
  603. emitter.emit(eventPlaylistPlay, { data: playlistData });
  604. Logger.info(TAG, `heanup 发送WebDAV播放事件,只传递索引: ${index}`);
  605. if(!this.isNoJumpToHome){
  606. // 跳转到首页播放器
  607. this.getUIContext()?.animateTo({ duration: 555 }, () => {
  608. this.mType = 0
  609. })
  610. }
  611. } catch (error) {
  612. const err = error as Error;
  613. Logger.error(TAG, '播放歌曲失败: ' + err.message);
  614. this.getUIContext().getPromptAction().showToast({ message: '播放失败' });
  615. }
  616. }
  617. /**
  618. * 一键创建歌单:将当前WebDAV歌曲全部加入新歌单
  619. */
  620. private async createPlaylistFromCurrentWebDav(): Promise<void> {
  621. try {
  622. Logger.info(TAG, 'heanup 一键创建歌单开始');
  623. if (!this.selectedAccount || !this.selectedAccount.id) {
  624. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  625. return;
  626. }
  627. if (!this.songs || this.songs.length === 0) {
  628. // 回退到manager内的歌曲(可能还未复制到页面state)
  629. this.songs = this.webdavManager.webDavSongs;
  630. Logger.info(TAG, `heanup 页面songs为空,回退webdavManager.webDavSongs,长度=${this.songs.length}`);
  631. if (this.songs.length === 0) {
  632. this.getUIContext().getPromptAction().showToast({ message: '当前目录没有可添加的歌曲' });
  633. return;
  634. }
  635. }
  636. Logger.info(TAG, `heanup 歌单创建前歌曲数量: ${this.songs.length}`);
  637. // 确保每首歌都有webdav_account_id
  638. for (let i = 0; i < this.songs.length; i++) {
  639. if (!this.songs[i].webdav_account_id) {
  640. this.songs[i].webdav_account_id = this.selectedAccount.id.toString();
  641. }
  642. }
  643. // 构建歌单名称:账户名 + 当前路径(简化)
  644. const rawPath = this.webdavManager.currentPath || '/';
  645. const shortPath = rawPath === '/' ? '根目录' : rawPath.replace(/\/$/, '').split('/').slice(-1)[0];
  646. const playlistName = `${getRemoteDrivePlaylistPrefix(this.selectedAccount.webType)}-${this.selectedAccount.name}-${shortPath}`;
  647. // 创建歌单
  648. // 安全获取HostContext
  649. const uiContext = this.getUIContext();
  650. const hostCtx = uiContext ? uiContext.getHostContext() : undefined;
  651. if (!hostCtx) {
  652. this.getUIContext().getPromptAction().showToast({ message: '无法获取上下文' });
  653. return;
  654. }
  655. const playlistModule = await import('../common/util/PlaylistTable');
  656. const mediaModule = await import('../common/util/MediaTable');
  657. const playlistTable = new playlistModule.default(hostCtx);
  658. const mediaTable = new mediaModule.default(hostCtx);
  659. // 等待MediaTable底层RDB初始化完成
  660. await new Promise<void>((resolve) => {
  661. mediaTable.getRdbStore(hostCtx, () => {
  662. Logger.info(TAG, 'heanup mediaTable RDB 初始化完成');
  663. resolve();
  664. });
  665. });
  666. // 先将WebDAV歌曲入库(若不存在)
  667. let upsertSuccess = 0;
  668. for (let i = 0; i < this.songs.length; i++) {
  669. const v = this.songs[i];
  670. if (!v.id || v.id === '') {
  671. // 使用filePath作为唯一ID
  672. v.id = v.filePath;
  673. }
  674. if (!v.parentPath) {
  675. const idxp = v.filePath.lastIndexOf('/');
  676. if (idxp > 0) {
  677. v.parentPath = v.filePath.substring(0, idxp);
  678. }
  679. }
  680. const ok = await mediaTable.upsertWebDavVideoItem(v);
  681. Logger.info(TAG, `heanup upsert 第${i+1}/${this.songs.length}首: ${v.filePath} => ${ok}`);
  682. if (ok) {
  683. upsertSuccess++;
  684. }
  685. }
  686. Logger.info(TAG, `heanup WebDAV歌曲入库完成: 成功 ${upsertSuccess}/${this.songs.length}`);
  687. // 先查询是否已有同名歌单,避免重复创建导致混淆
  688. const existing = (await playlistTable.queryAllPlaylists()).find(p => p.name === playlistName);
  689. if (existing) {
  690. this.getUIContext().getPromptAction().showToast({ message: '歌单已存在,直接追加歌曲' });
  691. const filePathsExist: string[] = this.songs.map(s => s.filePath);
  692. await playlistTable.addSongsToPlaylist(existing.id, filePathsExist);
  693. router.pushUrl({ url: 'pages/PlaylistDetailPage', params: { playlist: existing } });
  694. return;
  695. }
  696. Logger.info(TAG, `heanup 准备创建歌单: ${playlistName}`);
  697. const created = await playlistTable.createPlaylist(playlistName, `来自${getRemoteDriveDisplayLabel(this.selectedAccount.webType)}: ${this.selectedAccount.name} 路径: ${rawPath}`);
  698. if (!created) {
  699. this.getUIContext().getPromptAction().showToast({ message: '歌单创建失败' });
  700. return;
  701. }
  702. // 查询刚创建的歌单ID
  703. const playlists = await playlistTable.queryAllPlaylists();
  704. const target = playlists.reverse().find(p => p.name === playlistName); // 取最近创建的同名歌单
  705. if (!target) {
  706. this.getUIContext().getPromptAction().showToast({ message: '无法找到新建歌单' });
  707. return;
  708. }
  709. // 批量添加歌曲
  710. const filePaths: string[] = this.songs.map(s => s.filePath);
  711. Logger.info(TAG, `heanup 开始批量添加歌曲到歌单: ${target.id}`);
  712. const addResult = await playlistTable.addSongsToPlaylist(target.id, filePaths);
  713. Logger.info(TAG, `heanup 批量添加结果: ${addResult}`);
  714. if (!addResult) {
  715. Logger.warn(TAG, '批量添加歌曲返回false,可能全部已存在或写入失败');
  716. }
  717. this.getUIContext().getPromptAction().showToast({ message: `歌单创建成功: ${playlistName}` });
  718. Logger.info(TAG, `heanup 一键创建歌单成功: ${playlistName}, 添加 ${filePaths.length} 首歌曲`);
  719. // 跳转到歌单详情页面
  720. router.pushUrl({ url: 'pages/PlaylistDetailPage', params: { playlist: target } });
  721. } catch (error) {
  722. Logger.error(TAG, '一键创建歌单失败: ' + (error as Error).message);
  723. this.getUIContext().getPromptAction().showToast({ message: '一键创建歌单失败' });
  724. }
  725. }
  726. // 导航到指定层级的面包屑路径
  727. private async navigateToBreadcrumb(crumb: BreadcrumbItem): Promise<void> {
  728. if (!crumb) {
  729. return;
  730. }
  731. try {
  732. this.isLoading = true;
  733. await this.webdavManager.enterFolderFromPath(crumb.path);
  734. } catch (error) {
  735. Logger.error(TAG, '导航到面包屑路径失败: ' + (error as Error).message);
  736. this.isLoading = false;
  737. }
  738. }
  739. // 导航到上传页面
  740. private navigateToUploadPage(): void {
  741. if (!this.selectedAccount || !this.selectedAccount.id) {
  742. this.getUIContext().getPromptAction().showToast({ message: '请先选择账户' });
  743. return;
  744. }
  745. Logger.info(TAG, '导航到上传页面,账户ID: ' + this.selectedAccount.id);
  746. this.getUIContext().getRouter().pushUrl({
  747. url: 'pages/UploadMusicPage',
  748. params: {
  749. accountId: this.selectedAccount.id
  750. }
  751. }).catch((error: Error) => {
  752. Logger.error(TAG, '导航到上传页面失败: ' + error.message);
  753. this.getUIContext().getPromptAction().showToast({ message: '打开上传页面失败' });
  754. });
  755. }
  756. @Builder
  757. MoreMenuBuilder() {
  758. Menu() {
  759. MenuItem({
  760. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.music_note_list')),
  761. content: $r('app.string.onekey_add_playlist')
  762. })
  763. .onClick(async () => {
  764. this.createPlaylistFromCurrentWebDav();
  765. })
  766. MenuItem({
  767. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.arrow_up_to_line')),
  768. content: $r('app.string.upload_music_file')
  769. })
  770. .onClick(async () => {
  771. this.navigateToUploadPage();
  772. })
  773. MenuItem({
  774. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.folder_badge_plus')),
  775. content: $r('app.string.create_foder')
  776. })
  777. .onClick(async () => {
  778. this.showCreateFolderDialog();
  779. })
  780. }.attributeModifier(new MenuModifier())
  781. }
  782. @Builder
  783. SortMenuBuilder() {
  784. Menu() {
  785. MenuItem({
  786. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  787. content: $r('app.string.sort_by_name')
  788. })
  789. .onClick(async () => {
  790. this.doSortType(0)
  791. PreferencesUtil.put("webDavSortType", 0)
  792. })
  793. MenuItem({
  794. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  795. content: '按名称降序'
  796. })
  797. .onClick(async () => {
  798. this.doSortType(1)
  799. PreferencesUtil.put("webDavSortType", 1)
  800. })
  801. MenuItem({
  802. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')),
  803. content:'按时间升序'
  804. })
  805. .onClick(async () => {
  806. this.doSortType(2)
  807. PreferencesUtil.put("webDavSortType", 2)
  808. })
  809. MenuItem({
  810. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.timer')),
  811. content: '按时间降序'
  812. })
  813. .onClick(async () => {
  814. this.doSortType(3)
  815. PreferencesUtil.put("webDavSortType", 3)
  816. })
  817. MenuItem({
  818. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_up')),
  819. content:'按大小升序'
  820. })
  821. .onClick(async () => {
  822. this.doSortType(4)
  823. PreferencesUtil.put("webDavSortType", 4)
  824. })
  825. MenuItem({
  826. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.text_and_arrow_down')),
  827. content: '按大小降序'
  828. })
  829. .onClick(async () => {
  830. this.doSortType(5)
  831. PreferencesUtil.put("webDavSortType", 5)
  832. })
  833. }.attributeModifier(new MenuModifier())
  834. }
  835. doSortType(index: number) {
  836. // 对歌曲列表进行排序
  837. switch (index) {
  838. case 0:
  839. Utility.doSortListAscending(this.songs,this.isShowFileName)
  840. this.visibleFoldersState.sort((a, b) => {
  841. return a.fileName.localeCompare(b.fileName);
  842. });
  843. break;
  844. case 1:
  845. Utility.doSortListDescending(this.songs,this.isShowFileName)
  846. this.visibleFoldersState.sort((a, b) => {
  847. return b.fileName.localeCompare(a.fileName);
  848. });
  849. break;
  850. case 2:
  851. this.songs.sort((a, b) => {
  852. return a.cTime.localeCompare(b.cTime);
  853. });
  854. this.visibleFoldersState.sort((a, b) => {
  855. return a.time-b.time;
  856. });
  857. break;
  858. case 3:
  859. this.songs.sort((a, b) => {
  860. return b.cTime.localeCompare(a.cTime);
  861. });
  862. this.visibleFoldersState.sort((a, b) => {
  863. return b.time-a.time;
  864. });
  865. break;
  866. case 4:
  867. this.songs.sort((a, b) => {
  868. return a.videoSize - b.videoSize;
  869. });
  870. break;
  871. case 5:
  872. this.songs.sort((a, b) => {
  873. return b.videoSize - a.videoSize;
  874. });
  875. break;
  876. }
  877. this.updateListData(this.songs,true)
  878. }
  879. @Builder
  880. topTitleBar(){
  881. Column() {
  882. Row({ space: 15 }) {
  883. if (!this.isSearchMode ) {
  884. //左侧滑动按钮
  885. Button({ type: ButtonType.Circle, stateEffect: true }) {
  886. SymbolGlyph($r('sys.symbol.sort'))
  887. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  888. }
  889. .attributeModifier(new ButtonFancyModifier(40, 40))
  890. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  891. .animation({ duration: 300, curve: Curve.Ease })
  892. .onClick(() => {
  893. this.getUIContext().animateTo({ duration: 555 }, () => {
  894. // 动画闭包内控制Image组件的出现和消失
  895. this.isShowDrawer = !this.isShowDrawer
  896. this.offsetX = 0
  897. })
  898. })
  899. .attributeModifier(new ShadowModifier())
  900. .zIndex(0)
  901. Text(this.selectedAccount.name)
  902. .margin({left:3,right:10})
  903. .fontColor($r('app.color.text_color'))
  904. .fontSize(19)
  905. .maxLines(1)
  906. .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
  907. .layoutWeight(1)
  908. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  909. }else{
  910. //左侧搜索返回按钮
  911. Button({ type: ButtonType.Circle, stateEffect: true }) {
  912. SymbolGlyph($r('sys.symbol.chevron_left'))
  913. .attributeModifier(new SymbolGlyphFancyModifier(24, '', ''))
  914. }
  915. .attributeModifier(new ButtonFancyModifier(40, 40))
  916. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  917. .animation({ duration: 300, curve: Curve.Ease })
  918. .onClick(() => {
  919. this.isSearchMode = false
  920. this.onSearchInput('')
  921. })
  922. .attributeModifier(new ShadowModifier())
  923. .zIndex(0)
  924. }
  925. //搜索框
  926. Search({ controller: this.searchController,value: this.searchText, placeholder: '搜索标题、艺术家...' })
  927. .searchButton('搜索',{fontColor:this.themeColor})
  928. .searchIcon({
  929. src: $r('sys.media.ohos_ic_public_search_filled')
  930. })
  931. .cancelButton({
  932. style: CancelButtonStyle.CONSTANT,
  933. icon: {
  934. src: $r('sys.media.ohos_ic_public_cancel_filled')
  935. }
  936. })
  937. .layoutWeight(1)
  938. .height(35)
  939. .maxLength(20)
  940. .backgroundColor(this.isDarkMode?Color.Black:'#F5F5F5')
  941. .placeholderColor(Color.Grey)
  942. .placeholderFont({ size: 14, weight: 400 })
  943. .textFont({ size: 14, weight: 400 })
  944. .onSubmit((value: string) => {
  945. console.log('onecold onSubmit ='+value)
  946. this.searchController.stopEditing()
  947. this.onSearchInput(this.searchText);
  948. })
  949. .onChange((value: string) => {
  950. console.log('onecold onChange ='+value)
  951. this.onSearchInput(value);
  952. })
  953. .visibility(this.isSearchMode?Visibility.Visible:Visibility.None)
  954. .animation({ duration: 300, curve: Curve.Ease })
  955. //搜索按钮
  956. if (!this.isSearchMode) {
  957. Button({ type: ButtonType.Circle, stateEffect: true }) {
  958. SymbolGlyph($r('sys.symbol.magnifyingglass'))
  959. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  960. }
  961. .attributeModifier(new ButtonFancyModifier(40, 40))
  962. .animation({ duration: 300, curve: Curve.Ease })
  963. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  964. .attributeModifier(new ShadowModifier())
  965. .zIndex(0)
  966. .onClick(()=>{
  967. this.filteredFolderList = [...this.visibleFoldersState]; // 显示所有文件夹
  968. this.isSearchMode = true
  969. })
  970. //排序按钮
  971. Button({ type: ButtonType.Circle, stateEffect: true }) {
  972. SymbolGlyph($r('sys.symbol.list_number'))
  973. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  974. }
  975. .attributeModifier(new ButtonFancyModifier(40, 40))
  976. .animation({ duration: 300, curve: Curve.Ease })
  977. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  978. .bindMenu(this.SortMenuBuilder)
  979. .attributeModifier(new ShadowModifier())
  980. .zIndex(0)
  981. //添加/上传综合按钮
  982. Button({ type: ButtonType.Circle, stateEffect: true }) {
  983. SymbolGlyph($r('sys.symbol.plus'))
  984. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  985. }
  986. .attributeModifier(new ButtonFancyModifier(40, 40))
  987. .animation({ duration: 300, curve: Curve.Ease })
  988. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  989. .bindMenu(this.MoreMenuBuilder)
  990. .attributeModifier(new ShadowModifier())
  991. .zIndex(0)
  992. }
  993. }
  994. }
  995. .padding({ top: this.topSafeHeight + 10, left: 10, right: 10, bottom: 2 })
  996. .width('100%')
  997. }
  998. //搜索功能的实现
  999. @State searchText: string = ''; // 用户输入内容
  1000. @State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
  1001. @State filteredFolderList: Array<FileInfo> = []; // 过滤后的文件夹结果
  1002. // 实时搜索逻辑(带防抖)
  1003. // 实时搜索逻辑(带防抖)
  1004. private onSearchInput(value: string) {
  1005. this.searchText = value.trim();
  1006. let mSearchList: Array<VideoItem> = []
  1007. mSearchList = this.songs
  1008. // 新增条件判断:空输入时显示所有数据
  1009. if (this.searchText === '') {
  1010. this.filteredList = [...mSearchList]; // 创建新数组以触发状态更新
  1011. this.filteredFolderList = [...this.visibleFoldersState]; // 显示所有文件夹
  1012. } else {
  1013. this.filteredList = mSearchList.filter((item: VideoItem) => {
  1014. //支持模糊匹配和艺术家 专辑匹配
  1015. const regex = new RegExp(this.searchText.toLowerCase().replace(/\s+/g, '.*'), 'i');
  1016. return regex.test(item.name.toLowerCase())||
  1017. regex.test(item.fileName?.toLowerCase() ?? "") ||
  1018. regex.test(item.artist?.toLowerCase() ?? "") ||
  1019. regex.test(item.album?.toLowerCase() ?? "")
  1020. });
  1021. // 对文件夹进行过滤
  1022. this.filteredFolderList = this.visibleFoldersState.filter((folder: FileInfo) => {
  1023. const regex = new RegExp(this.searchText.toLowerCase().replace(/\s+/g, '.*'), 'i');
  1024. return regex.test(folder.fileName?.toLowerCase() ?? "") ||
  1025. regex.test(decodeUrlEncodedString(folder.fileName?.replace('/', '') ?? "").toLowerCase());
  1026. });
  1027. }
  1028. this.updateListData(this.filteredList);
  1029. }
  1030. build() {
  1031. Stack() {
  1032. if (this.accounts.length === 0) {
  1033. this.buildEmptyView();
  1034. } else {
  1035. this.buildContentView();
  1036. }
  1037. // 顶部区域:标题栏在上,面包屑在下
  1038. Column() {
  1039. this.topTitleBar()
  1040. this.breaker()
  1041. }
  1042. .width('100%')
  1043. .visibility(this.isShowTitleBar?Visibility.Visible:
  1044. this.autoHideTitle?Visibility.None:Visibility.Visible)
  1045. .animation({
  1046. duration: 500,
  1047. curve: Curve.Friction // 可选动画曲线
  1048. })
  1049. .position({ top: 0, left: 0 })
  1050. // .backgroundColor($r('app.color.start_window_background'))
  1051. // 多选操作条,位于播放条之上(仅在多选模式下显示)
  1052. if (this.isMultiSelect || this.isDeletingSelection) {
  1053. this.buildSelectionOverlay()
  1054. }
  1055. }
  1056. .width('100%')
  1057. .height('100%')
  1058. .alignContent(Alignment.Bottom)
  1059. .backgroundImage(this.isCustomizeBg?this.customizeBgPath:$r('app.color.start_window_background'))
  1060. .backgroundImageSize(this.isLandscape?{width:'100%'}:{ height: '100%'})
  1061. .backgroundImagePosition(Alignment.Center)
  1062. .backdropBlur(this.blurValue)
  1063. .backgroundBrightness({rate:this.isCustomizeBg?0.1:0,lightUpDegree:this.bgBrightness})
  1064. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  1065. }
  1066. @Builder
  1067. breaker() {
  1068. // 面包屑导航
  1069. Column({ space: 8 }) {
  1070. // 面包屑导航
  1071. if (this.webdavManager.currentPath !== '') {
  1072. Row({ space: 8 }) {
  1073. Button({ type: ButtonType.Circle }) {
  1074. Image(this.webdavManager.canGoBack()?$r('app.media.back'):
  1075. this.selectedAccount.coverPath?this.selectedAccount.coverPath:getCloudDiskIcon(this.selectedAccount.webType))
  1076. .width(15)
  1077. .height(15)
  1078. .borderRadius(10)
  1079. .alt($r('app.media.cloudDisk'))
  1080. .fillColor(Color.White)
  1081. }
  1082. .width(20)
  1083. .height(20)
  1084. .margin({left:5})
  1085. .backgroundColor(this.themeColor)
  1086. .onClick(() => this.goBack())
  1087. Row({ space: 4 }) {
  1088. ForEach(this.breadcrumbs, (crumb: BreadcrumbItem, index: number) => {
  1089. Row() {
  1090. Text(crumb.label)
  1091. .fontSize(15)
  1092. .fontColor($r('app.color.text_color'))
  1093. .maxLines(1)
  1094. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1095. }
  1096. .onClick(() => {
  1097. this.navigateToBreadcrumb(crumb);
  1098. })
  1099. // 添加分隔符(除了最后一个元素)
  1100. if (index < this.breadcrumbs.length - 1) {
  1101. Text('/')
  1102. .fontSize(15)
  1103. .fontColor($r('app.color.index_tab_font_color'))
  1104. .opacity(0.6)
  1105. }
  1106. })
  1107. }
  1108. .layoutWeight(1)
  1109. }
  1110. .width('100%')
  1111. .padding({ left: 4, right: 4 })
  1112. }
  1113. }
  1114. .width('100%')
  1115. .padding({ left: 12, right: 12, top: 8, bottom: 5 })
  1116. }
  1117. // 空状态视图
  1118. @Builder
  1119. buildEmptyView() {
  1120. Column({ space: 20 }) {
  1121. Image($r('app.media.cloudDisk'))
  1122. .width(120)
  1123. .height(120)
  1124. .opacity(0.3)
  1125. Text(`暂无${getRemoteDriveAccountLabel()}`)
  1126. .fontSize(16)
  1127. .fontColor($r('app.color.index_tab_font_color'))
  1128. .opacity(0.6)
  1129. }
  1130. .justifyContent(FlexAlign.Center)
  1131. .width('100%')
  1132. .padding({top: this.topSafeHeight+160})
  1133. .layoutWeight(1)
  1134. }
  1135. // 内容视图
  1136. @Builder
  1137. buildContentView() {
  1138. Column() {
  1139. // 加载状态
  1140. Row() {
  1141. LoadingProgress()
  1142. .width(30)
  1143. .height(30)
  1144. .color(this.themeColor)
  1145. Text('加载中...')
  1146. .fontSize(14)
  1147. .fontColor($r('app.color.index_tab_font_color'))
  1148. .margin({ left: 12 })
  1149. }
  1150. .padding({top: this.topSafeHeight + 90, left: 20, right: 20})
  1151. .visibility(this.isLoading?Visibility.Visible:Visibility.None)
  1152. .opacity(this.isLoading ? 1 : 0)
  1153. .animation({
  1154. duration: 800,
  1155. curve: Curve.Smooth // 可选动画曲线
  1156. })
  1157. // 文件列表(文件夹 + 歌曲)
  1158. if (this.webDavFiles.length > 0) {
  1159. List({ scroller: this.listScroller ,space: 0 }) {
  1160. // 显示文件夹 - 只显示当前目录下的直接子文件夹
  1161. ForEach(this.isSearchMode ? this.filteredFolderList : this.visibleFoldersState, (folder: FileInfo) => {
  1162. ListItem() {
  1163. this.buildFolderItem(folder)
  1164. }
  1165. .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 600 }),
  1166. TransitionEffect.scale({ x: 0, y: 0 })))
  1167. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  1168. }, (folder: FileInfo) => folder.name+folder.fileName)
  1169. // 显示歌曲
  1170. LazyForEach(this.dataSource, (song: VideoItem, index: number) => {
  1171. ListItem() {
  1172. this.buildSongItem(song, index)
  1173. }
  1174. .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 600 }),
  1175. TransitionEffect.scale({ x: 0, y: 0 })))
  1176. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  1177. }, (item: VideoItem) => item.filePath + '_' + this.listRefreshKey)
  1178. }
  1179. .scrollBar(BarState.Off)
  1180. .onScrollFrameBegin((offset: number) => {
  1181. // 获取当前滚动偏移量
  1182. if(this.autoHideTitle){
  1183. const currentOffsetY = this.listScroller.currentOffset().yOffset;
  1184. // 判断滚动方向
  1185. if (currentOffsetY > this.prevOffsetY) {
  1186. console.log("onecold 向上滚动");
  1187. this.isShowTitleBar = false
  1188. } else if (currentOffsetY < this.prevOffsetY) {
  1189. console.log("onecold 向下滚动");
  1190. this.isShowTitleBar = true
  1191. }
  1192. // 更新前一次偏移量
  1193. this.prevOffsetY = currentOffsetY;
  1194. }
  1195. return { offsetRemain: offset };
  1196. })
  1197. .contentStartOffset(this.topSafeHeight + 85)
  1198. .contentEndOffset(this.bottomSafeHeight+70)
  1199. .layoutWeight(1)
  1200. .margin({ top: 4 })
  1201. } else if (!this.isLoading) {
  1202. Column() {
  1203. Text('暂无内容')
  1204. .fontSize(14)
  1205. .fontColor($r('app.color.index_tab_font_color'))
  1206. .opacity(0.6)
  1207. Text('点击"左侧菜单"网盘加载')
  1208. .fontSize(12)
  1209. .fontColor($r('app.color.index_tab_font_color'))
  1210. .opacity(0.4)
  1211. .margin({ top: 8 })
  1212. }
  1213. .justifyContent(FlexAlign.Center)
  1214. .layoutWeight(1)
  1215. }
  1216. }
  1217. .layoutWeight(1)
  1218. }
  1219. @Builder
  1220. private buildMultiSelectBar() {
  1221. Row({ space: 12 }) {
  1222. Button(this.isAllSelected ? '反选' : '全选', { type: ButtonType.Circle, stateEffect: true })
  1223. .width(55)
  1224. .height(55)
  1225. .fontSize(13)
  1226. .backgroundColor(this.themeColor)
  1227. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  1228. .onClick(() => this.toggleSelectAll())
  1229. Button('删除', { type: ButtonType.Circle, stateEffect: true })
  1230. .width(55)
  1231. .height(55)
  1232. .fontSize(13)
  1233. .backgroundColor(this.themeColor)
  1234. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  1235. .enabled(this.selectedSongs.length > 0 && !this.isDeletingSelection)
  1236. .onClick(() => this.confirmDeleteSelected())
  1237. Button('取消', { type: ButtonType.Circle, stateEffect: true })
  1238. .width(55)
  1239. .height(55)
  1240. .fontSize(13)
  1241. .backgroundColor(this.themeColor)
  1242. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
  1243. .onClick(() => this.exitMultiSelect())
  1244. }
  1245. .width('100%')
  1246. .padding({ bottom: 10, top: 12 })
  1247. .justifyContent(FlexAlign.Center)
  1248. .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
  1249. .opacity(this.isMultiSelect ? 1 : 0)
  1250. .animation({ duration: 300, curve: 'ease-in-out' })
  1251. }
  1252. @Builder
  1253. private buildSelectionOverlay() {
  1254. Column() {
  1255. Row({ space: 10 }) {
  1256. LoadingProgress()
  1257. .width(22)
  1258. .height(22)
  1259. .color(this.themeColor)
  1260. Text('正在删除...')
  1261. .fontSize(14)
  1262. .fontColor($r('app.color.index_tab_font_color'))
  1263. }
  1264. .padding({ top: 12, bottom: 6 })
  1265. .justifyContent(FlexAlign.Center)
  1266. .visibility(this.isDeletingSelection ? Visibility.Visible : Visibility.None)
  1267. this.buildMultiSelectBar()
  1268. }
  1269. .width('100%')
  1270. .padding({ left: 12, right: 12, bottom: this.bottomSafeHeight })
  1271. }
  1272. // 文件夹列表项
  1273. @Builder
  1274. buildFolderItem(folder: FileInfo) {
  1275. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  1276. Row({ space: 2 }) {
  1277. SymbolGlyph($r('sys.symbol.folder'))
  1278. .fontSize(48)
  1279. .fontColor([this.themeColor])
  1280. .alignSelf(ItemAlign.Center)
  1281. .margin({ left: 8, right: 6 })
  1282. // 文件夹信息
  1283. Column({ space: 4 }) {
  1284. Text(decodeUrlEncodedString(folder.fileName.replace('/', '')))
  1285. .fontSize(15)
  1286. .fontColor($r('app.color.index_tab_font_color'))
  1287. .maxLines(1)
  1288. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1289. Text('文件夹')
  1290. .fontSize(13)
  1291. .fontColor($r('app.color.index_tab_font_color'))
  1292. .opacity(0.6)
  1293. }
  1294. .alignItems(HorizontalAlign.Start)
  1295. .layoutWeight(1)
  1296. }
  1297. }
  1298. .reuseId('dir_item')
  1299. .width('100%')
  1300. .padding(12)
  1301. .backgroundColor(Color.Transparent)
  1302. // .backgroundColor($r('app.color.start_window_background'))
  1303. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  1304. .onClick(() => {
  1305. this.enterFolder(folder);
  1306. this.breadcrumbs = this.webdavManager.getBreadcrumbs();
  1307. })
  1308. }
  1309. // 歌曲列表项
  1310. @Builder
  1311. buildSongItem(song: VideoItem, index: number) {
  1312. Button({ type: ButtonType.Capsule, stateEffect: true }) {
  1313. Row({ space: 12 }) {
  1314. // 序号
  1315. // 歌曲封面
  1316. Image(song.pixelMapPath ? song.pixelMapPath : $r('app.media.alt'))
  1317. .width(52)
  1318. .height(52)
  1319. .borderRadius(9)
  1320. .sourceSize({width:38, height:38})
  1321. .alt($r('app.media.alt'))
  1322. .fillColor(this.themeColor)
  1323. .objectFit(ImageFit.Cover)
  1324. .shadow({
  1325. radius: StrUtil.isEmpty(song.pixelMapPath) ?6:14,
  1326. type: ShadowType.BLUR,
  1327. color: 'on_primary'
  1328. })
  1329. .margin({ left: 8 })
  1330. .onClick(() => {
  1331. if (this.isMultiSelect) {
  1332. this.toggleSongSelection(song);
  1333. } else {
  1334. this.playSong(song, index, true);
  1335. }
  1336. })
  1337. // 歌曲信息
  1338. Column({ space: 4 }) {
  1339. Text(this.isShowFileName?song.fileName :song.name)
  1340. .fontSize(15)
  1341. .textOverflow({ overflow: this.isLongNameRoLL?TextOverflow.MARQUEE:TextOverflow.Ellipsis })//超长滚动
  1342. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:$r('app.color.index_tab_font_color'))
  1343. .maxLines(1)
  1344. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1345. Row(){
  1346. Text((song.artist ?? '') + " ")
  1347. .fontSize(13)
  1348. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:$r('app.color.index_tab_font_color'))
  1349. .opacity(0.6)
  1350. .maxLines(1)
  1351. .visibility(song.artist?Visibility.Visible:Visibility.None)
  1352. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1353. Text(this.buildSongMetaLine(song))
  1354. .fontSize(13)
  1355. .fontColor(this.currentSong?.filePath==song.filePath?this.themeColor:
  1356. $r('app.color.index_tab_font_color'))
  1357. .opacity(0.6)
  1358. .maxLines(1)
  1359. .textOverflow({ overflow: TextOverflow.Ellipsis })
  1360. }
  1361. .width('90%')
  1362. }
  1363. .alignItems(HorizontalAlign.Start)
  1364. .layoutWeight(1)
  1365. .padding({ right: 20 })
  1366. Column() {
  1367. Checkbox({ name: 'checkbox_' + index })
  1368. .select(this.isSongSelected(song))
  1369. .selectedColor(this.themeColor)
  1370. .opacity(this.isMultiSelect ? 1 : 0)
  1371. .animation({
  1372. duration: 300,
  1373. curve: 'Smooth'
  1374. })
  1375. .visibility(this.isMultiSelect ? Visibility.Visible : Visibility.None)
  1376. .onChange((checked: boolean) => this.handleCheckboxSelection(song, checked))
  1377. .margin({ left: 10, top: 8, bottom: 8, right: 12 })
  1378. .width(22)
  1379. .height(22)
  1380. ImageAnimator()
  1381. .images(CommonConstants.IMAGE_FRAME_INFO)// 动画数组
  1382. .duration(1000)// 持续
  1383. //.state(this.animationState)// 动画状态
  1384. .state(AnimationStatus.Running)// 动画状态
  1385. .fillMode(FillMode.Forwards)
  1386. .width(18)
  1387. .margin({ right: 12, top: 8, bottom: 8 })
  1388. .visibility(this.currentSong?.filePath==song.filePath ? (this.isMultiSelect ? Visibility.None : Visibility.Visible) :
  1389. Visibility.None)
  1390. .height(18)
  1391. .iterations(-1) // 播放次数
  1392. }
  1393. }
  1394. }
  1395. .width('100%')
  1396. .padding(12)
  1397. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  1398. .backgroundColor(Color.Transparent)
  1399. .onClick(() => {
  1400. if (this.isMultiSelect) {
  1401. this.toggleSongSelection(song);
  1402. } else {
  1403. this.playSong(song, index);
  1404. }
  1405. })
  1406. .gesture(LongPressGesture().onAction(() => {
  1407. this.enterMultiSelect(song);
  1408. }))
  1409. }
  1410. // 对话框控制器
  1411. private createFolderDialogController: CustomDialogController | null = null;
  1412. /**
  1413. * 显示创建文件夹对话框
  1414. */
  1415. private showCreateFolderDialog(): void {
  1416. if (!this.selectedAccount) {
  1417. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  1418. return;
  1419. }
  1420. // 获取当前主题色
  1421. const currentThemeColor = this.themeColor;
  1422. // 创建自定义对话框
  1423. this.createFolderDialogController = new CustomDialogController({
  1424. builder: CreateFolderDialog({
  1425. onConfirm: (folderName: string) => {
  1426. if (folderName.trim().length > 0) {
  1427. void this.createFolder(folderName.trim());
  1428. } else {
  1429. this.getUIContext().getPromptAction().showToast({ message: '文件夹名称不能为空' });
  1430. }
  1431. },
  1432. onCancel: () => {
  1433. // 用户取消创建
  1434. },
  1435. initialName: '',
  1436. themeColor: currentThemeColor
  1437. }),
  1438. autoCancel: true,
  1439. alignment: DialogAlignment.Center,
  1440. customStyle: false
  1441. });
  1442. this.createFolderDialogController.open();
  1443. }
  1444. /**
  1445. * 创建文件夹的实际方法
  1446. * @param folderName 文件夹名称
  1447. */
  1448. private async createFolder(folderName: string): Promise<void> {
  1449. if (!this.selectedAccount) {
  1450. this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
  1451. return;
  1452. }
  1453. if (folderName.length === 0) {
  1454. this.getUIContext().getPromptAction().showToast({ message: '文件夹名称不能为空' });
  1455. return;
  1456. }
  1457. try {
  1458. Logger.info(TAG, `heanup 开始创建文件夹: ${folderName}`);
  1459. // 显示加载状态
  1460. this.isLoading = true;
  1461. if(this.selectedAccount.webType==RemoteDriveType.Baidu){
  1462. // 调用RemoteDriveManager的createBaiduFolder方法
  1463. const createdPath = await this.webdavManager.createBaiduFolder(this.selectedAccount, folderName);
  1464. // 创建成功后刷新当前目录
  1465. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount, this.webdavManager.currentPath);
  1466. }else if(this.selectedAccount.webType==RemoteDriveType.WebDav){
  1467. // 调用RemoteDriveManager的createWebDavFolder方法
  1468. const createdPath = await this.webdavManager.createWebDavFolder(this.selectedAccount, folderName);
  1469. // 创建成功后刷新当前目录
  1470. await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount, this.webdavManager.currentPath);
  1471. }else{
  1472. throw new Error('当前账户类型不支持创建文件夹');
  1473. }
  1474. this.getUIContext().getPromptAction().showToast({
  1475. message: `文件夹 "${folderName}" 创建成功`
  1476. });
  1477. Logger.info(TAG, `heanup 文件夹创建成功`);
  1478. } catch (error) {
  1479. const err = error as Error;
  1480. Logger.error(TAG, `heanup 创建文件夹失败: ${err.message}`);
  1481. // 显示具体的错误信息
  1482. this.getUIContext().getPromptAction().showToast({
  1483. message: `创建失败: ${err.message}`
  1484. });
  1485. } finally {
  1486. this.isLoading = false;
  1487. }
  1488. }
  1489. }