AddSongsToPlaylistDialog.ets 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064
  1. import { DialogHelper } from '@pura/harmony-dialog';
  2. import { Playlist } from '../viewmodel/Playlist';
  3. import { ToastUtil, LogUtil, StrUtil, FileUtil, PreferencesUtil } from '@pura/harmony-utils';
  4. import { VideoItem } from '../viewmodel/VideoItem';
  5. import MediaTable from '../common/util/MediaTable';
  6. import PlaylistTable from '../common/util/PlaylistTable';
  7. import { LazyDataSource } from '../common/util/LazyDataSource';
  8. import { ConfigurationConstant, common } from '@kit.AbilityKit';
  9. import { CommonConstants } from '../common/constants/CommonConstants';
  10. import { SegmentButton } from '@kit.ArkUI';
  11. import { SegmentButtonOptions, SegmentButtonItemTuple } from '@kit.ArkUI';
  12. import { Utility } from '../common/util/Utility';
  13. import fs from '@ohos.file.fs';
  14. // 工具函数:将 #RRGGBB 字符串和透明度转为 'rgba(r,g,b,a)' 字符串,深色模式下可返回深色变体
  15. function themeColorWithAlpha(themeColor: string, alpha: number, isDarkMode: boolean = false): string {
  16. if (isDarkMode) {
  17. // 深色模式下返回更深的灰色或半透明黑色
  18. return `rgba(34,34,34,${alpha + 0.2 > 1 ? 1 : alpha + 0.2})`;
  19. }
  20. const color = themeColor.replace('#', '');
  21. const r = parseInt(color.substring(0, 2), 16);
  22. const g = parseInt(color.substring(2, 4), 16);
  23. const b = parseInt(color.substring(4, 6), 16);
  24. return `rgba(${r},${g},${b},${alpha})`;
  25. }
  26. // 定义SegmentButton按钮元组类型
  27. const viewModeButtons: SegmentButtonItemTuple = [{ text: '全部' }, { text: '目录' }];
  28. /**
  29. * 添加歌曲到歌单对话框内容组件
  30. */
  31. @Component
  32. struct AddSongsToPlaylistDialogContent {
  33. context = this.getUIContext().getHostContext() as common.UIAbilityContext
  34. @State opacityItem: number = 1; // 控制透明度的状态变量
  35. @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
  36. @State isDarkMode: boolean = false
  37. @StorageProp('currentColorMode') @Watch('onColorModeChange') currentMode: number =
  38. ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
  39. onColorModeChange() {
  40. this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
  41. }
  42. @State selectedSongs: VideoItem[] = []
  43. @State isLoading: boolean = false
  44. @State searchText: string = ''
  45. @State filteredSongs: VideoItem[] = []
  46. private listScroller: ListScroller = new ListScroller()
  47. @State isSearchMode: boolean = false
  48. @Prop playlist: Playlist
  49. @StorageProp('mediaKuList') mediaKuList: Array<VideoItem> = []; //媒体库文件
  50. private mediaTable: MediaTable = new MediaTable(getContext(this))
  51. private playlistTable: PlaylistTable = new PlaylistTable(getContext(this))
  52. @State dataSource: LazyDataSource<VideoItem> = new LazyDataSource([])
  53. // 分页相关状态
  54. @State currentPage: number = 0
  55. @State hasMoreData: boolean = true
  56. private readonly PAGE_SIZE: number = 50
  57. // 用于存储完整数据的引用
  58. private allSongs: VideoItem[] = []
  59. private baseDownloadPath: string = ''
  60. // 回调函数
  61. onConfirm?: (songs: VideoItem[]) => void
  62. onCancel?: () => void
  63. // 添加目录浏览相关状态
  64. @State @Watch('onViewModeChange') viewMode: number[] = [0]; // 0: 全部歌曲, 1: 目录模式
  65. @State currentPath: string = '';
  66. @State folderItems: VideoItem[] = [];
  67. @State selectedFolders: Set<string> = new Set();
  68. @State isFolderLoading: boolean = false;
  69. private table: MediaTable = new MediaTable(getContext(this))
  70. // 用于高效判断歌曲选中状态(避免在大列表中频繁遍历)
  71. @State totalSelectedCount: number = 0;
  72. private selectedSongIds: Set<string> = new Set();
  73. private updateTimer: number = -1;
  74. // SegmentButton选项配置
  75. @State viewModeOptions: SegmentButtonOptions = SegmentButtonOptions.capsule({
  76. buttons: viewModeButtons,
  77. backgroundColor: $r('app.color.index_background'),
  78. selectedBackgroundColor: $r('app.color.start_window_background'),
  79. selectedFontColor: $r('app.color.text_color'),
  80. buttonPadding: { top: 10, bottom: 10 },
  81. multiply: false
  82. });
  83. async aboutToAppear() {
  84. // 初始化深色模式状态
  85. this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
  86. LogUtil.info('heanup AddSongsToPlaylistDialogContent aboutToAppear 开始')
  87. LogUtil.info('heanup 初始化深色模式状态: ' + this.isDarkMode + ', currentMode: ' + this.currentMode)
  88. LogUtil.info('heanup playlist: ' +
  89. (this.playlist ? JSON.stringify({ id: this.playlist.id, name: this.playlist.name }) : 'null'))
  90. console.info('onecold mediaKuList 对话框 aboutToAppear=' + this.mediaKuList.length)
  91. this.allSongs = [...this.mediaKuList]
  92. this.filteredSongs = [...this.mediaKuList]
  93. this.loadInitialData()
  94. this.clearSelection()
  95. this.baseDownloadPath = this.normalizeFsPath(PreferencesUtil.getStringSync('download_path', '/storage/Users/currentUser'))
  96. this.currentPath = this.baseDownloadPath
  97. // 异步加载目录数据
  98. await this.loadFile(this.currentPath)
  99. }
  100. aboutToDisappear() {
  101. // 清理定时器,防止内存泄漏
  102. if (this.updateTimer !== -1) {
  103. clearTimeout(this.updateTimer)
  104. this.updateTimer = -1
  105. }
  106. }
  107. /**
  108. * 加载初始数据
  109. */
  110. private loadInitialData() {
  111. this.currentPage = 0
  112. this.hasMoreData = true
  113. this.updateListData(false)
  114. }
  115. @State fileList: Array<string> = []
  116. async loadFile(curPath: string) {
  117. const fsPath = this.normalizeFsPath(curPath)
  118. this.isFolderLoading = true
  119. this.currentPath = fsPath
  120. let directories: Array<VideoItem> = []
  121. let files: Array<VideoItem> = []
  122. const normalizedCurrent = this.normalizeLocalPath(fsPath)
  123. try {
  124. const entries: Array<string> = fs.listFileSync(fsPath) as Array<string>
  125. this.fileList = entries
  126. for (let i = 0; i < entries.length; i++) {
  127. const name = entries[i]
  128. if (name.startsWith('.')) {
  129. continue
  130. }
  131. const fullPath = `${fsPath}/${name}`
  132. try {
  133. const stat: fs.Stat = fs.statSync(fullPath) as fs.Stat
  134. if (stat.isDirectory()) {
  135. directories.push(new VideoItem(name, name, fullPath, CommonConstants.TYPE_IS_DIR, 0, ''))
  136. } else {
  137. // 仅显示音频文件
  138. if (!Utility.isMeidaByExtension(fullPath)&&!fullPath.endsWith('.lrc')&&!fullPath.endsWith('.srt')) {
  139. continue
  140. }
  141. const normalized = this.normalizeLocalPath(fullPath)
  142. const matched = this.allSongs.find((item: VideoItem) =>
  143. this.normalizeLocalPath(item.filePath) === normalized)
  144. if (matched) {
  145. files.push(matched)
  146. } else {
  147. // 构造最小信息的 VideoItem 兜底显示
  148. const fallback = new VideoItem(name, normalized, fullPath, CommonConstants.TYPE_LOCAL, stat.size, '')
  149. fallback.fileName = name
  150. fallback.parentPath = fsPath
  151. files.push(fallback)
  152. }
  153. }
  154. } catch (error) {
  155. LogUtil.warn('heanup AddSongsToPlaylistDialog', `无法访问: ${fullPath}`)
  156. }
  157. }
  158. // 目录优先,其次按名称排序
  159. this.folderItems = directories.concat(files).sort((a: VideoItem, b: VideoItem) => {
  160. if (a.type === CommonConstants.TYPE_IS_DIR && b.type !== CommonConstants.TYPE_IS_DIR) {
  161. return -1
  162. }
  163. if (a.type !== CommonConstants.TYPE_IS_DIR && b.type === CommonConstants.TYPE_IS_DIR) {
  164. return 1
  165. }
  166. return a.name.localeCompare(b.name)
  167. })
  168. } catch (error) {
  169. LogUtil.error('heanup AddSongsToPlaylistDialog', `加载文件列表失败: ${(error as Error).message}`)
  170. this.fileList = []
  171. this.folderItems = directories
  172. } finally {
  173. this.isFolderLoading = false
  174. }
  175. }
  176. /**
  177. * 用于文件系统访问的路径清洗:去掉多余 //,保留单个前导 /
  178. */
  179. private normalizeFsPath(path: string): string {
  180. if (!path) {
  181. return ''
  182. }
  183. let cleaned = path.replace('file://', '').replace('file://docs', '').replace('docs://', '')
  184. cleaned = cleaned.replace(/\/{2,}/g, '/')
  185. if (!cleaned.startsWith('/')) {
  186. cleaned = '/' + cleaned
  187. }
  188. if (cleaned.endsWith('/') && cleaned.length > 1) {
  189. cleaned = cleaned.slice(0, -1)
  190. }
  191. return cleaned
  192. }
  193. /**
  194. * 安全获取 parentPath,避免因异常抛出导致目录加载中断
  195. */
  196. private safeGetParentPath(item: VideoItem): string {
  197. if (item.parentPath) {
  198. return item.parentPath
  199. }
  200. try {
  201. return FileUtil.getParentPath(item.filePath)
  202. } catch (error) {
  203. const idx = item.filePath?.lastIndexOf('/') ?? -1
  204. if (idx > 0) {
  205. return item.filePath.substring(0, idx)
  206. }
  207. LogUtil.warn('heanup AddSongsToPlaylistDialog', `fallback parentPath for ${item.filePath}: ${(error as Error).message}`)
  208. return ''
  209. }
  210. }
  211. private querySongsByParentPath(path: string): Promise<VideoItem[]> {
  212. return new Promise((resolve) => {
  213. try {
  214. this.table.queryByParentPath(path, (result: VideoItem[]) => resolve(result))
  215. } catch (error) {
  216. LogUtil.error('heanup AddSongsToPlaylistDialog', `queryByParentPath 异常: ${(error as Error).message}`)
  217. resolve([])
  218. }
  219. })
  220. }
  221. onViewModeChange() {
  222. if (this.viewMode && this.viewMode[0] === 1) {
  223. this.loadFile(this.currentPath)
  224. } else {
  225. this.updateSelectedCount()
  226. }
  227. }
  228. private enterFolder(path: string) {
  229. this.loadFile(path)
  230. }
  231. private normalizeLocalPath(path: string): string {
  232. if (!path) {
  233. return ''
  234. }
  235. let normalized = path.replace('file://docs', '').replace('file://', '')
  236. if (normalized.startsWith('/docs/')) {
  237. normalized = normalized.replace('/docs', '')
  238. } else if (normalized.startsWith('docs/')) {
  239. normalized = normalized.substring(4)
  240. if (!normalized.startsWith('/')) {
  241. normalized = '/' + normalized
  242. }
  243. }
  244. if (!normalized.startsWith('/')) {
  245. normalized = '/' + normalized
  246. }
  247. if (normalized.endsWith('/') && normalized.length > 1) {
  248. normalized = normalized.slice(0, -1)
  249. }
  250. return normalized
  251. }
  252. private navigateToParent() {
  253. if (!this.currentPath) {
  254. return
  255. }
  256. // 限制在下载目录之下
  257. if (this.baseDownloadPath && this.normalizeFsPath(this.currentPath) === this.baseDownloadPath) {
  258. LogUtil.info('heanup AddSongsToPlaylistDialog', 'navigateToParent blocked at baseDownloadPath')
  259. return
  260. }
  261. const idx = this.currentPath.lastIndexOf('/')
  262. if (idx <= 0) {
  263. return
  264. }
  265. const parent = this.currentPath.substring(0, idx)
  266. if (!parent || parent === this.currentPath) {
  267. return
  268. }
  269. this.loadFile(parent)
  270. }
  271. private canNavigateUp(): boolean {
  272. if (!this.currentPath) {
  273. return false
  274. }
  275. const current = this.normalizeFsPath(this.currentPath)
  276. if (!this.baseDownloadPath) {
  277. return current.lastIndexOf('/') > 0
  278. }
  279. return current.startsWith(this.baseDownloadPath) && current !== this.baseDownloadPath
  280. }
  281. private toggleSongSelection(song: VideoItem) {
  282. if (song.type === CommonConstants.TYPE_IS_DIR) {
  283. this.toggleFolderSelection(song.filePath)
  284. return
  285. }
  286. const exists = this.selectedSongIds.has(song.id)
  287. if (exists) {
  288. this.selectedSongs = this.selectedSongs.filter((s: VideoItem) => s.id !== song.id)
  289. this.selectedSongIds.delete(song.id)
  290. } else {
  291. this.selectedSongs = [...this.selectedSongs, song]
  292. this.selectedSongIds.add(song.id)
  293. }
  294. this.updateSelectedCount()
  295. }
  296. private toggleFolderSelection(folderPath: string) {
  297. const normalized = this.normalizeLocalPath(folderPath)
  298. const next = new Set(this.selectedFolders)
  299. if (next.has(normalized)) {
  300. next.delete(normalized)
  301. } else {
  302. next.add(normalized)
  303. }
  304. LogUtil.info('heanup AddSongsToPlaylistDialog', `toggleFolderSelection folder=${normalized}, now=${Array.from(next).join(',')}`)
  305. this.selectedFolders = next
  306. this.updateSelectedCount()
  307. }
  308. private clearSelection() {
  309. this.selectedSongs = []
  310. this.selectedFolders = new Set()
  311. this.selectedSongIds.clear()
  312. this.totalSelectedCount = 0
  313. }
  314. private mergeSongs(primary: VideoItem[], extra: VideoItem[]): VideoItem[] {
  315. const map = new Map<string, VideoItem>()
  316. primary.forEach((item: VideoItem) => {
  317. map.set(this.normalizeLocalPath(item.filePath), item)
  318. })
  319. extra.forEach((item: VideoItem) => {
  320. const key = this.normalizeLocalPath(item.filePath)
  321. if (!map.has(key)) {
  322. map.set(key, item)
  323. }
  324. })
  325. return Array.from(map.values())
  326. }
  327. private safeParentFromPath(path: string): string {
  328. if (!path) {
  329. return ''
  330. }
  331. try {
  332. return FileUtil.getParentPath(path)
  333. } catch (error) {
  334. const idx = path.lastIndexOf('/')
  335. return idx > 0 ? path.substring(0, idx) : ''
  336. }
  337. }
  338. private getSongsFromFolders(folderPaths: string[]): VideoItem[] {
  339. if (!folderPaths || folderPaths.length === 0) {
  340. LogUtil.info('heanup AddSongsToPlaylistDialog', 'getSongsFromFolders empty folderPaths')
  341. return []
  342. }
  343. try {
  344. // 使用 Set 优化查找性能,并统一路径格式
  345. const normalizedSet = new Set(folderPaths.map((path: string) => this.normalizeLocalPath(path)))
  346. const result: VideoItem[] = []
  347. // 单次遍历,避免嵌套循环
  348. for (let i = 0; i < this.allSongs.length; i++) {
  349. const item = this.allSongs[i]
  350. if (!item || item.type === CommonConstants.TYPE_IS_DIR || !item.filePath) {
  351. continue
  352. }
  353. const parentPath = this.normalizeLocalPath(item.parentPath || this.safeParentFromPath(item.filePath))
  354. const filePath = this.normalizeLocalPath(item.filePath)
  355. // 直接使用 Set 查找,O(1) 时间复杂度
  356. if (normalizedSet.has(parentPath)) {
  357. result.push(item)
  358. continue
  359. }
  360. // 检查是否在子目录中(这部分仍需遍历,但通常文件夹数量不多)
  361. for (const folder of normalizedSet) {
  362. if (filePath.startsWith(folder + '/')) {
  363. result.push(item)
  364. break
  365. }
  366. }
  367. }
  368. // 额外兜底:将当前目录列表中展示的文件也纳入(防止媒体表尚未入库时无法计数)
  369. this.folderItems.forEach((item: VideoItem) => {
  370. if (!item || item.type === CommonConstants.TYPE_IS_DIR) {
  371. return
  372. }
  373. const parentPath = this.normalizeLocalPath(item.parentPath || this.safeParentFromPath(item.filePath))
  374. const filePath = this.normalizeLocalPath(item.filePath)
  375. if (normalizedSet.has(parentPath) || Array.from(normalizedSet).some(folder => filePath.startsWith(folder + '/'))) {
  376. result.push(item)
  377. }
  378. })
  379. LogUtil.info('heanup AddSongsToPlaylistDialog', `getSongsFromFolders folders=${Array.from(normalizedSet).join(',')}, result=${result.length}`)
  380. return result
  381. } catch (error) {
  382. LogUtil.error('heanup AddSongsToPlaylistDialog', `getSongsFromFolders error: ${(error as Error).message}`)
  383. return []
  384. }
  385. }
  386. private updateSelectedCount() {
  387. // 清除之前的定时器,实现防抖
  388. if (this.updateTimer !== -1) {
  389. clearTimeout(this.updateTimer)
  390. }
  391. // 延迟执行计算,避免阻塞主线程
  392. this.updateTimer = setTimeout(() => {
  393. try {
  394. const folderSongs = this.getSongsFromFolders(Array.from(this.selectedFolders))
  395. const merged = this.mergeSongs(
  396. this.selectedSongs.filter((item: VideoItem) => item.type !== CommonConstants.TYPE_IS_DIR),
  397. folderSongs
  398. )
  399. this.totalSelectedCount = merged.length
  400. LogUtil.info('heanup AddSongsToPlaylistDialog', `updateSelectedCount folders=${Array.from(this.selectedFolders).join(',')}, folderSongs=${folderSongs.length}, directSongs=${this.selectedSongs.length}, total=${this.totalSelectedCount}`)
  401. } catch (error) {
  402. LogUtil.error('heanup AddSongsToPlaylistDialog', `更新选中数量失败: ${(error as Error).message}`)
  403. // 发生错误时至少显示已选歌曲数量
  404. this.totalSelectedCount = this.selectedSongs.filter(
  405. (item: VideoItem) => item.type !== CommonConstants.TYPE_IS_DIR
  406. ).length
  407. }
  408. this.updateTimer = -1
  409. }, 50)
  410. }
  411. /**
  412. * 异步全选歌曲,分批处理避免阻塞主线程
  413. */
  414. private selectAllSongsAsync() {
  415. const allSongs = this.filteredSongs && this.filteredSongs.length > 0 ? this.filteredSongs : this.allSongs
  416. LogUtil.info('heanup AddSongsToPlaylistDialog', `selectAllSongsAsync start, total=${allSongs.length}`)
  417. const BATCH_SIZE = 500 // 单批进一步增大,减少全选等待时间
  418. let currentIndex = 0
  419. const tempSelected: VideoItem[] = [...this.selectedSongs] // 保留已选歌曲
  420. const tempIds = new Set(this.selectedSongIds)
  421. const processBatch = () => {
  422. const endIndex = Math.min(currentIndex + BATCH_SIZE, allSongs.length)
  423. const batch = allSongs.slice(currentIndex, endIndex)
  424. // 批量添加到临时列表和 Set
  425. batch.forEach((song: VideoItem) => {
  426. if (!tempIds.has(song.id)) {
  427. tempSelected.push(song)
  428. tempIds.add(song.id)
  429. }
  430. })
  431. // 重新赋值触发响应式更新
  432. this.selectedSongs = [...tempSelected]
  433. this.selectedSongIds = new Set(tempIds)
  434. currentIndex = endIndex
  435. if (currentIndex < allSongs.length) {
  436. // 还有更多数据,继续处理下一批
  437. setTimeout(processBatch, 0) // 使用 setTimeout 让出主线程
  438. } else {
  439. // 全部处理完成,更新计数
  440. this.updateSelectedCount()
  441. LogUtil.info('AddSongsToPlaylistDialog', `全选完成,共选中 ${this.selectedSongs.length} 首歌曲`)
  442. }
  443. }
  444. // 开始第一批处理
  445. processBatch()
  446. }
  447. /**
  448. * 搜索过滤歌曲
  449. */
  450. filterSongs() {
  451. this.isSearchMode = true
  452. if (!this.searchText.trim()) {
  453. this.filteredSongs = [...this.allSongs]
  454. } else {
  455. // 添加空值检查以防止TypeError
  456. if (!this.allSongs || !Array.isArray(this.allSongs)) {
  457. this.filteredSongs = []
  458. } else {
  459. this.filteredSongs = this.allSongs.filter(item => {
  460. //支持模糊匹配和艺术家 专辑匹配
  461. const regex = new RegExp(this.searchText.replace(/\s+/g, '.*'), 'i');
  462. return regex.test(item.name.toLowerCase()) ||
  463. regex.test(item.fileName?.toLowerCase() ?? "") ||
  464. regex.test(item.artist?.toLowerCase() ?? "") ||
  465. regex.test(item.album?.toLowerCase() ?? "")
  466. })
  467. }
  468. }
  469. // 重置分页状态并重新加载数据
  470. this.loadInitialData()
  471. }
  472. // 修改 updateListData 方法,使其更清晰
  473. updateListData(append: boolean = false) {
  474. this.getUIContext().animateTo({ duration: 666 }, () => {
  475. this.opacityItem = 0;
  476. })
  477. setTimeout(() => {
  478. const sourceData = this.filteredSongs
  479. let dataToShow: VideoItem[] = []
  480. if (append) {
  481. // 追加数据模式
  482. const currentData = this.dataSource.dataArray
  483. const startIndex = this.currentPage * this.PAGE_SIZE
  484. const endIndex = Math.min(startIndex + this.PAGE_SIZE, sourceData.length)
  485. const newData = sourceData.slice(startIndex, endIndex)
  486. if (newData.length > 0) {
  487. dataToShow = [...currentData, ...newData]
  488. this.currentPage++
  489. }
  490. } else {
  491. // 初始加载模式
  492. this.currentPage = 1
  493. const endIndex = Math.min(this.PAGE_SIZE, sourceData.length)
  494. dataToShow = sourceData.slice(0, endIndex)
  495. }
  496. // 更新数据源
  497. this.dataSource.pushArrayData(dataToShow)
  498. // 检查是否还有更多数据
  499. const totalLoaded = dataToShow.length
  500. this.hasMoreData = totalLoaded < sourceData.length
  501. this.getUIContext().animateTo({ duration: 666 }, () => {
  502. this.opacityItem = 1
  503. })
  504. }, 200)
  505. }
  506. loadMoreData() {
  507. console.info('loadMoreData called, hasMoreData:', this.hasMoreData, 'isLoading:', this.isLoading)
  508. if (!this.hasMoreData || this.isLoading) {
  509. console.info('loadMoreData skipped - no more data or already loading')
  510. return
  511. }
  512. console.info('loadMoreData executing')
  513. this.isLoading = true
  514. // 使用 setTimeout 模拟异步加载
  515. setTimeout(() => {
  516. this.updateListData(true) // append mode
  517. this.isLoading = false
  518. console.info('loadMoreData completed, current data length:', this.dataSource.dataArray.length)
  519. }, 50)
  520. }
  521. build() {
  522. Column({ space: 16 }) {
  523. // 搜索框
  524. Row({ space: 2 }) {
  525. Image($r('app.media.ic_action_search'))
  526. .width(20)
  527. .height(20)
  528. .padding({ left: 5})
  529. .fillColor(this.isDarkMode ? '#8E8E93' : $r('app.color.text_color'))
  530. .opacity(0.6)
  531. TextInput({ placeholder: '搜索歌曲、歌手或专辑', text: this.searchText })
  532. .layoutWeight(1)
  533. .height(35)
  534. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  535. .borderRadius(8)
  536. .padding({ left: 8, right: 8 })
  537. .fontSize(11)
  538. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  539. .placeholderColor(this.isDarkMode ? '#8E8E93' : '#999999')
  540. .placeholderFont({ size:11 })
  541. .onChange((value: string) => {
  542. this.searchText = value
  543. this.filterSongs()
  544. })
  545. }
  546. .width('100%')
  547. .padding(2)
  548. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  549. .borderRadius(20)
  550. // 视图模式切换 SegmentButton
  551. SegmentButton({
  552. options: this.viewModeOptions,
  553. selectedIndexes: $viewMode
  554. })
  555. .width('100%')
  556. .margin({ top: 2 })
  557. // 已选择歌曲数量(包含目录展开后的歌曲)
  558. if (this.totalSelectedCount > 0) {
  559. Row() {
  560. Text(`已选择 ${this.totalSelectedCount} 首歌曲`)
  561. .fontSize(14)
  562. .fontColor(this.isDarkMode ? '#FFFFFF' : this.themeColor)
  563. .fontWeight(FontWeight.Medium)
  564. Blank()
  565. if (this.viewMode[0] === 0) {
  566. Button((this.selectedSongs.length === this.dataSource.dataArray.length
  567. && this.dataSource.dataArray.length > 0) ? '全不选' : '全选')
  568. .fontSize(12)
  569. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  570. .backgroundColor(Color.Transparent)
  571. .height(30)
  572. .padding({ left: 8, right: 8 })
  573. .onClick(() => {
  574. if (this.selectedSongs.length === this.dataSource.dataArray.length
  575. && this.dataSource.dataArray.length > 0) {
  576. // 全不选:清空所有选择
  577. this.selectedSongs = []
  578. this.selectedSongIds.clear()
  579. this.updateSelectedCount()
  580. } else {
  581. // 全选:异步分批处理,避免阻塞主线程
  582. this.selectAllSongsAsync()
  583. }
  584. })
  585. }
  586. Button('清空')
  587. .fontSize(12)
  588. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  589. .backgroundColor(Color.Transparent)
  590. .height(30)
  591. .padding({ left: 8, right: 8 })
  592. .onClick(() => {
  593. this.clearSelection()
  594. })
  595. }
  596. .width('100%')
  597. .padding({ left: 4, right: 4 })
  598. }
  599. // 歌曲列表或目录列表
  600. if (this.viewMode[0] === 0) {
  601. // 全部歌曲模式
  602. if (this.dataSource.dataArray.length === 0 && this.isSearchMode) {
  603. Column({ space: 12 }) {
  604. Image($r('app.media.music_red'))
  605. .width(64)
  606. .height(64)
  607. .opacity(0.3)
  608. Text(this.searchText ? '没有找到匹配的歌曲' : '没有可添加的歌曲')
  609. .fontSize(14)
  610. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  611. }
  612. .width('100%')
  613. .height(300)
  614. .justifyContent(FlexAlign.Center)
  615. } else {
  616. this.getListView()
  617. }
  618. } else {
  619. // 目录模式
  620. this.getFolderView()
  621. }
  622. // 按钮区域
  623. Row({ space: 12 }) {
  624. Button('取消')
  625. .width('45%')
  626. .height(40)
  627. .borderRadius(8)
  628. .fontSize(14)
  629. .backgroundColor(this.isDarkMode ? themeColorWithAlpha(this.themeColor, 0.8, this.isDarkMode)
  630. :$r('app.color.cancel_button_background') )
  631. .fontColor($r('app.color.cancel_button_text'))
  632. .onClick(() => {
  633. this.onCancel?.()
  634. DialogHelper.closeDialog('addSongsToPlaylistDialog')
  635. })
  636. Button(`添加${this.totalSelectedCount > 0 ? `(${this.totalSelectedCount})` : ''}`)
  637. .width('45%')
  638. .height(40)
  639. .backgroundColor(this.isDarkMode ? $r('app.color.silvery'):this.themeColor)
  640. .borderRadius(8)
  641. .fontSize(14)
  642. .fontColor(Color.White)
  643. .enabled(this.totalSelectedCount > 0)
  644. .opacity(this.totalSelectedCount > 0 ? 1 : 0.5)
  645. .onClick(() => {
  646. this.handleConfirm()
  647. })
  648. }
  649. .width('100%')
  650. .justifyContent(FlexAlign.SpaceBetween)
  651. .margin({ top: 10, bottom: 10 })
  652. }
  653. .width('100%')
  654. .constraintSize({ maxWidth: 400 })
  655. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.dialog_background'))
  656. .borderRadius(12)
  657. .padding({ left: 20, right: 20 })
  658. }
  659. /**
  660. * 处理确认操作
  661. */
  662. private handleConfirm() {
  663. const folderSongs = this.getSongsFromFolders(Array.from(this.selectedFolders))
  664. const submitSongs = this.mergeSongs(
  665. this.selectedSongs.filter((item: VideoItem) => item.type !== CommonConstants.TYPE_IS_DIR),
  666. folderSongs
  667. )
  668. LogUtil.info('heanup AddSongsToPlaylistDialog', `handleConfirm selectedFolders=${Array.from(this.selectedFolders).join(',')}, folderSongs=${folderSongs.length}, directSongs=${this.selectedSongs.length}, submit=${submitSongs.length}`)
  669. if (submitSongs.length === 0) {
  670. ToastUtil.showToast(' 请选择至少一首歌曲')
  671. return
  672. }
  673. this.onConfirm?.(submitSongs)
  674. DialogHelper.closeDialog('addSongsToPlaylistDialog')
  675. }
  676. @Builder
  677. getListView() {
  678. Scroll() {
  679. Column({ space: 0 }) {
  680. List({ scroller: this.listScroller }) {
  681. LazyForEach(this.dataSource, (song: VideoItem, index: number) => {
  682. ListItem() {
  683. Row({ space: 12 }) {
  684. Image(StrUtil.isEmpty(song.pixelMapPath) ? $r('app.media.music_red') : song.pixelMapPath)
  685. .fillColor(StrUtil.isEmpty(song.pixelMapPath) ? (this.isDarkMode ? Color.White : this.themeColor) : undefined)
  686. .height(40)
  687. .width(40)
  688. .alt($r('app.media.music_red'))
  689. .borderRadius('100%')
  690. .clip(true)
  691. .draggable(false)
  692. .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿
  693. .autoResize(true) // 重采样,可减少内存占用
  694. .opacity(this.opacityItem) // 绑定透明度
  695. // 歌曲信息
  696. Column({ space: 4 }) {
  697. Text(song.name || '未知歌曲')
  698. .fontSize(14)
  699. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  700. .maxLines(1)
  701. .textOverflow({ overflow: TextOverflow.Ellipsis })
  702. .width('100%')
  703. Row() {
  704. if (song.artist) {
  705. Text(song.artist+' '+song.duration)
  706. .fontSize(12)
  707. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  708. .maxLines(1)
  709. .textOverflow({ overflow: TextOverflow.Ellipsis })
  710. .layoutWeight(1)
  711. }
  712. }
  713. .width('100%')
  714. }
  715. .alignItems(HorizontalAlign.Start)
  716. .layoutWeight(1)
  717. // 选择框
  718. Checkbox({ name: 'song_' + song.id })
  719. .select(this.selectedSongIds.has(song.id))
  720. .selectedColor($r('app.color.theme_color'))
  721. .shape(CheckBoxShape.ROUNDED_SQUARE)
  722. .onChange(() => {
  723. this.toggleSongSelection(song)
  724. })
  725. }
  726. .width('100%')
  727. .padding(12)
  728. .borderRadius(10)
  729. .onClick(() => {
  730. this.toggleSongSelection(song)
  731. })
  732. }
  733. .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 })
  734. .animation({ duration: 500 }),
  735. TransitionEffect.scale({ x: 0, y: 0 })))
  736. .clickEffect({ level: ClickEffectLevel.LIGHT })
  737. }, (song: VideoItem) => song.id)
  738. // 添加 footer 来显示加载状态
  739. ListItem() {
  740. this.footer()
  741. }
  742. .visibility(this.hasMoreData ? Visibility.Visible : Visibility.None)
  743. }
  744. .cachedCount(6)
  745. .height('100%')
  746. .scrollBar(BarState.Off)
  747. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  748. .onReachEnd(() => {
  749. // 滚动到底部时加载更多数据
  750. console.info('List onReachEnd triggered')
  751. this.loadMoreData()
  752. })
  753. }
  754. }
  755. .scrollBar(BarState.Auto)
  756. .scrollable(ScrollDirection.Vertical)
  757. .height(300)
  758. }
  759. @Builder
  760. getFolderView() {
  761. Column({ space: 12 }) {
  762. Row({ space: 8 }) {
  763. Text('目录视图')
  764. .fontSize(16)
  765. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  766. Blank()
  767. Button('上一级')
  768. .fontSize(12)
  769. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  770. .height(28)
  771. .padding({ left: 10, right: 10 })
  772. .backgroundColor(Color.Transparent)
  773. .border({ width: 1, color: this.isDarkMode ? '#444444' : '#dddddd' })
  774. .borderRadius(14)
  775. .enabled(this.canNavigateUp())
  776. .opacity(this.canNavigateUp() ? 1 : 0.4)
  777. .onClick(() => {
  778. this.navigateToParent()
  779. })
  780. Button('刷新')
  781. .fontSize(12)
  782. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  783. .height(28)
  784. .padding({ left: 10, right: 10 })
  785. .backgroundColor(Color.Transparent)
  786. .border({ width: 1, color: this.isDarkMode ? '#444444' : '#dddddd' })
  787. .borderRadius(14)
  788. .onClick(() => {
  789. this.loadFile(this.currentPath)
  790. })
  791. }
  792. .width('100%')
  793. Text(this.normalizeLocalPath(this.currentPath) || '未选择路径')
  794. .fontSize(12)
  795. .fontColor(this.isDarkMode ? '#8E8E93' : '#666666')
  796. .maxLines(1)
  797. .textOverflow({ overflow: TextOverflow.Ellipsis })
  798. .padding({ left: 4, right: 4 })
  799. if (this.isFolderLoading) {
  800. Row() {
  801. LoadingProgress()
  802. .width(20)
  803. .height(20)
  804. Text('正在加载目录...')
  805. .fontSize(12)
  806. .fontColor(this.isDarkMode ? '#8E8E93' : '#666666')
  807. .margin({ left: 8 })
  808. }
  809. .width('100%')
  810. .height(80)
  811. .justifyContent(FlexAlign.Center)
  812. } else {
  813. Scroll() {
  814. Column() {
  815. ForEach(this.folderItems, (item: VideoItem, index: number) => {
  816. Row() {
  817. Text(item.type === CommonConstants.TYPE_IS_DIR ? '📁' : '🎵')
  818. .fontSize(20)
  819. .margin({ right: 6 })
  820. Column({ space: 2 }) {
  821. Text(item.name || '未知歌曲')
  822. .fontSize(14)
  823. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  824. .maxLines(1)
  825. .textOverflow({ overflow: TextOverflow.Ellipsis })
  826. if (item.artist && item.type !== CommonConstants.TYPE_IS_DIR) {
  827. Text(item.artist + ' ' + (item.duration || ''))
  828. .fontSize(12)
  829. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  830. .maxLines(1)
  831. .textOverflow({ overflow: TextOverflow.Ellipsis })
  832. } else if (item.type === CommonConstants.TYPE_IS_DIR) {
  833. Text('包含子目录所有音乐')
  834. .fontSize(11)
  835. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  836. .maxLines(1)
  837. }
  838. }
  839. .alignItems(HorizontalAlign.Start)
  840. .layoutWeight(1)
  841. .margin({ left: 8 })
  842. if (item.type === CommonConstants.TYPE_IS_DIR) {
  843. Button() {
  844. Text('进入')
  845. .fontSize(12)
  846. .fontColor(this.isDarkMode ? '#FFFFFF' : this.themeColor)
  847. }
  848. .height(30)
  849. .padding({ left: 10, right: 10 })
  850. .backgroundColor(this.isDarkMode ? '#3a3a3c' : '#f1f1f1')
  851. .borderRadius(8)
  852. .margin({ right: 8 })
  853. .onClick(() => {
  854. this.enterFolder(item.filePath)
  855. })
  856. }
  857. Checkbox({ name: 'folder_item_' + item.id })
  858. .select((item.type === CommonConstants.TYPE_IS_DIR)
  859. ? this.selectedFolders.has(this.normalizeLocalPath(item.filePath))
  860. : this.selectedSongIds.has(item.id))
  861. .selectedColor($r('app.color.theme_color'))
  862. .onChange(() => {
  863. if (item.type === CommonConstants.TYPE_IS_DIR) {
  864. this.toggleFolderSelection(item.filePath)
  865. } else {
  866. this.toggleSongSelection(item)
  867. }
  868. })
  869. }
  870. .width('100%')
  871. .padding(12)
  872. .backgroundColor(this.isDarkMode ? '#2C2C2E' : '#F8F8F8')
  873. .borderRadius(8)
  874. .margin({ bottom: 4 })
  875. .onClick(() => {
  876. if (item.type === CommonConstants.TYPE_IS_DIR) {
  877. this.toggleFolderSelection(item.filePath)
  878. } else {
  879. this.toggleSongSelection(item)
  880. }
  881. })
  882. }, (item: VideoItem) => item.id)
  883. }
  884. }
  885. .height(300)
  886. }
  887. }
  888. .width('100%')
  889. }
  890. // 改进的 footer Builder
  891. @Builder
  892. footer() {
  893. Column() {
  894. if (this.isLoading) {
  895. Row() {
  896. LoadingProgress()
  897. .height(20)
  898. .width(20)
  899. Text('加载中...')
  900. .fontSize(12)
  901. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  902. .margin({ left: 8 })
  903. }
  904. .width('100%')
  905. .height(40)
  906. .justifyContent(FlexAlign.Center)
  907. } else if (this.hasMoreData) {
  908. Row() {
  909. Text('上拉加载更多')
  910. .fontSize(12)
  911. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  912. }
  913. .width('100%')
  914. .height(40)
  915. .justifyContent(FlexAlign.Center)
  916. }
  917. }
  918. .width('100%')
  919. }
  920. }
  921. /**
  922. * 添加歌曲到歌单对话框管理器
  923. */
  924. @Component
  925. export struct AddSongsToPlaylistDialogManager {
  926. /**
  927. * 添加歌曲到歌单对话框构建器
  928. */
  929. @Builder
  930. buildAddSongsToPlaylistDialog(
  931. playlist: Playlist,
  932. onConfirm: (songs: VideoItem[]) => void,
  933. onCancel?: () => void,
  934. ) {
  935. AddSongsToPlaylistDialogContent({
  936. playlist: playlist,
  937. onConfirm: onConfirm,
  938. onCancel: onCancel
  939. })
  940. }
  941. /**
  942. * 显示添加歌曲到歌单对话框
  943. */
  944. showAddSongsToPlaylistDialog(
  945. playlist: Playlist,
  946. onConfirm: (songs: VideoItem[]) => void,
  947. onCancel?: () => void,
  948. ) {
  949. DialogHelper.showCustomContentDialog({
  950. dialogId: 'addSongsToPlaylistDialog',
  951. title: '添加歌曲到歌单',
  952. autoCancel: true,
  953. contentBuilder: () => {
  954. this.buildAddSongsToPlaylistDialog(playlist, onConfirm,onCancel)
  955. },
  956. buttons: []
  957. })
  958. }
  959. build() {
  960. }
  961. }
  962. // 创建全局实例
  963. const dialogManager = new AddSongsToPlaylistDialogManager()
  964. /**
  965. * 显示添加歌曲到歌单对话框
  966. */
  967. export function showAddSongsToPlaylistDialog(
  968. playlist: Playlist,
  969. onConfirm: (songs: VideoItem[]) => void,
  970. onCancel?: () => void,
  971. ) {
  972. dialogManager.showAddSongsToPlaylistDialog(playlist, onConfirm, onCancel)
  973. }