AddSongsToPlaylistDialog.ets 36 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061
  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)) {
  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: 8 }) {
  525. Image($r('app.media.ic_action_search'))
  526. .width(20)
  527. .height(20)
  528. .fillColor(this.isDarkMode ? '#8E8E93' : $r('app.color.text_color'))
  529. .opacity(0.6)
  530. TextInput({ placeholder: '搜索歌曲、歌手或专辑', text: this.searchText })
  531. .layoutWeight(1)
  532. .height(35)
  533. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  534. .borderRadius(8)
  535. .padding({ left: 8, right: 8 })
  536. .fontSize(14)
  537. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  538. .placeholderColor(this.isDarkMode ? '#8E8E93' : '#999999')
  539. .onChange((value: string) => {
  540. this.searchText = value
  541. this.filterSongs()
  542. })
  543. }
  544. .width('100%')
  545. .padding(12)
  546. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  547. .borderRadius(20)
  548. // 视图模式切换 SegmentButton
  549. SegmentButton({
  550. options: this.viewModeOptions,
  551. selectedIndexes: $viewMode
  552. })
  553. .width('100%')
  554. .margin({ top: 8 })
  555. // 已选择歌曲数量(包含目录展开后的歌曲)
  556. if (this.totalSelectedCount > 0) {
  557. Row() {
  558. Text(`已选择 ${this.totalSelectedCount} 首歌曲`)
  559. .fontSize(14)
  560. .fontColor(this.isDarkMode ? '#FFFFFF' : this.themeColor)
  561. .fontWeight(FontWeight.Medium)
  562. Blank()
  563. if (this.viewMode[0] === 0) {
  564. Button((this.selectedSongs.length === this.dataSource.dataArray.length
  565. && this.dataSource.dataArray.length > 0) ? '全不选' : '全选')
  566. .fontSize(12)
  567. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  568. .backgroundColor(Color.Transparent)
  569. .height(30)
  570. .padding({ left: 8, right: 8 })
  571. .onClick(() => {
  572. if (this.selectedSongs.length === this.dataSource.dataArray.length
  573. && this.dataSource.dataArray.length > 0) {
  574. // 全不选:清空所有选择
  575. this.selectedSongs = []
  576. this.selectedSongIds.clear()
  577. this.updateSelectedCount()
  578. } else {
  579. // 全选:异步分批处理,避免阻塞主线程
  580. this.selectAllSongsAsync()
  581. }
  582. })
  583. }
  584. Button('清空')
  585. .fontSize(12)
  586. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  587. .backgroundColor(Color.Transparent)
  588. .height(30)
  589. .padding({ left: 8, right: 8 })
  590. .onClick(() => {
  591. this.clearSelection()
  592. })
  593. }
  594. .width('100%')
  595. .padding({ left: 4, right: 4 })
  596. }
  597. // 歌曲列表或目录列表
  598. if (this.viewMode[0] === 0) {
  599. // 全部歌曲模式
  600. if (this.dataSource.dataArray.length === 0 && this.isSearchMode) {
  601. Column({ space: 12 }) {
  602. Image($r('app.media.music_red'))
  603. .width(64)
  604. .height(64)
  605. .opacity(0.3)
  606. Text(this.searchText ? '没有找到匹配的歌曲' : '没有可添加的歌曲')
  607. .fontSize(14)
  608. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  609. }
  610. .width('100%')
  611. .height(300)
  612. .justifyContent(FlexAlign.Center)
  613. } else {
  614. this.getListView()
  615. }
  616. } else {
  617. // 目录模式
  618. this.getFolderView()
  619. }
  620. // 按钮区域
  621. Row({ space: 12 }) {
  622. Button('取消')
  623. .width('45%')
  624. .height(40)
  625. .borderRadius(8)
  626. .fontSize(14)
  627. .backgroundColor(this.isDarkMode ? themeColorWithAlpha(this.themeColor, 0.8, this.isDarkMode)
  628. :$r('app.color.cancel_button_background') )
  629. .fontColor($r('app.color.cancel_button_text'))
  630. .onClick(() => {
  631. this.onCancel?.()
  632. DialogHelper.closeDialog('addSongsToPlaylistDialog')
  633. })
  634. Button(`添加${this.totalSelectedCount > 0 ? `(${this.totalSelectedCount})` : ''}`)
  635. .width('45%')
  636. .height(40)
  637. .backgroundColor(this.isDarkMode ? $r('app.color.silvery'):this.themeColor)
  638. .borderRadius(8)
  639. .fontSize(14)
  640. .fontColor(Color.White)
  641. .enabled(this.totalSelectedCount > 0)
  642. .opacity(this.totalSelectedCount > 0 ? 1 : 0.5)
  643. .onClick(() => {
  644. this.handleConfirm()
  645. })
  646. }
  647. .width('100%')
  648. .justifyContent(FlexAlign.SpaceBetween)
  649. .margin({ top: 10, bottom: 10 })
  650. }
  651. .width('100%')
  652. .constraintSize({ maxWidth: 400 })
  653. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.dialog_background'))
  654. .borderRadius(12)
  655. .padding({ left: 20, right: 20 })
  656. }
  657. /**
  658. * 处理确认操作
  659. */
  660. private handleConfirm() {
  661. const folderSongs = this.getSongsFromFolders(Array.from(this.selectedFolders))
  662. const submitSongs = this.mergeSongs(
  663. this.selectedSongs.filter((item: VideoItem) => item.type !== CommonConstants.TYPE_IS_DIR),
  664. folderSongs
  665. )
  666. LogUtil.info('heanup AddSongsToPlaylistDialog', `handleConfirm selectedFolders=${Array.from(this.selectedFolders).join(',')}, folderSongs=${folderSongs.length}, directSongs=${this.selectedSongs.length}, submit=${submitSongs.length}`)
  667. if (submitSongs.length === 0) {
  668. ToastUtil.showToast(' 请选择至少一首歌曲')
  669. return
  670. }
  671. this.onConfirm?.(submitSongs)
  672. DialogHelper.closeDialog('addSongsToPlaylistDialog')
  673. }
  674. @Builder
  675. getListView() {
  676. Scroll() {
  677. Column({ space: 0 }) {
  678. List({ scroller: this.listScroller }) {
  679. LazyForEach(this.dataSource, (song: VideoItem, index: number) => {
  680. ListItem() {
  681. Row({ space: 12 }) {
  682. Image(StrUtil.isEmpty(song.pixelMapPath) ? $r('app.media.music_red') : song.pixelMapPath)
  683. .fillColor(StrUtil.isEmpty(song.pixelMapPath) ? (this.isDarkMode ? Color.White : this.themeColor) : undefined)
  684. .height(40)
  685. .width(40)
  686. .alt($r('app.media.music_red'))
  687. .borderRadius('100%')
  688. .clip(true)
  689. .draggable(false)
  690. .interpolation(ImageInterpolation.High)// 用于重采样后的抗锯齿
  691. .autoResize(true) // 重采样,可减少内存占用
  692. .opacity(this.opacityItem) // 绑定透明度
  693. // 歌曲信息
  694. Column({ space: 4 }) {
  695. Text(song.name || '未知歌曲')
  696. .fontSize(14)
  697. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  698. .maxLines(1)
  699. .textOverflow({ overflow: TextOverflow.Ellipsis })
  700. .width('100%')
  701. Row() {
  702. if (song.artist) {
  703. Text(song.artist+' '+song.duration)
  704. .fontSize(12)
  705. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  706. .maxLines(1)
  707. .textOverflow({ overflow: TextOverflow.Ellipsis })
  708. .layoutWeight(1)
  709. }
  710. }
  711. .width('100%')
  712. }
  713. .alignItems(HorizontalAlign.Start)
  714. .layoutWeight(1)
  715. // 选择框
  716. Checkbox({ name: 'song_' + song.id })
  717. .select(this.selectedSongIds.has(song.id))
  718. .selectedColor($r('app.color.theme_color'))
  719. .shape(CheckBoxShape.ROUNDED_SQUARE)
  720. .onChange(() => {
  721. this.toggleSongSelection(song)
  722. })
  723. }
  724. .width('100%')
  725. .padding(12)
  726. .borderRadius(10)
  727. .onClick(() => {
  728. this.toggleSongSelection(song)
  729. })
  730. }
  731. .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 })
  732. .animation({ duration: 500 }),
  733. TransitionEffect.scale({ x: 0, y: 0 })))
  734. .clickEffect({ level: ClickEffectLevel.LIGHT })
  735. }, (song: VideoItem) => song.id)
  736. // 添加 footer 来显示加载状态
  737. ListItem() {
  738. this.footer()
  739. }
  740. .visibility(this.hasMoreData ? Visibility.Visible : Visibility.None)
  741. }
  742. .cachedCount(6)
  743. .height('100%')
  744. .scrollBar(BarState.Off)
  745. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  746. .onReachEnd(() => {
  747. // 滚动到底部时加载更多数据
  748. console.info('List onReachEnd triggered')
  749. this.loadMoreData()
  750. })
  751. }
  752. }
  753. .scrollBar(BarState.Auto)
  754. .scrollable(ScrollDirection.Vertical)
  755. .height(300)
  756. }
  757. @Builder
  758. getFolderView() {
  759. Column({ space: 12 }) {
  760. Row({ space: 8 }) {
  761. Text('目录视图')
  762. .fontSize(16)
  763. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  764. Blank()
  765. Button('上一级')
  766. .fontSize(12)
  767. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  768. .height(28)
  769. .padding({ left: 10, right: 10 })
  770. .backgroundColor(Color.Transparent)
  771. .border({ width: 1, color: this.isDarkMode ? '#444444' : '#dddddd' })
  772. .borderRadius(14)
  773. .enabled(this.canNavigateUp())
  774. .opacity(this.canNavigateUp() ? 1 : 0.4)
  775. .onClick(() => {
  776. this.navigateToParent()
  777. })
  778. Button('刷新')
  779. .fontSize(12)
  780. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  781. .height(28)
  782. .padding({ left: 10, right: 10 })
  783. .backgroundColor(Color.Transparent)
  784. .border({ width: 1, color: this.isDarkMode ? '#444444' : '#dddddd' })
  785. .borderRadius(14)
  786. .onClick(() => {
  787. this.loadFile(this.currentPath)
  788. })
  789. }
  790. .width('100%')
  791. Text(this.normalizeLocalPath(this.currentPath) || '未选择路径')
  792. .fontSize(12)
  793. .fontColor(this.isDarkMode ? '#8E8E93' : '#666666')
  794. .maxLines(1)
  795. .textOverflow({ overflow: TextOverflow.Ellipsis })
  796. .padding({ left: 4, right: 4 })
  797. if (this.isFolderLoading) {
  798. Row() {
  799. LoadingProgress()
  800. .width(20)
  801. .height(20)
  802. Text('正在加载目录...')
  803. .fontSize(12)
  804. .fontColor(this.isDarkMode ? '#8E8E93' : '#666666')
  805. .margin({ left: 8 })
  806. }
  807. .width('100%')
  808. .height(80)
  809. .justifyContent(FlexAlign.Center)
  810. } else {
  811. Scroll() {
  812. Column() {
  813. ForEach(this.folderItems, (item: VideoItem, index: number) => {
  814. Row() {
  815. Text(item.type === CommonConstants.TYPE_IS_DIR ? '📁' : '🎵')
  816. .fontSize(20)
  817. .margin({ right: 6 })
  818. Column({ space: 2 }) {
  819. Text(item.name || '未知歌曲')
  820. .fontSize(14)
  821. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  822. .maxLines(1)
  823. .textOverflow({ overflow: TextOverflow.Ellipsis })
  824. if (item.artist && item.type !== CommonConstants.TYPE_IS_DIR) {
  825. Text(item.artist + ' ' + (item.duration || ''))
  826. .fontSize(12)
  827. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  828. .maxLines(1)
  829. .textOverflow({ overflow: TextOverflow.Ellipsis })
  830. } else if (item.type === CommonConstants.TYPE_IS_DIR) {
  831. Text('包含子目录内的所有音乐将一起添加')
  832. .fontSize(12)
  833. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  834. .maxLines(1)
  835. }
  836. }
  837. .layoutWeight(1)
  838. .margin({ left: 8 })
  839. if (item.type === CommonConstants.TYPE_IS_DIR) {
  840. Button() {
  841. Text('进入')
  842. .fontSize(12)
  843. .fontColor(this.isDarkMode ? '#FFFFFF' : this.themeColor)
  844. }
  845. .height(30)
  846. .padding({ left: 10, right: 10 })
  847. .backgroundColor(this.isDarkMode ? '#3a3a3c' : '#f1f1f1')
  848. .borderRadius(8)
  849. .margin({ right: 8 })
  850. .onClick(() => {
  851. this.enterFolder(item.filePath)
  852. })
  853. }
  854. Checkbox({ name: 'folder_item_' + item.id })
  855. .select((item.type === CommonConstants.TYPE_IS_DIR)
  856. ? this.selectedFolders.has(this.normalizeLocalPath(item.filePath))
  857. : this.selectedSongIds.has(item.id))
  858. .selectedColor($r('app.color.theme_color'))
  859. .onChange(() => {
  860. if (item.type === CommonConstants.TYPE_IS_DIR) {
  861. this.toggleFolderSelection(item.filePath)
  862. } else {
  863. this.toggleSongSelection(item)
  864. }
  865. })
  866. }
  867. .width('100%')
  868. .padding(12)
  869. .backgroundColor(this.isDarkMode ? '#2C2C2E' : '#F8F8F8')
  870. .borderRadius(8)
  871. .margin({ bottom: 4 })
  872. .onClick(() => {
  873. if (item.type === CommonConstants.TYPE_IS_DIR) {
  874. this.toggleFolderSelection(item.filePath)
  875. } else {
  876. this.toggleSongSelection(item)
  877. }
  878. })
  879. }, (item: VideoItem) => item.id)
  880. }
  881. }
  882. .height(300)
  883. }
  884. }
  885. .width('100%')
  886. }
  887. // 改进的 footer Builder
  888. @Builder
  889. footer() {
  890. Column() {
  891. if (this.isLoading) {
  892. Row() {
  893. LoadingProgress()
  894. .height(20)
  895. .width(20)
  896. Text('加载中...')
  897. .fontSize(12)
  898. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  899. .margin({ left: 8 })
  900. }
  901. .width('100%')
  902. .height(40)
  903. .justifyContent(FlexAlign.Center)
  904. } else if (this.hasMoreData) {
  905. Row() {
  906. Text('上拉加载更多')
  907. .fontSize(12)
  908. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  909. }
  910. .width('100%')
  911. .height(40)
  912. .justifyContent(FlexAlign.Center)
  913. }
  914. }
  915. .width('100%')
  916. }
  917. }
  918. /**
  919. * 添加歌曲到歌单对话框管理器
  920. */
  921. @Component
  922. export struct AddSongsToPlaylistDialogManager {
  923. /**
  924. * 添加歌曲到歌单对话框构建器
  925. */
  926. @Builder
  927. buildAddSongsToPlaylistDialog(
  928. playlist: Playlist,
  929. onConfirm: (songs: VideoItem[]) => void,
  930. onCancel?: () => void,
  931. ) {
  932. AddSongsToPlaylistDialogContent({
  933. playlist: playlist,
  934. onConfirm: onConfirm,
  935. onCancel: onCancel
  936. })
  937. }
  938. /**
  939. * 显示添加歌曲到歌单对话框
  940. */
  941. showAddSongsToPlaylistDialog(
  942. playlist: Playlist,
  943. onConfirm: (songs: VideoItem[]) => void,
  944. onCancel?: () => void,
  945. ) {
  946. DialogHelper.showCustomContentDialog({
  947. dialogId: 'addSongsToPlaylistDialog',
  948. title: '添加歌曲到歌单',
  949. autoCancel: true,
  950. contentBuilder: () => {
  951. this.buildAddSongsToPlaylistDialog(playlist, onConfirm,onCancel)
  952. },
  953. buttons: []
  954. })
  955. }
  956. build() {
  957. }
  958. }
  959. // 创建全局实例
  960. const dialogManager = new AddSongsToPlaylistDialogManager()
  961. /**
  962. * 显示添加歌曲到歌单对话框
  963. */
  964. export function showAddSongsToPlaylistDialog(
  965. playlist: Playlist,
  966. onConfirm: (songs: VideoItem[]) => void,
  967. onCancel?: () => void,
  968. ) {
  969. dialogManager.showAddSongsToPlaylistDialog(playlist, onConfirm, onCancel)
  970. }