|
@@ -3,11 +3,10 @@ import { WebDavAccount } from '../viewmodel/WebDavAccount';
|
|
|
import { Song } from '../viewmodel/Song';
|
|
import { Song } from '../viewmodel/Song';
|
|
|
import { RemoteDriveManagerStates } from '../common/enums/RemoteDriveManagerStates';
|
|
import { RemoteDriveManagerStates } from '../common/enums/RemoteDriveManagerStates';
|
|
|
import Logger from '../common/util/Logger';
|
|
import Logger from '../common/util/Logger';
|
|
|
-import { promptAction, SymbolGlyphModifier, router, window, curves } from '@kit.ArkUI';
|
|
|
|
|
|
|
+import { SymbolGlyphModifier, router, curves } from '@kit.ArkUI';
|
|
|
import { CommonConstants } from '../common/constants/CommonConstants';
|
|
import { CommonConstants } from '../common/constants/CommonConstants';
|
|
|
import { VideoItem } from '../viewmodel/VideoItem';
|
|
import { VideoItem } from '../viewmodel/VideoItem';
|
|
|
import { GlobalContext } from '../common/util/GlobalContext';
|
|
import { GlobalContext } from '../common/util/GlobalContext';
|
|
|
-import { display } from '@kit.ArkUI';
|
|
|
|
|
import { FileInfo } from '../viewmodel/FileInfo';
|
|
import { FileInfo } from '../viewmodel/FileInfo';
|
|
|
import { emitter } from '@kit.BasicServicesKit';
|
|
import { emitter } from '@kit.BasicServicesKit';
|
|
|
import { EventConstants } from '../common/constants/EventConstants';
|
|
import { EventConstants } from '../common/constants/EventConstants';
|
|
@@ -26,8 +25,15 @@ import { CreateFolderDialog } from '../dialog/CreateFolderDialog';
|
|
|
import { UploadMusicPage } from './UploadMusicPage';
|
|
import { UploadMusicPage } from './UploadMusicPage';
|
|
|
import { FFmpeg } from '@sj/ffmpeg';
|
|
import { FFmpeg } from '@sj/ffmpeg';
|
|
|
import FileManager from '../common/util/FileManager';
|
|
import FileManager from '../common/util/FileManager';
|
|
|
-import { fileIo, fileUri } from '@kit.CoreFileKit';
|
|
|
|
|
|
|
+import { fileIo, fileUri, picker } from '@kit.CoreFileKit';
|
|
|
import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil';
|
|
import { setVideoUrlForSong } from '../common/util/RemotePlayerUtil';
|
|
|
|
|
+import { showAddToPlaylistDialog } from '../dialog/AddToPlaylistDialog';
|
|
|
|
|
+import { showCreatePlaylistDialog } from '../dialog/PlaylistDialog';
|
|
|
|
|
+import { Playlist } from '../viewmodel/Playlist';
|
|
|
|
|
+import PlaylistTable from '../common/util/PlaylistTable';
|
|
|
|
|
+import MediaTable from '../common/util/MediaTable';
|
|
|
|
|
+import { DownloadCenterManager } from '../common/util/DownloadCenterManager';
|
|
|
|
|
+import { DownloadCenter } from '../view/DownloadCenter';
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* 歌单播放事件数据
|
|
* 歌单播放事件数据
|
|
@@ -133,9 +139,15 @@ export struct WebDavMainPage {
|
|
|
@State selectedSongs: VideoItem[] = []
|
|
@State selectedSongs: VideoItem[] = []
|
|
|
@State isAllSelected: boolean = false
|
|
@State isAllSelected: boolean = false
|
|
|
@State isDeletingSelection: boolean = false
|
|
@State isDeletingSelection: boolean = false
|
|
|
|
|
+ @State ignoreTapAfterExitMultiSelect: boolean = false
|
|
|
|
|
+ @State rootPath: string = ''
|
|
|
|
|
+ @State isShowDownloadCenter: boolean = false
|
|
|
|
|
+ @State downloadCenterTabIndex: number[] = [0]
|
|
|
|
|
+ private readonly downloadFolderName: string = '下载'
|
|
|
private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
|
|
private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
|
|
|
private thumbnailTaskToken: number = 0;
|
|
private thumbnailTaskToken: number = 0;
|
|
|
private thumbnailRunningKeys: Set<string> = new Set<string>();
|
|
private thumbnailRunningKeys: Set<string> = new Set<string>();
|
|
|
|
|
+ private readonly downloadCenterManager: DownloadCenterManager = DownloadCenterManager.getInstance();
|
|
|
|
|
|
|
|
private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
|
|
private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
|
|
|
const nextTask: Promise<void> = globalRemoteThumbFfmpegQueue.then(async (): Promise<void> => {
|
|
const nextTask: Promise<void> = globalRemoteThumbFfmpegQueue.then(async (): Promise<void> => {
|
|
@@ -659,6 +671,9 @@ export struct WebDavMainPage {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private handleCheckboxSelection(song: VideoItem, checked: boolean): void {
|
|
private handleCheckboxSelection(song: VideoItem, checked: boolean): void {
|
|
|
|
|
+ if (this.ignoreTapAfterExitMultiSelect) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
if (!this.isMultiSelect) {
|
|
if (!this.isMultiSelect) {
|
|
|
this.isMultiSelect = true;
|
|
this.isMultiSelect = true;
|
|
|
}
|
|
}
|
|
@@ -684,10 +699,15 @@ export struct WebDavMainPage {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private exitMultiSelect(): void {
|
|
private exitMultiSelect(): void {
|
|
|
|
|
+ this.ignoreTapAfterExitMultiSelect = true;
|
|
|
this.isMultiSelect = false;
|
|
this.isMultiSelect = false;
|
|
|
this.selectedSongs = [];
|
|
this.selectedSongs = [];
|
|
|
this.isAllSelected = false;
|
|
this.isAllSelected = false;
|
|
|
- console.info('onecold this.isMultiSelect '+this.isMultiSelect)
|
|
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ this.isMultiSelect = false;
|
|
|
|
|
+ this.ignoreTapAfterExitMultiSelect = false;
|
|
|
|
|
+ }, 180);
|
|
|
|
|
+ console.info('onecold this.isMultiSelect ' + this.isMultiSelect)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private syncSelectionAfterRefresh(): void {
|
|
private syncSelectionAfterRefresh(): void {
|
|
@@ -750,6 +770,669 @@ export struct WebDavMainPage {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private confirmDeleteSingleSong(song: VideoItem): void {
|
|
|
|
|
+ if (!this.selectedAccount) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `请先选择${getRemoteDriveAccountLabel()}` });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.getUIContext().showAlertDialog({
|
|
|
|
|
+ title: '删除文件',
|
|
|
|
|
+ message: `确定删除 "${song.name}" 吗?`,
|
|
|
|
|
+ primaryButton: {
|
|
|
|
|
+ value: '取消',
|
|
|
|
|
+ action: () => {}
|
|
|
|
|
+ },
|
|
|
|
|
+ secondaryButton: {
|
|
|
|
|
+ value: '删除',
|
|
|
|
|
+ fontColor: Color.Red,
|
|
|
|
|
+ action: () => {
|
|
|
|
|
+ void this.performDeleteSingleSong(song);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async performDeleteSingleSong(song: VideoItem): Promise<void> {
|
|
|
|
|
+ if (!this.selectedAccount) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.isDeletingSelection = true;
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.webdavManager.deleteRemoteSongs(this.selectedAccount, [song]);
|
|
|
|
|
+ await this.webdavManager.loadFilesInfoFromAccount(this.selectedAccount, this.webdavManager.currentPath);
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '删除成功' });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `删除失败: ${err.message}` });
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ this.isDeletingSelection = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private addSongToNextPlay(song: VideoItem): void {
|
|
|
|
|
+ if (!song) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const queue: VideoItem[] = globalWebdavVideoItems && globalWebdavVideoItems.length > 0 ?
|
|
|
|
|
+ globalWebdavVideoItems : this.songs;
|
|
|
|
|
+ if (!queue || queue.length === 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '当前列表为空' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const currentPath = this.currentSong?.filePath;
|
|
|
|
|
+ if (StrUtil.isEmpty(currentPath)) {
|
|
|
|
|
+ const targetIndex = this.songs.findIndex((item: VideoItem) => item.filePath === song.filePath);
|
|
|
|
|
+ if (targetIndex >= 0) {
|
|
|
|
|
+ this.playSong(song, targetIndex, false);
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '已开始播放' });
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (currentPath === song.filePath) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '当前正在播放这首歌' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let currentIndex = queue.findIndex((item: VideoItem) => item.filePath === currentPath);
|
|
|
|
|
+ if (currentIndex < 0 && globalWebdavCurrentPlayIndex >= 0 && globalWebdavCurrentPlayIndex < queue.length) {
|
|
|
|
|
+ currentIndex = globalWebdavCurrentPlayIndex;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (currentIndex < 0) {
|
|
|
|
|
+ currentIndex = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const existIndex = queue.findIndex((item: VideoItem) => item.filePath === song.filePath);
|
|
|
|
|
+ if (existIndex >= 0) {
|
|
|
|
|
+ const movedSong = queue.splice(existIndex, 1)[0];
|
|
|
|
|
+ if (existIndex < currentIndex) {
|
|
|
|
|
+ currentIndex -= 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ queue.splice(currentIndex + 1, 0, movedSong);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ queue.splice(currentIndex + 1, 0, song);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ globalWebdavCurrentPlayIndex = currentIndex;
|
|
|
|
|
+ globalWebdavVideoItems = queue;
|
|
|
|
|
+ this.songs = [...queue];
|
|
|
|
|
+ this.dataSource.pushArrayData(this.songs);
|
|
|
|
|
+ const eventQueueRefresh: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAY_QUEUE_REFRESH };
|
|
|
|
|
+ emitter.emit(eventQueueRefresh, { data: { source: 'webdav' } });
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '已添加到下一首播放' });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private cloneSongForDb(song: VideoItem): VideoItem {
|
|
|
|
|
+ const dbSong = new VideoItem(
|
|
|
|
|
+ song.name || '',
|
|
|
|
|
+ song.id || song.filePath,
|
|
|
|
|
+ song.filePath,
|
|
|
|
|
+ song.type,
|
|
|
|
|
+ song.videoSize || 0,
|
|
|
|
|
+ song.cTime || Utility.getFormatDateStr(new Date(), 'yyyy-MM-dd HH:mm')
|
|
|
|
|
+ );
|
|
|
|
|
+ dbSong.parentPath = song.parentPath;
|
|
|
|
|
+ dbSong.isFav = song.isFav;
|
|
|
|
|
+ dbSong.size = song.size;
|
|
|
|
|
+ dbSong.pixelMapPath = song.pixelMapPath;
|
|
|
|
|
+ dbSong.artist = song.artist;
|
|
|
|
|
+ dbSong.album = song.album;
|
|
|
|
|
+ dbSong.fileName = song.fileName;
|
|
|
|
|
+ dbSong.duration = song.duration;
|
|
|
|
|
+ dbSong.mimeType = song.mimeType;
|
|
|
|
|
+ dbSong.sampleRate = song.sampleRate;
|
|
|
|
|
+ dbSong.trackCount = song.trackCount;
|
|
|
|
|
+ dbSong.lastPlayedStr = song.lastPlayedStr;
|
|
|
|
|
+ dbSong.playCount = song.playCount;
|
|
|
|
|
+ dbSong.lyricContent = song.lyricContent;
|
|
|
|
|
+ dbSong.md5Str = song.md5Str;
|
|
|
|
|
+ dbSong.extra_json = song.extra_json;
|
|
|
|
|
+ dbSong.pyStr = song.pyStr;
|
|
|
|
|
+ dbSong.bit_rate = song.bit_rate;
|
|
|
|
|
+ dbSong.probe_score = song.probe_score;
|
|
|
|
|
+ dbSong.year = song.year;
|
|
|
|
|
+ dbSong.nb_streams = song.nb_streams;
|
|
|
|
|
+ dbSong.nb_programs = song.nb_programs;
|
|
|
|
|
+ dbSong.genre = song.genre;
|
|
|
|
|
+ dbSong.track = song.track;
|
|
|
|
|
+ dbSong.bits_per_raw_sample = song.bits_per_raw_sample;
|
|
|
|
|
+ dbSong.channels = song.channels;
|
|
|
|
|
+ dbSong.channel_layout = song.channel_layout;
|
|
|
|
|
+ dbSong.start_time = song.start_time;
|
|
|
|
|
+ dbSong.ALBUMARTIST = song.ALBUMARTIST;
|
|
|
|
|
+ dbSong.COMPOSER = song.COMPOSER;
|
|
|
|
|
+ dbSong.LYRICIST = song.LYRICIST;
|
|
|
|
|
+ dbSong.COMMENT = song.COMMENT;
|
|
|
|
|
+ dbSong.disc = song.disc;
|
|
|
|
|
+ dbSong.webdav_account_id = song.webdav_account_id;
|
|
|
|
|
+ dbSong.remote_rel_path = song.remote_rel_path;
|
|
|
|
|
+ dbSong.navArtistId = song.navArtistId;
|
|
|
|
|
+ dbSong.navAlbumId = song.navAlbumId;
|
|
|
|
|
+ dbSong.baiduFsId = song.baiduFsId;
|
|
|
|
|
+ dbSong.webdav_id = song.webdav_id;
|
|
|
|
|
+ dbSong.lyricIndex = song.lyricIndex;
|
|
|
|
|
+ return dbSong;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private emitPlaylistRefresh(): void {
|
|
|
|
|
+ emitter.emit({ eventId: EventConstants.EVENT_PLAYLIST_REFRESH }, {});
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildPlaylistSongCandidates(songs: VideoItem[]): VideoItem[] {
|
|
|
|
|
+ if (!songs || songs.length <= 0) {
|
|
|
|
|
+ return [];
|
|
|
|
|
+ }
|
|
|
|
|
+ const result: VideoItem[] = [];
|
|
|
|
|
+ const existedPaths: Set<string> = new Set<string>();
|
|
|
|
|
+ for (let index: number = 0; index < songs.length; index += 1) {
|
|
|
|
|
+ const song = songs[index];
|
|
|
|
|
+ if (!song || StrUtil.isEmpty(song.filePath)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (existedPaths.has(song.filePath)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ existedPaths.add(song.filePath);
|
|
|
|
|
+ result.push(song);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async resolvePlaylistSongPaths(mediaTable: MediaTable, songs: VideoItem[]): Promise<string[]> {
|
|
|
|
|
+ const paths: string[] = [];
|
|
|
|
|
+ for (let index: number = 0; index < songs.length; index += 1) {
|
|
|
|
|
+ const song: VideoItem = songs[index];
|
|
|
|
|
+ const songForDb: VideoItem = this.cloneSongForDb(song);
|
|
|
|
|
+ if (!songForDb.webdav_account_id && this.selectedAccount?.id) {
|
|
|
|
|
+ songForDb.webdav_account_id = this.selectedAccount.id.toString();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(songForDb.id)) {
|
|
|
|
|
+ songForDb.id = songForDb.remote_rel_path || songForDb.filePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(songForDb.remote_rel_path)) {
|
|
|
|
|
+ songForDb.remote_rel_path = songForDb.id || songForDb.filePath;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(songForDb.parentPath) && songForDb.filePath.includes('/')) {
|
|
|
|
|
+ const pathIndex = songForDb.filePath.lastIndexOf('/');
|
|
|
|
|
+ if (pathIndex > 0) {
|
|
|
|
|
+ songForDb.parentPath = songForDb.filePath.substring(0, pathIndex);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const saved: boolean = await mediaTable.saveOrUpdateWebDavItem(songForDb);
|
|
|
|
|
+ if (!saved) {
|
|
|
|
|
+ Logger.warn(TAG, `歌曲入库失败,跳过加入歌单: ${songForDb.filePath}`);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ const storedSong: VideoItem | null = await mediaTable.queryVideoByFilePath(songForDb.filePath);
|
|
|
|
|
+ if (storedSong && StrUtil.isNotEmpty(storedSong.filePath)) {
|
|
|
|
|
+ paths.push(storedSong.filePath);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const uniquePaths: string[] = [];
|
|
|
|
|
+ const pathSet: Set<string> = new Set<string>();
|
|
|
|
|
+ for (let index: number = 0; index < paths.length; index += 1) {
|
|
|
|
|
+ const path = paths[index];
|
|
|
|
|
+ if (StrUtil.isEmpty(path) || pathSet.has(path)) {
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ pathSet.add(path);
|
|
|
|
|
+ uniquePaths.push(path);
|
|
|
|
|
+ }
|
|
|
|
|
+ return uniquePaths;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async openSongsAddToPlaylistDialog(songs: VideoItem[]): Promise<void> {
|
|
|
|
|
+ const candidateSongs: VideoItem[] = this.buildPlaylistSongCandidates(songs);
|
|
|
|
|
+ if (candidateSongs.length <= 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '请选择要加入歌单的歌曲' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const uiContext = this.getUIContext();
|
|
|
|
|
+ const hostCtx = uiContext ? uiContext.getHostContext() : undefined;
|
|
|
|
|
+ if (!hostCtx) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '无法获取上下文' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const playlistTable: PlaylistTable = new PlaylistTable(hostCtx);
|
|
|
|
|
+ const mediaTable: MediaTable = new MediaTable(hostCtx);
|
|
|
|
|
+
|
|
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
|
|
+ mediaTable.getRdbStore(hostCtx, () => resolve());
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const playlistSongPaths: string[] = await this.resolvePlaylistSongPaths(mediaTable, candidateSongs);
|
|
|
|
|
+ if (playlistSongPaths.length <= 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '歌曲入库失败,无法添加到歌单' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const resolveSuccessToast = (): string => {
|
|
|
|
|
+ if (playlistSongPaths.length > 1) {
|
|
|
|
|
+ return `已添加${playlistSongPaths.length}首到歌单`;
|
|
|
|
|
+ }
|
|
|
|
|
+ return '已添加到歌单';
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const openDialog = async (): Promise<void> => {
|
|
|
|
|
+ const playlists: Playlist[] = await playlistTable.queryAllPlaylists();
|
|
|
|
|
+ showAddToPlaylistDialog(
|
|
|
|
|
+ candidateSongs,
|
|
|
|
|
+ playlists,
|
|
|
|
|
+ async (playlistId: string) => {
|
|
|
|
|
+ const success = await playlistTable.addSongsToPlaylist(playlistId, playlistSongPaths);
|
|
|
|
|
+ if (success) {
|
|
|
|
|
+ this.emitPlaylistRefresh();
|
|
|
|
|
+ }
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: success ? resolveSuccessToast() : '歌曲已在该歌单中' });
|
|
|
|
|
+ },
|
|
|
|
|
+ () => {},
|
|
|
|
|
+ () => {
|
|
|
|
|
+ showCreatePlaylistDialog(
|
|
|
|
|
+ async (name: string, description: string, coverPath?: string) => {
|
|
|
|
|
+ const created = await playlistTable.createPlaylist(name, description, coverPath);
|
|
|
|
|
+ if (!created) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '歌单创建失败' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const target = await playlistTable.queryPlaylistByName(name);
|
|
|
|
|
+ if (!target) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '未找到新建歌单' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const addSuccess = await playlistTable.addSongsToPlaylist(target.id, playlistSongPaths);
|
|
|
|
|
+ if (addSuccess) {
|
|
|
|
|
+ this.emitPlaylistRefresh();
|
|
|
|
|
+ }
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: addSuccess ? resolveSuccessToast() : '歌曲已在该歌单中' });
|
|
|
|
|
+ },
|
|
|
|
|
+ () => {}
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+ );
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ await openDialog();
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `打开添加到歌单失败: ${err.message}`);
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '添加到歌单失败' });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async openSelectedSongsAddToPlaylistDialog(): Promise<void> {
|
|
|
|
|
+ if (!this.isMultiSelect || this.selectedSongs.length <= 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '请先选择歌曲' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.openSongsAddToPlaylistDialog(this.selectedSongs.slice());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async openSongAddToPlaylistDialog(song: VideoItem): Promise<void> {
|
|
|
|
|
+ if (!song) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ await this.openSongsAddToPlaylistDialog([song]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private sanitizeDownloadFileName(fileName: string): string {
|
|
|
|
|
+ const trimmed = (fileName || '').trim();
|
|
|
|
|
+ const sanitized = trimmed.replace(/[\\/:*?"<>|]/g, '_');
|
|
|
|
|
+ return sanitized.length > 0 ? sanitized : '未知歌曲.mp3';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private resolveDownloadFileName(song: VideoItem, sourceUrlOrPath: string): string {
|
|
|
|
|
+ if (StrUtil.isNotEmpty(song.fileName)) {
|
|
|
|
|
+ return this.sanitizeDownloadFileName(song.fileName as string);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isNotEmpty(song.name) && (song.name as string).includes('.')) {
|
|
|
|
|
+ return this.sanitizeDownloadFileName(song.name as string);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isNotEmpty(sourceUrlOrPath)) {
|
|
|
|
|
+ const purePath = sourceUrlOrPath.split('?')[0];
|
|
|
|
|
+ const lastSlash = purePath.lastIndexOf('/');
|
|
|
|
|
+ if (lastSlash >= 0 && lastSlash < purePath.length - 1) {
|
|
|
|
|
+ const urlName = purePath.substring(lastSlash + 1);
|
|
|
|
|
+ if (StrUtil.isNotEmpty(urlName)) {
|
|
|
|
|
+ return this.sanitizeDownloadFileName(urlName);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ const baseName = StrUtil.isNotEmpty(song.name) ? song.name as string : '未知歌曲';
|
|
|
|
|
+ return this.sanitizeDownloadFileName(`${baseName}.mp3`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private buildUniqueDownloadPath(downloadDir: string, fileName: string): string {
|
|
|
|
|
+ const safeName = this.sanitizeDownloadFileName(fileName);
|
|
|
|
|
+ const dotIndex = safeName.lastIndexOf('.');
|
|
|
|
|
+ const hasExt = dotIndex > 0;
|
|
|
|
|
+ const namePart = hasExt ? safeName.substring(0, dotIndex) : safeName;
|
|
|
|
|
+ const extPart = hasExt ? safeName.substring(dotIndex) : '';
|
|
|
|
|
+ let targetPath = `${downloadDir}/${safeName}`;
|
|
|
|
|
+ let suffix = 1;
|
|
|
|
|
+ while (FileUtil.accessSync(targetPath)) {
|
|
|
|
|
+ targetPath = `${downloadDir}/${namePart}(${suffix})${extPart}`;
|
|
|
|
|
+ suffix += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ return targetPath;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async pickDownloadRootPath(): Promise<string> {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const documentViewPicker = new picker.DocumentViewPicker();
|
|
|
|
|
+ const documentSaveResult = await documentViewPicker.save({ pickerMode: picker.DocumentPickerMode.DOWNLOAD });
|
|
|
|
|
+ if (!documentSaveResult || documentSaveResult.length === 0) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const resolvedPath = new fileUri.FileUri(documentSaveResult[0]).path;
|
|
|
|
|
+ this.rootPath = resolvedPath;
|
|
|
|
|
+ return resolvedPath;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `选择下载目录失败: ${err.message}`);
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async ensureDownloadDirectory(): Promise<string> {
|
|
|
|
|
+ let basePath = this.rootPath;
|
|
|
|
|
+ if (StrUtil.isEmpty(basePath) || !FileUtil.accessSync(basePath)) {
|
|
|
|
|
+ basePath = await this.pickDownloadRootPath();
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(basePath)) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const downloadDir = `${basePath}/${this.downloadFolderName}`;
|
|
|
|
|
+ if (!FileUtil.accessSync(downloadDir)) {
|
|
|
|
|
+ await fileIo.mkdir(downloadDir);
|
|
|
|
|
+ }
|
|
|
|
|
+ return downloadDir;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async upsertDownloadedSongToLibrary(targetPath: string, sourceSong: VideoItem): Promise<void> {
|
|
|
|
|
+ if (StrUtil.isEmpty(targetPath) || !FileUtil.accessSync(targetPath)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const pageContext = getContext(this);
|
|
|
|
|
+ const mediaModule = await import('../common/util/MediaTable');
|
|
|
|
|
+ const mediaTable = new mediaModule.default(pageContext);
|
|
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
|
|
+ mediaTable.getRdbStore(pageContext, () => resolve());
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ const exists = await mediaTable.isRecordExists(targetPath);
|
|
|
|
|
+ if (exists) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let mediaItem: VideoItem;
|
|
|
|
|
+ try {
|
|
|
|
|
+ mediaItem = await Utility.readMetaInfoFFmpeg(pageContext, targetPath, CommonConstants.TYPE_LOCAL, false);
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ Logger.warn(TAG, `下载歌曲读取元数据失败,使用兜底入库: ${(error as Error).message}`);
|
|
|
|
|
+ mediaItem = new VideoItem(
|
|
|
|
|
+ sourceSong.name || FileUtil.getFileName(targetPath),
|
|
|
|
|
+ targetPath,
|
|
|
|
|
+ targetPath,
|
|
|
|
|
+ CommonConstants.TYPE_LOCAL,
|
|
|
|
|
+ 0,
|
|
|
|
|
+ Utility.getFormatDateStr(new Date(), 'yyyy-MM-dd HH:mm')
|
|
|
|
|
+ );
|
|
|
|
|
+ mediaItem.fileName = FileUtil.getFileName(targetPath);
|
|
|
|
|
+ mediaItem.artist = sourceSong.artist;
|
|
|
|
|
+ mediaItem.album = sourceSong.album;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ mediaItem.id = targetPath;
|
|
|
|
|
+ mediaItem.filePath = targetPath;
|
|
|
|
|
+ mediaItem.type = CommonConstants.TYPE_LOCAL;
|
|
|
|
|
+ if (StrUtil.isEmpty(mediaItem.fileName)) {
|
|
|
|
|
+ mediaItem.fileName = FileUtil.getFileName(targetPath);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(mediaItem.name)) {
|
|
|
|
|
+ mediaItem.name = mediaItem.fileName as string;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(mediaItem.parentPath)) {
|
|
|
|
|
+ mediaItem.parentPath = FileUtil.getParentPath(targetPath);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ await new Promise<void>((resolve) => {
|
|
|
|
|
+ mediaTable.insert(mediaItem, () => resolve());
|
|
|
|
|
+ });
|
|
|
|
|
+ emitter.emit({ eventId: EventConstants.EVENT_SCAN_UPDATE }, {});
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ Logger.error(TAG, `下载歌曲入库失败: ${(error as Error).message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private openDownloadCenter(): void {
|
|
|
|
|
+ this.isShowDownloadCenter = true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private resolveSongDownloadSizeText(song: VideoItem): string {
|
|
|
|
|
+ if (StrUtil.isNotEmpty(song.size)) {
|
|
|
|
|
+ return decodeUrlEncodedString(song.size as string);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (song.videoSize > 0) {
|
|
|
|
|
+ return this.formatDownloadBytes(song.videoSize);
|
|
|
|
|
+ }
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private parseSizeTextToBytes(sizeText: string): number {
|
|
|
|
|
+ if (StrUtil.isEmpty(sizeText)) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ const normalized = sizeText.trim();
|
|
|
|
|
+ const match = normalized.match(/([0-9]+(?:\.[0-9]+)?)\s*(B|KB|MB|GB|TB)/i);
|
|
|
|
|
+ if (!match) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ const value = parseFloat(match[1]);
|
|
|
|
|
+ if (!Number.isFinite(value) || value <= 0) {
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+ const unit = match[2].toUpperCase();
|
|
|
|
|
+ const factor = this.resolveUnitFactor(unit);
|
|
|
|
|
+ return Math.floor(value * factor);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private resolveUnitFactor(unit: string): number {
|
|
|
|
|
+ switch (unit) {
|
|
|
|
|
+ case 'KB':
|
|
|
|
|
+ return 1024;
|
|
|
|
|
+ case 'MB':
|
|
|
|
|
+ return 1024 * 1024;
|
|
|
|
|
+ case 'GB':
|
|
|
|
|
+ return 1024 * 1024 * 1024;
|
|
|
|
|
+ case 'TB':
|
|
|
|
|
+ return 1024 * 1024 * 1024 * 1024;
|
|
|
|
|
+ case 'B':
|
|
|
|
|
+ default:
|
|
|
|
|
+ return 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private formatDownloadBytes(bytes: number): string {
|
|
|
|
|
+ if (!Number.isFinite(bytes) || bytes <= 0) {
|
|
|
|
|
+ return '';
|
|
|
|
|
+ }
|
|
|
|
|
+ const units: string[] = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
|
|
|
+ let size: number = bytes;
|
|
|
|
|
+ let index: number = 0;
|
|
|
|
|
+ while (size >= 1024 && index < units.length - 1) {
|
|
|
|
|
+ size /= 1024;
|
|
|
|
|
+ index += 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ const precision: number = index === 0 ? 0 : 2;
|
|
|
|
|
+ return `${size.toFixed(precision)} ${units[index]}`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private pauseDownloadTask(taskId: string): void {
|
|
|
|
|
+ if (StrUtil.isEmpty(taskId)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.downloadCenterManager.pauseTask(taskId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private resumeDownloadTask(taskId: string): void {
|
|
|
|
|
+ if (StrUtil.isEmpty(taskId)) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.downloadCenterManager.resumeTask(taskId);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async resolveSongDownloadSourceUrl(song: VideoItem): Promise<string> {
|
|
|
|
|
+ if (song.type === CommonConstants.TYPE_BAIDU && song.webdav_account_id) {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const account = await this.webdavManager.getWebDavAccountById(song.webdav_account_id);
|
|
|
|
|
+ if (account) {
|
|
|
|
|
+ const dlink = await this.webdavManager.getBaiduDownloadUrl(account, song);
|
|
|
|
|
+ if (StrUtil.isNotEmpty(dlink)) {
|
|
|
|
|
+ Logger.info(TAG, `下载中心使用百度直链下载: ${song.name}`);
|
|
|
|
|
+ return dlink;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ Logger.warn(TAG, `百度下载直链获取失败,回退播放地址: ${(error as Error).message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return setVideoUrlForSong(song, {
|
|
|
|
|
+ context: getContext(this),
|
|
|
|
|
+ autoParseMusicName: false,
|
|
|
|
|
+ extractCover: false,
|
|
|
|
|
+ extractLyric: false,
|
|
|
|
|
+ extractAudioInfo: false
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async buildDownloadRequestHeaders(song: VideoItem, sourceUrl: string): Promise<Map<string, string>> {
|
|
|
|
|
+ if (song.type === CommonConstants.TYPE_BAIDU) {
|
|
|
|
|
+ const headers = new Map<string, string>();
|
|
|
|
|
+ // 参考 BaiduFileCache 下载链路,避免复用播放头导致 Range 行为异常
|
|
|
|
|
+ headers.set('User-Agent', 'pan.baidu.com');
|
|
|
|
|
+ headers.set('Accept', '*/*');
|
|
|
|
|
+ headers.set('Connection', 'Keep-Alive');
|
|
|
|
|
+ return headers;
|
|
|
|
|
+ }
|
|
|
|
|
+ return this.webdavManager.buildHttpHeadersWithAccountId(song, sourceUrl);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async enqueueSongDownloadWithDirectory(song: VideoItem, downloadDir: string): Promise<string> {
|
|
|
|
|
+ if (!song) {
|
|
|
|
|
+ throw new Error('歌曲不存在');
|
|
|
|
|
+ }
|
|
|
|
|
+ if (StrUtil.isEmpty(downloadDir)) {
|
|
|
|
|
+ throw new Error('未选择下载目录');
|
|
|
|
|
+ }
|
|
|
|
|
+ const sourceUrl = await this.resolveSongDownloadSourceUrl(song);
|
|
|
|
|
+ const normalizedSource = sourceUrl.startsWith('file://') ? new fileUri.FileUri(sourceUrl).path : sourceUrl;
|
|
|
|
|
+ const fileName = this.resolveDownloadFileName(song, normalizedSource);
|
|
|
|
|
+ const targetPath = this.buildUniqueDownloadPath(downloadDir, fileName);
|
|
|
|
|
+ const sizeText = this.resolveSongDownloadSizeText(song);
|
|
|
|
|
+ const expectedBytes = this.parseSizeTextToBytes(sizeText);
|
|
|
|
|
+ const requestHeaders = await this.buildDownloadRequestHeaders(song, sourceUrl);
|
|
|
|
|
+ const coverPath = song.pixelMapPath ?? '';
|
|
|
|
|
+
|
|
|
|
|
+ this.downloadCenterManager.enqueueDownload({
|
|
|
|
|
+ title: song.name || fileName,
|
|
|
|
|
+ fileName: fileName,
|
|
|
|
|
+ coverPath: coverPath,
|
|
|
|
|
+ sizeText: sizeText,
|
|
|
|
|
+ expectedBytes: expectedBytes,
|
|
|
|
|
+ sourceUrl: sourceUrl,
|
|
|
|
|
+ targetPath: targetPath,
|
|
|
|
|
+ downloadDir: downloadDir,
|
|
|
|
|
+ headers: requestHeaders,
|
|
|
|
|
+ onCompleted: async () => {
|
|
|
|
|
+ await this.upsertDownloadedSongToLibrary(targetPath, song);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ return fileName;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async enqueueSongDownload(song: VideoItem): Promise<void> {
|
|
|
|
|
+ if (!song) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const downloadDir = await this.ensureDownloadDirectory();
|
|
|
|
|
+ if (StrUtil.isEmpty(downloadDir)) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '未选择下载目录' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ const fileName: string = await this.enqueueSongDownloadWithDirectory(song, downloadDir);
|
|
|
|
|
+ this.openDownloadCenter();
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `已加入下载队列: ${fileName}` });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `加入下载队列失败: ${err.message}`);
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `下载任务创建失败: ${err.message}` });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private async enqueueSelectedSongsDownload(): Promise<void> {
|
|
|
|
|
+ if (!this.isMultiSelect || this.selectedSongs.length <= 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '请先选择要下载的歌曲' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const candidates: VideoItem[] = this.buildPlaylistSongCandidates(this.selectedSongs.slice());
|
|
|
|
|
+ if (candidates.length <= 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '没有可下载的歌曲' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const downloadDir = await this.ensureDownloadDirectory();
|
|
|
|
|
+ if (StrUtil.isEmpty(downloadDir)) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '未选择下载目录' });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let successCount: number = 0;
|
|
|
|
|
+ let failedCount: number = 0;
|
|
|
|
|
+
|
|
|
|
|
+ for (let index: number = 0; index < candidates.length; index += 1) {
|
|
|
|
|
+ const song: VideoItem = candidates[index];
|
|
|
|
|
+ try {
|
|
|
|
|
+ await this.enqueueSongDownloadWithDirectory(song, downloadDir);
|
|
|
|
|
+ successCount += 1;
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ failedCount += 1;
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `批量下载入队失败: ${(song.name || song.fileName || song.filePath)}, ${err.message}`);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (successCount > 0) {
|
|
|
|
|
+ this.openDownloadCenter();
|
|
|
|
|
+ this.exitMultiSelect();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (successCount > 0 && failedCount <= 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `已加入下载队列: ${successCount}首` });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (successCount > 0) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `已加入下载队列${successCount}首,失败${failedCount}首` });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '下载任务创建失败' });
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ const err = error as Error;
|
|
|
|
|
+ Logger.error(TAG, `批量下载失败: ${err.message}`);
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: `批量下载失败: ${err.message}` });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private buildSongMetaLine(song: VideoItem): string {
|
|
private buildSongMetaLine(song: VideoItem): string {
|
|
|
const parts: string[] = [];
|
|
const parts: string[] = [];
|
|
|
if (StrUtil.isNotEmpty(song.duration)) {
|
|
if (StrUtil.isNotEmpty(song.duration)) {
|
|
@@ -1067,12 +1750,38 @@ export struct WebDavMainPage {
|
|
|
this.updateListData(this.songs)
|
|
this.updateListData(this.songs)
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ private resolveSongPlayIndex(song: VideoItem, fallbackIndex: number): number {
|
|
|
|
|
+ if (this.songs.length <= 0) {
|
|
|
|
|
+ return -1;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (song && StrUtil.isNotEmpty(song.filePath)) {
|
|
|
|
|
+ for (let i = 0; i < this.songs.length; i++) {
|
|
|
|
|
+ if (this.songs[i].filePath === song.filePath) {
|
|
|
|
|
+ return i;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (fallbackIndex >= 0 && fallbackIndex < this.songs.length) {
|
|
|
|
|
+ return fallbackIndex;
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
// 播放WebDAV歌曲
|
|
// 播放WebDAV歌曲
|
|
|
private playSong(song: VideoItem, index: number,isJump:boolean=false): void {
|
|
private playSong(song: VideoItem, index: number,isJump:boolean=false): void {
|
|
|
try {
|
|
try {
|
|
|
Logger.info(TAG, `heanup === WebDAV playSong 方法被调用 ===`);
|
|
Logger.info(TAG, `heanup === WebDAV playSong 方法被调用 ===`);
|
|
|
Logger.info(TAG, 'heanup 播放指定歌曲: ' + song.name + ', 索引: ' + index);
|
|
Logger.info(TAG, 'heanup 播放指定歌曲: ' + song.name + ', 索引: ' + index);
|
|
|
Logger.info(TAG, 'heanup 歌曲列表长度: ' + this.songs.length);
|
|
Logger.info(TAG, 'heanup 歌曲列表长度: ' + this.songs.length);
|
|
|
|
|
+ const playIndex = this.resolveSongPlayIndex(song, index);
|
|
|
|
|
+ if (playIndex < 0 || playIndex >= this.songs.length) {
|
|
|
|
|
+ this.getUIContext().getPromptAction().showToast({ message: '播放失败,未找到歌曲' });
|
|
|
|
|
+ Logger.warn(TAG, `heanup 无法定位播放索引: fallback=${index}, songPath=${song.filePath}`);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (playIndex !== index) {
|
|
|
|
|
+ Logger.info(TAG, `heanup 修正播放索引: fallback=${index}, real=${playIndex}, song=${song.name}`);
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
// 检查歌曲是否有webdav_account_id
|
|
// 检查歌曲是否有webdav_account_id
|
|
|
Logger.info(TAG, `heanup 播放歌曲的webdav_account_id: ${song.webdav_account_id || '未设置'}`);
|
|
Logger.info(TAG, `heanup 播放歌曲的webdav_account_id: ${song.webdav_account_id || '未设置'}`);
|
|
@@ -1107,21 +1816,21 @@ export struct WebDavMainPage {
|
|
|
playlistId: 'webdav-playlist', // 使用特殊的ID标识网盘播放列表
|
|
playlistId: 'webdav-playlist', // 使用特殊的ID标识网盘播放列表
|
|
|
playlistName: `${getRemoteDriveDisplayLabel(this.selectedAccount?.webType)} - ${this.selectedAccount?.name || '未知账户'}`,
|
|
playlistName: `${getRemoteDriveDisplayLabel(this.selectedAccount?.webType)} - ${this.selectedAccount?.name || '未知账户'}`,
|
|
|
songCount: this.songs.length,
|
|
songCount: this.songs.length,
|
|
|
- startIndex: index,
|
|
|
|
|
|
|
+ startIndex: playIndex,
|
|
|
isJump: isJump,//设置true会弹出播放页
|
|
isJump: isJump,//设置true会弹出播放页
|
|
|
songFilePaths: songFilePaths
|
|
songFilePaths: songFilePaths
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
// 保存videoItems到全局内存
|
|
// 保存videoItems到全局内存
|
|
|
globalWebdavVideoItems = videoItems;
|
|
globalWebdavVideoItems = videoItems;
|
|
|
- globalWebdavCurrentPlayIndex = index;
|
|
|
|
|
|
|
+ globalWebdavCurrentPlayIndex = playIndex;
|
|
|
|
|
|
|
|
- Logger.info(TAG, `heanup 保存WebDAV歌曲到全局内存,长度: ${videoItems.length}, 索引: ${index}`);
|
|
|
|
|
|
|
+ Logger.info(TAG, `heanup 保存WebDAV歌曲到全局内存,长度: ${videoItems.length}, 索引: ${playIndex}`);
|
|
|
|
|
|
|
|
// 发送播放请求事件,只传递索引信息
|
|
// 发送播放请求事件,只传递索引信息
|
|
|
emitter.emit(eventPlaylistPlay, { data: playlistData });
|
|
emitter.emit(eventPlaylistPlay, { data: playlistData });
|
|
|
|
|
|
|
|
- Logger.info(TAG, `heanup 发送WebDAV播放事件,只传递索引: ${index}`);
|
|
|
|
|
|
|
+ Logger.info(TAG, `heanup 发送WebDAV播放事件,只传递索引: ${playIndex}`);
|
|
|
|
|
|
|
|
if(!this.isNoJumpToHome){
|
|
if(!this.isNoJumpToHome){
|
|
|
// 跳转到首页播放器
|
|
// 跳转到首页播放器
|
|
@@ -1291,6 +2000,14 @@ export struct WebDavMainPage {
|
|
|
this.isShowUploadFile = !this.isShowUploadFile
|
|
this.isShowUploadFile = !this.isShowUploadFile
|
|
|
})
|
|
})
|
|
|
|
|
|
|
|
|
|
+ MenuItem({
|
|
|
|
|
+ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.download')),
|
|
|
|
|
+ content: '下载中心'
|
|
|
|
|
+ })
|
|
|
|
|
+ .onClick(() => {
|
|
|
|
|
+ this.openDownloadCenter();
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
MenuItem({
|
|
MenuItem({
|
|
|
symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.folder_badge_plus')),
|
|
symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.folder_badge_plus')),
|
|
|
content: $r('app.string.create_foder')
|
|
content: $r('app.string.create_foder')
|
|
@@ -1415,7 +2132,7 @@ export struct WebDavMainPage {
|
|
|
topTitleBar(){
|
|
topTitleBar(){
|
|
|
Column() {
|
|
Column() {
|
|
|
Row({ space: 15 }) {
|
|
Row({ space: 15 }) {
|
|
|
- if (!this.isSearchMode ) {
|
|
|
|
|
|
|
+ if (!this.isSearchMode &&!(this.webdavManager.canGoBack())) {
|
|
|
//左侧滑动按钮
|
|
//左侧滑动按钮
|
|
|
Button({ type: ButtonType.Circle, stateEffect: true }) {
|
|
Button({ type: ButtonType.Circle, stateEffect: true }) {
|
|
|
SymbolGlyph($r('sys.symbol.sort'))
|
|
SymbolGlyph($r('sys.symbol.sort'))
|
|
@@ -1435,14 +2152,6 @@ export struct WebDavMainPage {
|
|
|
.attributeModifier(new ShadowModifier())
|
|
.attributeModifier(new ShadowModifier())
|
|
|
.zIndex(0)
|
|
.zIndex(0)
|
|
|
|
|
|
|
|
- Text(this.selectedAccount.name)
|
|
|
|
|
- .margin({left:3,right:10})
|
|
|
|
|
- .fontColor($r('app.color.text_color'))
|
|
|
|
|
- .fontSize(19)
|
|
|
|
|
- .maxLines(1)
|
|
|
|
|
- .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
|
|
|
|
|
- .layoutWeight(1)
|
|
|
|
|
- .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
|
|
}else{
|
|
}else{
|
|
|
|
|
|
|
|
//左侧搜索返回按钮
|
|
//左侧搜索返回按钮
|
|
@@ -1454,14 +2163,32 @@ export struct WebDavMainPage {
|
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
.animation({ duration: 300, curve: Curve.Ease })
|
|
.animation({ duration: 300, curve: Curve.Ease })
|
|
|
.onClick(() => {
|
|
.onClick(() => {
|
|
|
- this.isSearchMode = false
|
|
|
|
|
- this.onSearchInput('')
|
|
|
|
|
|
|
+ if(this.isSearchMode){
|
|
|
|
|
+ this.isSearchMode = false
|
|
|
|
|
+ this.onSearchInput('')
|
|
|
|
|
+ }else{
|
|
|
|
|
+ if(this.webdavManager.canGoBack()){
|
|
|
|
|
+ this.goBack()
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
})
|
|
})
|
|
|
.attributeModifier(new ShadowModifier())
|
|
.attributeModifier(new ShadowModifier())
|
|
|
.zIndex(0)
|
|
.zIndex(0)
|
|
|
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ if(!this.isSearchMode){
|
|
|
|
|
+ Text(this.selectedAccount.name)
|
|
|
|
|
+ .margin({left:3,right:10})
|
|
|
|
|
+ .fontColor($r('app.color.text_color'))
|
|
|
|
|
+ .fontSize(19)
|
|
|
|
|
+ .maxLines(1)
|
|
|
|
|
+ .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
|
|
|
|
|
+ .layoutWeight(1)
|
|
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
//搜索框
|
|
//搜索框
|
|
|
Search({ controller: this.searchController,value: this.searchText, placeholder: '搜索标题、艺术家...' })
|
|
Search({ controller: this.searchController,value: this.searchText, placeholder: '搜索标题、艺术家...' })
|
|
|
.searchButton('搜索',{fontColor:this.themeColor})
|
|
.searchButton('搜索',{fontColor:this.themeColor})
|
|
@@ -1570,6 +2297,30 @@ export struct WebDavMainPage {
|
|
|
.width('100%')
|
|
.width('100%')
|
|
|
.height('100%')
|
|
.height('100%')
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ @Builder
|
|
|
|
|
+ DownloadCenterBuilder() {
|
|
|
|
|
+ DownloadCenter({
|
|
|
|
|
+ themeColor: this.themeColor,
|
|
|
|
|
+ isDarkMode: this.isDarkMode,
|
|
|
|
|
+ appName: this.appName,
|
|
|
|
|
+ topSafeHeight: this.topSafeHeight,
|
|
|
|
|
+ bottomSafeHeight: this.bottomSafeHeight,
|
|
|
|
|
+ selectedIndexes: $downloadCenterTabIndex,
|
|
|
|
|
+ onPauseTask: (taskId: string) => {
|
|
|
|
|
+ this.pauseDownloadTask(taskId);
|
|
|
|
|
+ },
|
|
|
|
|
+ onResumeTask: (taskId: string) => {
|
|
|
|
|
+ this.resumeDownloadTask(taskId);
|
|
|
|
|
+ },
|
|
|
|
|
+ onClose: () => {
|
|
|
|
|
+ this.isShowDownloadCenter = false;
|
|
|
|
|
+ }
|
|
|
|
|
+ })
|
|
|
|
|
+ .width('100%')
|
|
|
|
|
+ .height('100%')
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
//搜索功能的实现
|
|
//搜索功能的实现
|
|
|
@State searchText: string = ''; // 用户输入内容
|
|
@State searchText: string = ''; // 用户输入内容
|
|
|
@State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
|
|
@State filteredList: Array<VideoItem> = []; // 过滤后的歌曲结果
|
|
@@ -1643,6 +2394,9 @@ export struct WebDavMainPage {
|
|
|
.backgroundImagePosition(Alignment.Center)
|
|
.backgroundImagePosition(Alignment.Center)
|
|
|
.backdropBlur(this.blurValue)
|
|
.backdropBlur(this.blurValue)
|
|
|
.backgroundBrightness({rate:this.isCustomizeBg?0.1:0,lightUpDegree:this.bgBrightness})
|
|
.backgroundBrightness({rate:this.isCustomizeBg?0.1:0,lightUpDegree:this.bgBrightness})
|
|
|
|
|
+ .bindContentCover($$this.isShowDownloadCenter, this.DownloadCenterBuilder(), {
|
|
|
|
|
+ transition: TransitionEffect.translate({ x: 500 }).animation({ curve: curves.springMotion(0.6, 0.8) })
|
|
|
|
|
+ })
|
|
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
|
|
.expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -1654,8 +2408,9 @@ export struct WebDavMainPage {
|
|
|
if (this.webdavManager.currentPath !== '') {
|
|
if (this.webdavManager.currentPath !== '') {
|
|
|
Row({ space: 8 }) {
|
|
Row({ space: 8 }) {
|
|
|
Button({ type: ButtonType.Circle }) {
|
|
Button({ type: ButtonType.Circle }) {
|
|
|
- Image(this.webdavManager.canGoBack()?$r('app.media.back'):
|
|
|
|
|
- this.selectedAccount.coverPath?this.selectedAccount.coverPath:getCloudDiskIcon(this.selectedAccount.webType))
|
|
|
|
|
|
|
+ // Image(this.webdavManager.canGoBack()?$r('app.media.back'):
|
|
|
|
|
+ // this.selectedAccount.coverPath?this.selectedAccount.coverPath:getCloudDiskIcon(this.selectedAccount.webType))
|
|
|
|
|
+ Image(this.selectedAccount.coverPath)
|
|
|
.width(15)
|
|
.width(15)
|
|
|
.height(15)
|
|
.height(15)
|
|
|
.borderRadius(10)
|
|
.borderRadius(10)
|
|
@@ -1757,8 +2512,6 @@ export struct WebDavMainPage {
|
|
|
ListItem() {
|
|
ListItem() {
|
|
|
this.buildFolderItem(folder)
|
|
this.buildFolderItem(folder)
|
|
|
}
|
|
}
|
|
|
- // .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 600 }),
|
|
|
|
|
- // TransitionEffect.scale({ x: 0, y: 0 })))
|
|
|
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE })
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE })
|
|
|
}, (folder: FileInfo) => folder.name+folder.fileName)
|
|
}, (folder: FileInfo) => folder.name+folder.fileName)
|
|
|
|
|
|
|
@@ -1767,8 +2520,6 @@ export struct WebDavMainPage {
|
|
|
ListItem() {
|
|
ListItem() {
|
|
|
this.buildSongItem(song, index)
|
|
this.buildSongItem(song, index)
|
|
|
}
|
|
}
|
|
|
- // .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 600 }),
|
|
|
|
|
- // TransitionEffect.scale({ x: 0, y: 0 })))
|
|
|
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE })
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE })
|
|
|
}, (item: VideoItem, index: number) => item.filePath + '_' + index+this.listRefreshKey)
|
|
}, (item: VideoItem, index: number) => item.filePath + '_' + index+this.listRefreshKey)
|
|
|
}
|
|
}
|
|
@@ -1833,6 +2584,28 @@ export struct WebDavMainPage {
|
|
|
.enabled(this.selectedSongs.length > 0 && !this.isDeletingSelection)
|
|
.enabled(this.selectedSongs.length > 0 && !this.isDeletingSelection)
|
|
|
.onClick(() => this.confirmDeleteSelected())
|
|
.onClick(() => this.confirmDeleteSelected())
|
|
|
|
|
|
|
|
|
|
+ Button('+歌单', { type: ButtonType.Circle, stateEffect: true })
|
|
|
|
|
+ .width(55)
|
|
|
|
|
+ .height(55)
|
|
|
|
|
+ .fontSize(13)
|
|
|
|
|
+ .backgroundColor(this.themeColor)
|
|
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
|
|
|
|
|
+ .enabled(this.selectedSongs.length > 0 && !this.isDeletingSelection)
|
|
|
|
|
+ .onClick(() => {
|
|
|
|
|
+ void this.openSelectedSongsAddToPlaylistDialog();
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ Button('下载', { type: ButtonType.Circle, stateEffect: true })
|
|
|
|
|
+ .width(55)
|
|
|
|
|
+ .height(55)
|
|
|
|
|
+ .fontSize(13)
|
|
|
|
|
+ .backgroundColor(this.themeColor)
|
|
|
|
|
+ .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.5 })
|
|
|
|
|
+ .enabled(this.selectedSongs.length > 0 && !this.isDeletingSelection)
|
|
|
|
|
+ .onClick(() => {
|
|
|
|
|
+ void this.enqueueSelectedSongsDownload();
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
Button('取消', { type: ButtonType.Circle, stateEffect: true })
|
|
Button('取消', { type: ButtonType.Circle, stateEffect: true })
|
|
|
.width(55)
|
|
.width(55)
|
|
|
.height(55)
|
|
.height(55)
|
|
@@ -2006,15 +2779,86 @@ export struct WebDavMainPage {
|
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
|
|
.clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
|
|
|
.backgroundColor(Color.Transparent)
|
|
.backgroundColor(Color.Transparent)
|
|
|
.onClick(() => {
|
|
.onClick(() => {
|
|
|
|
|
+ if (this.ignoreTapAfterExitMultiSelect) {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
if (this.isMultiSelect) {
|
|
if (this.isMultiSelect) {
|
|
|
this.toggleSongSelection(song);
|
|
this.toggleSongSelection(song);
|
|
|
} else {
|
|
} else {
|
|
|
this.playSong(song, index);
|
|
this.playSong(song, index);
|
|
|
}
|
|
}
|
|
|
})
|
|
})
|
|
|
- .gesture(LongPressGesture().onAction(() => {
|
|
|
|
|
- this.enterMultiSelect(song);
|
|
|
|
|
- }))
|
|
|
|
|
|
|
+ // .gesture(LongPressGesture().onAction(() => {
|
|
|
|
|
+ // this.enterMultiSelect(song);
|
|
|
|
|
+ // }))
|
|
|
|
|
+ .bindContextMenu(this.LongPressMenuBuilder(song), ResponseType.LongPress,
|
|
|
|
|
+ {
|
|
|
|
|
+ preview: MenuPreviewMode.IMAGE
|
|
|
|
|
+ })
|
|
|
|
|
+ .bindContextMenu(this.LongPressMenuBuilder(song), ResponseType.RightClick,
|
|
|
|
|
+ {
|
|
|
|
|
+ preview: MenuPreviewMode.IMAGE
|
|
|
|
|
+ })
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ @Builder
|
|
|
|
|
+ LongPressMenuBuilder(song: VideoItem) {
|
|
|
|
|
+
|
|
|
|
|
+ Scroll() {
|
|
|
|
|
+ Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) {
|
|
|
|
|
+ Menu() {
|
|
|
|
|
+
|
|
|
|
|
+ MenuItem({
|
|
|
|
|
+ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.forward_end_fill')),
|
|
|
|
|
+ content: '下一首播放'
|
|
|
|
|
+ })
|
|
|
|
|
+ .onClick(() => {
|
|
|
|
|
+ this.addSongToNextPlay(song);
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ MenuItem({
|
|
|
|
|
+ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.music_note_list')),
|
|
|
|
|
+ content: '添加到歌单'
|
|
|
|
|
+ })
|
|
|
|
|
+ .onClick(async () => {
|
|
|
|
|
+ await this.openSongAddToPlaylistDialog(song);
|
|
|
|
|
+ })
|
|
|
|
|
+ //多选
|
|
|
|
|
+ MenuItem({
|
|
|
|
|
+ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.checkmark_square_on_square')),
|
|
|
|
|
+ content: $r('app.string.select_all')
|
|
|
|
|
+ })
|
|
|
|
|
+ .onClick(() => {
|
|
|
|
|
+ this.enterMultiSelect(song);
|
|
|
|
|
+ })
|
|
|
|
|
+ //下载
|
|
|
|
|
+ MenuItem({
|
|
|
|
|
+ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.download')),
|
|
|
|
|
+ content: '下载'
|
|
|
|
|
+ })
|
|
|
|
|
+ .onClick(async () => {
|
|
|
|
|
+ await this.enqueueSongDownload(song);
|
|
|
|
|
+ })
|
|
|
|
|
+ //删除
|
|
|
|
|
+ MenuItem({
|
|
|
|
|
+ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trash')),
|
|
|
|
|
+ content: $r('app.string.delete')
|
|
|
|
|
+ })
|
|
|
|
|
+ .onClick(() => {
|
|
|
|
|
+ this.confirmDeleteSingleSong(song);
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ }
|
|
|
|
|
+ .font({ size: 14, weight: FontWeight.Normal })
|
|
|
|
|
+ }
|
|
|
|
|
+ .width(180)
|
|
|
|
|
+ }
|
|
|
|
|
+ .height('auto')
|
|
|
|
|
+ .enableScrollInteraction(true)
|
|
|
|
|
+ .scrollBar(BarState.Off)
|
|
|
|
|
+
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
|