├── entry ├── src │ ├── mock │ │ └── mock-config.json5 │ ├── main │ │ ├── resources │ │ │ ├── base │ │ │ │ ├── profile │ │ │ │ │ ├── backup_config.json │ │ │ │ │ └── main_pages.json │ │ │ │ ├── media │ │ │ │ │ ├── ffmpeg.png │ │ │ │ │ ├── Background.png │ │ │ │ │ ├── Foreground.png │ │ │ │ │ ├── RedPlayer.bmp │ │ │ │ │ ├── startIcon.png │ │ │ │ │ ├── sweet_video.png │ │ │ │ │ ├── hdr_vivid_icon.png │ │ │ │ │ ├── sweet_video_alt.png │ │ │ │ │ ├── audio_vivid_icon.png │ │ │ │ │ ├── foreground_start_up.png │ │ │ │ │ └── layered_image.json │ │ │ │ └── element │ │ │ │ │ ├── color.json │ │ │ │ │ └── string.json │ │ │ ├── dark │ │ │ │ └── element │ │ │ │ │ └── color.json │ │ │ ├── rawfile │ │ │ │ └── README.md │ │ │ ├── zh_CN │ │ │ │ └── element │ │ │ │ │ └── string.json │ │ │ └── en_US │ │ │ │ └── element │ │ │ │ └── string.json │ │ ├── ets │ │ │ ├── common │ │ │ │ ├── enum │ │ │ │ │ ├── DeleteType.ets │ │ │ │ │ ├── BackgroundColorMode.ets │ │ │ │ │ ├── RepeatMode.ets │ │ │ │ │ ├── PlayStatus.ets │ │ │ │ │ ├── ListDisplayMode.ets │ │ │ │ │ ├── SubtitleMode.ets │ │ │ │ │ ├── VideoStartTimeMode.ets │ │ │ │ │ └── VideoSource.ets │ │ │ │ ├── SubtitleFormatConfig.ets │ │ │ │ ├── LanguageConfig.ets │ │ │ │ ├── NavigationCommon.ets │ │ │ │ └── AttributeModifierConfig.ets │ │ │ ├── interfaces │ │ │ │ ├── FastForwardSecondInterface.ets │ │ │ │ ├── AvSessionStateInterface.ets │ │ │ │ ├── IMenuItemInterface.ets │ │ │ │ ├── AudioTrackInterface.ets │ │ │ │ ├── ShadowFancyInterface.ets │ │ │ │ ├── VideoItemWithPinyinInterface.ets │ │ │ │ ├── FileFolderInterface.ets │ │ │ │ ├── SubtitleInterface.ets │ │ │ │ ├── VideoMetadataFromPlayerInterface.ets │ │ │ │ └── VideoMetadataInterface.ets │ │ │ ├── modules │ │ │ │ └── subtitles │ │ │ │ │ ├── src │ │ │ │ │ └── main │ │ │ │ │ │ ├── ets │ │ │ │ │ │ ├── models │ │ │ │ │ │ │ ├── VttStyle.ts │ │ │ │ │ │ │ ├── SrtLine.ts │ │ │ │ │ │ │ ├── VttLine.ts │ │ │ │ │ │ │ ├── Alignment.ts │ │ │ │ │ │ │ ├── VttRegion.ts │ │ │ │ │ │ │ ├── AssEvent.ts │ │ │ │ │ │ │ └── AssStyle.ts │ │ │ │ │ │ ├── ParseSrt.ts │ │ │ │ │ │ ├── ParseVtt.ts │ │ │ │ │ │ └── ParseAss.ts │ │ │ │ │ │ └── module.json │ │ │ │ │ ├── build-profile.json5 │ │ │ │ │ ├── hvigorfile.ts │ │ │ │ │ ├── CHANGELOG.md │ │ │ │ │ ├── BuildProfile.ets │ │ │ │ │ ├── index.ts │ │ │ │ │ ├── oh-package.json5 │ │ │ │ │ └── README.md │ │ │ ├── controller │ │ │ │ ├── RedPlayerController │ │ │ │ │ ├── RedPlayerSettingsConfig.ets │ │ │ │ │ └── RedPlayerStateHolder.ets │ │ │ │ └── PlayerControllerFactory.ets │ │ │ ├── utils │ │ │ │ ├── PrivacySpaceUtil.ets │ │ │ │ ├── LoggerImpl.ets │ │ │ │ ├── DataSyncUtil.ets │ │ │ │ ├── AVPlayerUtil.ets │ │ │ │ ├── PathUtils.ets │ │ │ │ ├── VideoUtil.ets │ │ │ │ ├── ObservedUtil.ets │ │ │ │ ├── TimeUtil.ets │ │ │ │ ├── BiometricAccessUtil.ets │ │ │ │ ├── TaskpoolUtil.ets │ │ │ │ ├── RecentPlayUtil.ets │ │ │ │ ├── AnimationUtil.ets │ │ │ │ ├── BaseEventUtil.ets │ │ │ │ └── PermissionUtil.ets │ │ │ ├── entrybackupability │ │ │ │ └── EntryBackupAbility.ets │ │ │ ├── component │ │ │ │ ├── PlayerComponent │ │ │ │ │ ├── AVCastPickerBuilder.ets │ │ │ │ │ ├── LockVideoBarComponent.ets │ │ │ │ │ ├── SubtitleDisplayBuilder.ets │ │ │ │ │ ├── BrightnessSwipingBuilder.ets │ │ │ │ │ ├── PlaybackSpeedComponent.ets │ │ │ │ │ ├── AIAsrComponent.ets │ │ │ │ │ ├── SwipingPlayTimePanelBuilder.ets │ │ │ │ │ ├── VideoSliderComponent.ets │ │ │ │ │ ├── FastForwardPanelBuilder.ets │ │ │ │ │ ├── VolumeSwipingPanelBuilder.ets │ │ │ │ │ ├── AudioTrackComponent.ets │ │ │ │ │ ├── SubtitlePanelComponent.ets │ │ │ │ │ ├── GestureComponent.ets │ │ │ │ │ ├── VideoTopComponent.ets │ │ │ │ │ ├── VideoSettingComponent.ets │ │ │ │ │ └── PlayerSideBarComponent.ets │ │ │ │ ├── FileFolderComponent │ │ │ │ │ ├── FileFolderSelectBuilder.ets │ │ │ │ │ ├── FileFolderListComponent.ets │ │ │ │ │ ├── FileFolderMenu.ets │ │ │ │ │ └── FileFolderView.ets │ │ │ │ ├── SideBarComponent │ │ │ │ │ ├── MaskComponent.ets │ │ │ │ │ └── SideBar.ets │ │ │ │ ├── VideoItemComponent │ │ │ │ │ ├── SortComponent.ets │ │ │ │ │ ├── ImportProgressBuilder.ets │ │ │ │ │ ├── SearchComponent.ets │ │ │ │ │ ├── VideoListItemComponent.ets │ │ │ │ │ ├── VideoCardItemComponent.ets │ │ │ │ │ ├── VideoInfoBuilder.ets │ │ │ │ │ └── PrivacyPolicyComponent.ets │ │ │ │ ├── SettingComponent │ │ │ │ │ ├── SettingsToggleItem.ets │ │ │ │ │ ├── SettingsClickItem.ets │ │ │ │ │ ├── SettingsMenuItem.ets │ │ │ │ │ ├── SettingsCheckboxItem.ets │ │ │ │ │ ├── SettingSliderItem.ets │ │ │ │ │ └── SettingTimeItem.ets │ │ │ │ └── Dialog │ │ │ │ │ ├── InfoConfirmDialog.ets │ │ │ │ │ ├── DeletedVideosDialog.ets │ │ │ │ │ ├── EditPasswordDialog.ets │ │ │ │ │ ├── EditFileFolderNameDialog.ets │ │ │ │ │ ├── EditMetadataDialog.ets │ │ │ │ │ ├── AddFileFolderNameDialog.ets │ │ │ │ │ └── VideoDetailDialog.ets │ │ │ ├── database │ │ │ │ ├── PreferencesUtil.ets │ │ │ │ └── VideoMetaData.ets │ │ │ └── pages │ │ │ │ ├── SettingPage │ │ │ │ └── PersonalizePage.ets │ │ │ │ └── PrivacyInfo.ets │ │ └── module.json5 │ ├── test │ │ ├── List.test.ets │ │ └── LocalUnit.test.ets │ └── ohosTest │ │ ├── ets │ │ └── test │ │ │ ├── List.test.ets │ │ │ └── Ability.test.ets │ │ └── module.json5 ├── .gitignore ├── libs │ ├── x86_64 │ │ ├── libsubtitles.so │ │ └── libc++_shared.so │ └── arm64-v8a │ │ ├── libc++_shared.so │ │ └── libsubtitles.so ├── oh-package.json5 ├── hvigorfile.ts ├── oh-package-lock.json5 ├── obfuscation-rules.txt └── build-profile.json5 ├── feature └── salmonlogger.har ├── AppScope ├── resources │ └── base │ │ ├── media │ │ ├── app_icon.png │ │ ├── Background.png │ │ ├── Foreground.png │ │ └── layered_image.json │ │ └── element │ │ └── string.json └── app.json5 ├── .gitignore ├── hvigorfile.ts ├── code-linter.json5 ├── oh-package.json5 ├── hvigor └── hvigor-config.json5 └── README.md /entry/src/mock/mock-config.json5: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /entry/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /oh_modules 3 | /.preview 4 | /build 5 | /.cxx 6 | /.test -------------------------------------------------------------------------------- /entry/src/main/resources/base/profile/backup_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "allowToBackupRestore": true 3 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/profile/main_pages.json: -------------------------------------------------------------------------------- 1 | { 2 | "src": [ 3 | "pages/Index" 4 | ] 5 | } -------------------------------------------------------------------------------- /feature/salmonlogger.har: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/feature/salmonlogger.har -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/DeleteType.ets: -------------------------------------------------------------------------------- 1 | export const enum DeleteType { 2 | SOFT, 3 | HARD, 4 | RECENT 5 | } -------------------------------------------------------------------------------- /entry/libs/x86_64/libsubtitles.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/libs/x86_64/libsubtitles.so -------------------------------------------------------------------------------- /entry/libs/x86_64/libc++_shared.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/libs/x86_64/libc++_shared.so -------------------------------------------------------------------------------- /entry/libs/arm64-v8a/libc++_shared.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/libs/arm64-v8a/libc++_shared.so -------------------------------------------------------------------------------- /entry/libs/arm64-v8a/libsubtitles.so: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/libs/arm64-v8a/libsubtitles.so -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/BackgroundColorMode.ets: -------------------------------------------------------------------------------- 1 | export const enum BackgroundColorMode { 2 | AUTO, 3 | LIGHT, 4 | DARK 5 | } -------------------------------------------------------------------------------- /AppScope/resources/base/media/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/AppScope/resources/base/media/app_icon.png -------------------------------------------------------------------------------- /AppScope/resources/base/media/Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/AppScope/resources/base/media/Background.png -------------------------------------------------------------------------------- /AppScope/resources/base/media/Foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/AppScope/resources/base/media/Foreground.png -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/RepeatMode.ets: -------------------------------------------------------------------------------- 1 | export const enum RepeatMode { 2 | LIST, 3 | SINGLE, 4 | RANDOM, 5 | SEQUENCE, 6 | ONCE 7 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/PlayStatus.ets: -------------------------------------------------------------------------------- 1 | // FFMpeg 播放器状态枚举值 2 | export const enum PlayStatus { 3 | INIT, 4 | PLAY, 5 | PAUSE, 6 | DONE 7 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/FastForwardSecondInterface.ets: -------------------------------------------------------------------------------- 1 | export interface FastForwardSecondInterface { 2 | label: string; 3 | value: string; 4 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/ffmpeg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/ffmpeg.png -------------------------------------------------------------------------------- /entry/src/test/List.test.ets: -------------------------------------------------------------------------------- 1 | import localUnitTest from './LocalUnit.test'; 2 | 3 | export default function testsuite() { 4 | localUnitTest(); 5 | } -------------------------------------------------------------------------------- /AppScope/resources/base/element/string.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": [ 3 | { 4 | "name": "app_name", 5 | "value": "SweetVideo" 6 | } 7 | ] 8 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/AvSessionStateInterface.ets: -------------------------------------------------------------------------------- 1 | // 播控中心数据 2 | export interface AvSessionState { 3 | playing: boolean 4 | duration: number 5 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/Background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/Background.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/Foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/Foreground.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/RedPlayer.bmp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/RedPlayer.bmp -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/startIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/startIcon.png -------------------------------------------------------------------------------- /entry/src/ohosTest/ets/test/List.test.ets: -------------------------------------------------------------------------------- 1 | import abilityTest from './Ability.test'; 2 | 3 | export default function testsuite() { 4 | abilityTest(); 5 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/ListDisplayMode.ets: -------------------------------------------------------------------------------- 1 | export const enum ListDisplayMode { 2 | CARD_DISPLAY = 0, 3 | LIST_DISPLAY = 1, 4 | GRID_DISPLAY = 2 5 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/SubtitleMode.ets: -------------------------------------------------------------------------------- 1 | export const enum SubtitleMode { 2 | INNER_SUBTITLE = 0, 3 | EXTERNAL_SUBTITLE = 1, 4 | AI_SUBTITLE = 2 5 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/IMenuItemInterface.ets: -------------------------------------------------------------------------------- 1 | export interface IMenuItem { 2 | icon: Resource 3 | content: ResourceStr 4 | onAction?: () => void 5 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/VttStyle.ts: -------------------------------------------------------------------------------- 1 | export class VttStyle { 2 | selector?: string 3 | entries: Record 4 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/sweet_video.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/sweet_video.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/hdr_vivid_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/hdr_vivid_icon.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/sweet_video_alt.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/sweet_video_alt.png -------------------------------------------------------------------------------- /AppScope/resources/base/media/layered_image.json: -------------------------------------------------------------------------------- 1 | { 2 | "layered-image": { 3 | "background": "$media:Background", 4 | "foreground": "$media:Foreground" 5 | } 6 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/audio_vivid_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/audio_vivid_icon.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/foreground_start_up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Yebingiscn/SweetVideo/HEAD/entry/src/main/resources/base/media/foreground_start_up.png -------------------------------------------------------------------------------- /entry/src/main/resources/base/media/layered_image.json: -------------------------------------------------------------------------------- 1 | { 2 | "layered-image": { 3 | "background": "$media:Background", 4 | "foreground": "$media:Foreground" 5 | } 6 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/AudioTrackInterface.ets: -------------------------------------------------------------------------------- 1 | // 音频数据 2 | export interface AudioTrack { 3 | index: number; 4 | language: string; 5 | name: string; 6 | mime: string 7 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/SubtitleFormatConfig.ets: -------------------------------------------------------------------------------- 1 | export default class SubtitleFormat { 2 | static SRT: string = 'srt' 3 | static ASS: string = 'ass' 4 | static VTT: string = 'vtt' 5 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/VideoStartTimeMode.ets: -------------------------------------------------------------------------------- 1 | // 起始时间枚举 2 | export const enum VideoStartTimeMode { 3 | SEEK_TO_LAST_PLAY_TIME, 4 | SEEK_TO_START_TIME, 5 | SEEK_TO_INTRO_TIME 6 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/ShadowFancyInterface.ets: -------------------------------------------------------------------------------- 1 | // 阴影数据 2 | export interface ShadowFancy { 3 | radius: number; 4 | color: Resource; 5 | offsetX: number; 6 | offsetY: number; 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /oh_modules 3 | /local.properties 4 | /.idea 5 | **/build 6 | /.hvigor 7 | .cxx 8 | /.clangd 9 | /.clang-format 10 | /.clang-tidy 11 | **/.test 12 | /.appanalyzer -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/SrtLine.ts: -------------------------------------------------------------------------------- 1 | export class SrtLine { 2 | endTimestampMillis: number; 3 | startTimestampMillis: number; 4 | text: string; 5 | sequenceNumber: number; 6 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/VideoItemWithPinyinInterface.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadata } from './VideoMetadataInterface'; 2 | 3 | export interface VideoItemWithPinyin { 4 | item: VideoMetadata; 5 | pinyin: string; 6 | } -------------------------------------------------------------------------------- /entry/oh-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | "name": "entry", 3 | "version": "1.0.1", 4 | "description": "Please describe the basic information.", 5 | "main": "", 6 | "author": "", 7 | "license": "", 8 | "dependencies": {} 9 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/enum/VideoSource.ets: -------------------------------------------------------------------------------- 1 | export const enum VideoSource { 2 | SWEET_VIDEO_DOWNLOAD_FILE = 1, 3 | DOCUMENT_SELECT_OPTION = 2, 4 | PHOTO_SELECT_OPTION = 3, 5 | PRIVACY_SPACE = 4, 6 | EXTERNAL_DEVICE = 5 7 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/FileFolderInterface.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadata } from './VideoMetadataInterface'; 2 | 3 | // 文件夹 4 | export interface FileFolder { 5 | name: string, 6 | date: string, 7 | video_list: VideoMetadata[] 8 | } -------------------------------------------------------------------------------- /hvigorfile.ts: -------------------------------------------------------------------------------- 1 | import { appTasks } from '@ohos/hvigor-ohos-plugin'; 2 | 3 | export default { 4 | system: appTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ 5 | plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ 6 | } -------------------------------------------------------------------------------- /entry/hvigorfile.ts: -------------------------------------------------------------------------------- 1 | import { hapTasks } from '@ohos/hvigor-ohos-plugin'; 2 | 3 | export default { 4 | system: hapTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ 5 | plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ 6 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/build-profile.json5: -------------------------------------------------------------------------------- 1 | { 2 | "apiType": "stageMode", 3 | "buildOption": { 4 | }, 5 | "targets": [ 6 | { 7 | "name": "default" 8 | }, 9 | { 10 | "name": "ohosTest" 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/VttLine.ts: -------------------------------------------------------------------------------- 1 | export class VttLine { 2 | identifier?: string; 3 | startTimestampMillis: number; 4 | endTimestampMillis: number; 5 | settings: Record; 6 | text: string; 7 | } -------------------------------------------------------------------------------- /AppScope/app.json5: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "bundleName": "com.example.sweetvideo", 4 | "vendor": "example", 5 | "versionCode": 1000026, 6 | "versionName": "1.1.3", 7 | "icon": "$media:layered_image", 8 | "label": "$string:app_name" 9 | } 10 | } -------------------------------------------------------------------------------- /entry/src/main/ets/controller/RedPlayerController/RedPlayerSettingsConfig.ets: -------------------------------------------------------------------------------- 1 | //红薯播放器设置配置 2 | export default class RedPlayerSettingsConfig { 3 | static displayDebugPanel = false 4 | static useSoftDecoder = false 5 | static useSystemPlayer = false 6 | static useLoopPlay = false 7 | } -------------------------------------------------------------------------------- /entry/oh-package-lock.json5: -------------------------------------------------------------------------------- 1 | { 2 | "meta": { 3 | "stableOrder": true, 4 | "enableUnifiedLockfile": false 5 | }, 6 | "lockfileVersion": 3, 7 | "ATTENTION": "THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.", 8 | "specifiers": {}, 9 | "packages": {} 10 | } -------------------------------------------------------------------------------- /entry/src/ohosTest/module.json5: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "name": "entry_test", 4 | "type": "feature", 5 | "deviceTypes": [ 6 | "phone", 7 | "tablet", 8 | "2in1" 9 | ], 10 | "deliveryWithInstall": true, 11 | "installationFree": false 12 | } 13 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/Alignment.ts: -------------------------------------------------------------------------------- 1 | export enum Alignment { 2 | BottomLeft = 1, 3 | BottomCenter = 2, 4 | BottomRight = 3, 5 | MiddleLeft = 4, 6 | MiddleCenter = 5, 7 | MiddleRight = 6, 8 | TopLeft = 7, 9 | TopCenter = 8, 10 | TopRight = 9 11 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/hvigorfile.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import { harTasks } from '@ohos/hvigor-ohos-plugin'; 3 | 4 | export default { 5 | system: harTasks, /* Built-in plugin of Hvigor. It cannot be modified. */ 6 | plugins: [] /* Custom plugin to extend the functionality of Hvigor. */ 7 | } 8 | -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/VttRegion.ts: -------------------------------------------------------------------------------- 1 | export class VttRegion { 2 | id?: string; 3 | width?: number; 4 | lines?: number; 5 | regionAnchorX?: number; 6 | regionAnchorY?: number; 7 | viewportAnchorX?: number; 8 | viewportAnchorY?: number; 9 | scroll: boolean; 10 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/SubtitleInterface.ets: -------------------------------------------------------------------------------- 1 | export interface SubtitleItem { 2 | startTimestampMillis: number; // 毫秒时间戳 3 | endTimestampMillis: number; // 毫秒时间戳 4 | text: string; 5 | } 6 | 7 | // 定义解析器接口 8 | export interface Parser { 9 | init(): Promise; 10 | 11 | readLines(): Promise; 12 | } -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/VideoMetadataFromPlayerInterface.ets: -------------------------------------------------------------------------------- 1 | // 播放器数据结构 2 | export interface VideoMetadataFromPlayer { 3 | uri: string 4 | title: string 5 | date: string 6 | size: number[] 7 | time: number 8 | last_play: number 9 | start_time: number 10 | end_time: number 11 | external_subtitle_format: string 12 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [v0.3.2] 2025.03.10 4 | 5 | 发布第一个版本 6 | 7 | ## [v0.3.3] 2025.9.28 8 | 9 | - 升级依赖到0.3.3 10 | - 修复ass解析的一些问题 11 | 12 | ---- 13 | 14 | # 下面是流心修改的部分 15 | 16 | ## [v0.3.4] 2025.10.24 17 | 18 | - 修复 srt ass vtt 字幕解析异常的问题 19 | - 修复没有创建对象导致调用 undefined 的问题 20 | - napi-ohos 升级到 1.1.3 -------------------------------------------------------------------------------- /entry/src/main/ets/interfaces/VideoMetadataInterface.ets: -------------------------------------------------------------------------------- 1 | // 视频数据 2 | export interface VideoMetadata { 3 | uri: string 4 | title: string 5 | date: string 6 | size: number[] 7 | time: number 8 | last_play: number 9 | format: string 10 | video_size: string 11 | hdr_type: number 12 | start_time: number 13 | end_time: number 14 | external_subtitle_format: string 15 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/AssEvent.ts: -------------------------------------------------------------------------------- 1 | export declare class AssEvent { 2 | layer: number; 3 | startTimestampMillis: number; 4 | endTimestampMillis: number; 5 | style: string; 6 | name: string; 7 | marginLeft: number; 8 | marginRight: number; 9 | marginTop: number; 10 | marginBottom: number; 11 | effect: string; 12 | text: string; 13 | } -------------------------------------------------------------------------------- /code-linter.json5: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "**/*.ets" 4 | ], 5 | "ignore": [ 6 | "**/src/ohosTest/**/*", 7 | "**/src/test/**/*", 8 | "**/src/mock/**/*", 9 | "**/node_modules/**/*", 10 | "**/oh_modules/**/*", 11 | "**/build/**/*", 12 | "**/.preview/**/*" 13 | ], 14 | "ruleSet": [ 15 | "plugin:@performance/recommended", 16 | "plugin:@typescript-eslint/recommended" 17 | ], 18 | "rules": { 19 | } 20 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/PrivacySpaceUtil.ets: -------------------------------------------------------------------------------- 1 | // 隐私空间工具类 2 | export default class PrivacySpaceUtil { 3 | // 标识当前是否激活隐私空间 4 | private static readonly PRIVATE_MODE_KEY = 'isPrivateMode'; 5 | 6 | static setPrivacyMode(privacy_mode: boolean) { 7 | AppStorage.setOrCreate(PrivacySpaceUtil.PRIVATE_MODE_KEY, privacy_mode); 8 | } 9 | 10 | static getPrivacyMode() { 11 | return AppStorage.get(PrivacySpaceUtil.PRIVATE_MODE_KEY); 12 | } 13 | } -------------------------------------------------------------------------------- /entry/src/main/ets/controller/RedPlayerController/RedPlayerStateHolder.ets: -------------------------------------------------------------------------------- 1 | //红薯播放器常用设置项 2 | export default class RedPlayerStateHolder { 3 | videoDuration: number = 0 4 | videoPosition: number = 0 5 | videoWidth: number = 0 6 | videoHeight: number = 0 7 | videoViewWidth: Length = '100%' 8 | videoViewHeight: Length = '100%' 9 | url: string = '' 10 | isStart: boolean = false 11 | speedIndex: number = 1 12 | videoCodec: string = '' 13 | audioCodec: string = '' 14 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/ParseSrt.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import * as lib from 'libsubtitles.so'; 3 | import { SrtLine } from './models/SrtLine'; 4 | 5 | export class ParseSrt { 6 | private readonly instance; 7 | 8 | constructor(path: string) { 9 | this.instance = new lib.ParseSrt(path); 10 | } 11 | 12 | init(): Promise { 13 | return this.instance.init(); 14 | } 15 | 16 | readLines(): Promise { 17 | return this.instance.readLines(); 18 | } 19 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/LoggerImpl.ets: -------------------------------------------------------------------------------- 1 | import { IRedPlayerLogger } from '@rte-xhs/redplayer' 2 | 3 | export default class LoggerImpl implements IRedPlayerLogger { 4 | i(tag: string, msg?: string) { 5 | console.info(tag, msg) 6 | } 7 | 8 | d(tag: string, msg?: string) { 9 | console.debug(tag, msg) 10 | } 11 | 12 | w(tag: string, msg?: string) { 13 | console.warn(tag, msg) 14 | } 15 | 16 | e(tag: string, msg?: string, error?: Error) { 17 | console.error(tag, msg, error) 18 | } 19 | } -------------------------------------------------------------------------------- /entry/src/main/ets/entrybackupability/EntryBackupAbility.ets: -------------------------------------------------------------------------------- 1 | import { hilog } from '@kit.PerformanceAnalysisKit'; 2 | import { BackupExtensionAbility, BundleVersion } from '@kit.CoreFileKit'; 3 | 4 | export default class EntryBackupAbility extends BackupExtensionAbility { 5 | async onBackup() { 6 | hilog.info(0x0000, 'testTag', 'onBackup ok'); 7 | } 8 | 9 | async onRestore(bundleVersion: BundleVersion) { 10 | hilog.info(0x0000, 'testTag', 'onRestore ok %{public}s', JSON.stringify(bundleVersion)); 11 | } 12 | } -------------------------------------------------------------------------------- /oh-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | "modelVersion": "5.0.1", 3 | "description": "Please describe the basic information.", 4 | "dependencies": { 5 | "@ohos/ijkplayer": "2.0.7-rc.6", 6 | "@ohos/pinyin4js": "^2.0.2", 7 | "@rte-xhs/redplayer": "^1.0.0", 8 | "salmonlogger": "feature/salmonlogger.har", 9 | "@ohos/juniversalchardet": "^2.0.3", 10 | "@luvi/lv-markdown-in": "^3.1.5" 11 | }, 12 | "devDependencies": { 13 | "@ohos/hypium": "1.0.19", 14 | "@ohos/hamock": "1.0.0" 15 | }, 16 | "dynamicDependencies": {} 17 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/AVCastPickerBuilder.ets: -------------------------------------------------------------------------------- 1 | import { ButtonFancyModifier, SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | 3 | @Builder 4 | export function AVCastPickerBuilder(): void { 5 | Button({ type: ButtonType.Circle, stateEffect: true }) { 6 | SymbolGlyph($r('sys.symbol.dot_radiowaves_left_and_right')) 7 | .attributeModifier(new SymbolGlyphFancyModifier(23, '', '')) 8 | .fontColor(['#f0f0f0']) 9 | } 10 | .attributeModifier(new ButtonFancyModifier(40, 40)) 11 | .backgroundColor('#50000000') 12 | } -------------------------------------------------------------------------------- /entry/src/main/ets/controller/PlayerControllerFactory.ets: -------------------------------------------------------------------------------- 1 | import { AVPlayerController } from './AVPlayerController' 2 | 3 | // 产品接口 interface 4 | export interface PlayerControllerInterface { 5 | backForward(): void 6 | 7 | fastForward(): void 8 | 9 | prepare(): void 10 | 11 | play(): void 12 | 13 | pause(): void 14 | 15 | release(): void 16 | 17 | seekTime(seekTime: number): void 18 | 19 | togglePlayback(): void 20 | } 21 | 22 | // 真正的工厂 controller 23 | export class PlayerControllerFactory { 24 | static createPlayerController(): AVPlayerController { 25 | return new AVPlayerController(); 26 | } 27 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/LanguageConfig.ets: -------------------------------------------------------------------------------- 1 | export class LanguageConfig { 2 | public static languageMap: Record = { 3 | 'und': '未知', 4 | 'chi': '中文', 5 | 'jpn': '日语', 6 | 'eng': '英语', 7 | 'kor': '韩语', 8 | 'fre': '法语', 9 | 'fra': '法语', 10 | 'ger': '德语', 11 | 'deu': '德语', 12 | 'spa': '西班牙语', 13 | 'rus': '俄语', 14 | 'ara': '阿拉伯语', 15 | 'por': '葡萄牙语', 16 | 'ita': '意大利语', 17 | 'hin': '印地语', 18 | 'tha': '泰语', 19 | 'ukr': '乌克兰语', 20 | 'tur': '土耳其语', 21 | 'ind': '印尼语', 22 | 'dut': '荷兰语', 23 | 'nld': '荷兰语', 24 | 'swe': '瑞典语' 25 | } 26 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/BuildProfile.ets: -------------------------------------------------------------------------------- 1 | /** 2 | * Use these variables when you tailor your ArkTS code. They must be of the const type. 3 | */ 4 | export const HAR_VERSION = '0.3.2'; 5 | 6 | export const BUILD_MODE_NAME = 'release'; 7 | 8 | export const DEBUG = false; 9 | 10 | export const TARGET_NAME = 'default'; 11 | 12 | /** 13 | * BuildProfile Class is used only for compatibility purposes. 14 | */ 15 | export default class BuildProfile { 16 | static readonly HAR_VERSION = HAR_VERSION; 17 | static readonly BUILD_MODE_NAME = BUILD_MODE_NAME; 18 | static readonly DEBUG = DEBUG; 19 | static readonly TARGET_NAME = TARGET_NAME; 20 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/DataSyncUtil.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../interfaces/FileFolderInterface'; 2 | import { VideoMetadata } from '../interfaces/VideoMetadataInterface'; 3 | 4 | // 数据类 5 | class DataSyncUtil { 6 | public editingVideo: VideoMetadata | undefined = undefined 7 | public editingFolder: FileFolder | undefined = undefined 8 | public deletedVideos: VideoMetadata[] = [] 9 | public delMultipleList: VideoMetadata[] = [] 10 | public minSideBarWidth: number = 100; // 最小侧边栏宽度 11 | public minContentWidth: number = 345; // 最小内容区宽度 12 | public lastPlayVideoIndex: number = -1 13 | } 14 | 15 | export default new DataSyncUtil(); 16 | 17 | export const OOBEVersion = 0 -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/index.ts: -------------------------------------------------------------------------------- 1 | export { ParseAss } from './src/main/ets/ParseAss'; 2 | 3 | export { ParseSrt } from './src/main/ets/ParseSrt'; 4 | 5 | export { ParseVtt } from './src/main/ets/ParseVtt'; 6 | 7 | export { Alignment } from './src/main/ets/models/Alignment'; 8 | 9 | export { AssEvent } from './src/main/ets/models/AssEvent'; 10 | 11 | export { AssStyle } from './src/main/ets/models/AssStyle'; 12 | 13 | export { SrtLine } from './src/main/ets/models/SrtLine'; 14 | 15 | export { VttLine } from './src/main/ets/models/VttLine'; 16 | 17 | export { VttRegion } from './src/main/ets/models/VttRegion'; 18 | 19 | export { VttStyle } from './src/main/ets/models/VttStyle'; 20 | -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/oh-package.json5: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@dove/subtitles", 3 | "version": "0.3.2", 4 | "description": "OpenHarmony/HarmonyOS 字幕解析库(OpenHarmony/HarmonyOS subtitles analysis library)", 5 | "main": "index.ts", 6 | "author": "michael", 7 | "license": "Mulan PSL v2", 8 | "keywords": [ 9 | "ssa", 10 | "ass", 11 | "vtt", 12 | "srt", 13 | "subtitle" 14 | ], 15 | "dependencies": {}, 16 | "metadata": { 17 | "sourceRoots": [ 18 | "./src/main" 19 | ], 20 | "debug": false, 21 | "nativeDebugSymbol": true 22 | }, 23 | "compatibleSdkVersion": 12, 24 | "compatibleSdkType": "OpenHarmony", 25 | "obfuscated": false 26 | } 27 | -------------------------------------------------------------------------------- /entry/src/main/ets/utils/AVPlayerUtil.ets: -------------------------------------------------------------------------------- 1 | import { media } from '@kit.MediaKit'; 2 | import { salmonLogger } from 'salmonlogger'; 3 | import ToolsUtil from './ToolsUtil'; 4 | 5 | // 系统播放器类 6 | export class AVPlayerUtil { 7 | static avPlayer: media.AVPlayer | undefined = undefined 8 | 9 | public static async getAVPlayer() { 10 | try { 11 | if (!AVPlayerUtil.avPlayer) { 12 | AVPlayerUtil.avPlayer = await media.createAVPlayer() 13 | } 14 | } catch (error) { 15 | ToolsUtil.showToast('播放异常') 16 | salmonLogger.addLog('avplayer', 17 | 'create avplayer error: ' + error.code + ':' + error.message, true) 18 | } 19 | return AVPlayerUtil.avPlayer 20 | } 21 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/models/AssStyle.ts: -------------------------------------------------------------------------------- 1 | import { Alignment } from './Alignment'; 2 | 3 | export class AssStyle { 4 | name: string; 5 | fontName: string; 6 | fontSize: number; 7 | primaryColor?: string; 8 | secondaryColor?: string; 9 | outlineColor?: string; 10 | backColor?: string; 11 | bold: boolean; 12 | italic: boolean; 13 | underline: boolean; 14 | strikeout: boolean; 15 | scaleX: number; 16 | scaleY: number; 17 | spacing: number; 18 | angle: number; 19 | borderStyle: number; 20 | outline: number; 21 | shadow: number; 22 | alignment: Alignment; 23 | marginLeft: number; 24 | marginRight: number; 25 | marginTop: number; 26 | marginBottom: number; 27 | encoding: number; 28 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/FileFolderComponent/FileFolderSelectBuilder.ets: -------------------------------------------------------------------------------- 1 | import { MenuFancyModifier } from '../../common/AttributeModifierConfig'; 2 | import { IMenuItem } from '../../interfaces/IMenuItemInterface'; 3 | import ToolsUtil from '../../utils/ToolsUtil'; 4 | import { SymbolGlyphModifier } from '@kit.ArkUI'; 5 | 6 | @Builder 7 | export function FolderSelectMenuBuilder(menus: IMenuItem[]) { 8 | Menu() { 9 | ForEach(menus, (item: IMenuItem, _index: number) => { 10 | MenuItem({ 11 | symbolStartIcon: new SymbolGlyphModifier(item.icon), 12 | content: item.content 13 | }).onClick(item.onAction) 14 | }) 15 | }.attributeModifier(new MenuFancyModifier()).onAppear(() => { 16 | ToolsUtil.startVibration() 17 | }) 18 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/ParseVtt.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import * as lib from 'libsubtitles.so'; 3 | import { VttLine } from './models/VttLine'; 4 | import { VttStyle } from './models/VttStyle'; 5 | import { VttRegion } from './models/VttRegion'; 6 | 7 | export class ParseVtt { 8 | private readonly instance; 9 | 10 | constructor(path: string) { 11 | this.instance = new lib.ParseVtt(path); 12 | } 13 | 14 | init(): Promise { 15 | return this.instance.init(); 16 | } 17 | 18 | readLines(): Promise { 19 | return this.instance.readLines(); 20 | } 21 | 22 | readRegions(): Promise> { 23 | return this.instance.readRegions(); 24 | } 25 | 26 | readStyles(): Promise> { 27 | return this.instance.readStyles(); 28 | } 29 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/module.json: -------------------------------------------------------------------------------- 1 | { 2 | "app": { 3 | "bundleName": "com.michael.rtf_parser", 4 | "debug": false, 5 | "versionCode": 1000000, 6 | "versionName": "1.0.0", 7 | "minAPIVersion": 12, 8 | "targetAPIVersion": 12, 9 | "apiReleaseType": "Release", 10 | "compileSdkVersion": "5.0.0.71", 11 | "compileSdkType": "OpenHarmony", 12 | "appEnvironments": [], 13 | "bundleType": "app", 14 | "buildMode": "release" 15 | }, 16 | "module": { 17 | "name": "subtitles", 18 | "type": "har", 19 | "deviceTypes": [ 20 | "default", 21 | "tablet" 22 | ], 23 | "packageName": "subtitles", 24 | "installationFree": false, 25 | "virtualMachine": "ark12.0.2.0", 26 | "compileMode": "esmodule", 27 | "dependencies": [] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /entry/src/main/ets/component/SideBarComponent/MaskComponent.ets: -------------------------------------------------------------------------------- 1 | import { curves } from '@kit.ArkUI'; 2 | import { SideBarController } from './SideBar'; 3 | 4 | class SideBarValue { 5 | sideBarController: SideBarController | undefined = undefined 6 | } 7 | 8 | @Builder 9 | export function MaskComponent(sideBarValue: SideBarValue) { 10 | Column() 11 | .height('100%') 12 | .width('100%') 13 | .backgroundBlurStyle(BlurStyle.BACKGROUND_THIN) 14 | .transition(TransitionEffect.OPACITY.animation({ 15 | curve: curves.springMotion(0, 1) 16 | })) 17 | .scale({ 18 | x: sideBarValue.sideBarController?.isShow === true ? 1.05 : 1, 19 | y: sideBarValue.sideBarController?.isShow ? 1.05 : 1 20 | }) 21 | .zIndex(1) 22 | .onClick(() => { 23 | sideBarValue.sideBarController?.close(true); 24 | }) 25 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/src/main/ets/ParseAss.ts: -------------------------------------------------------------------------------- 1 | //@ts-ignore 2 | import * as lib from 'libsubtitles.so'; 3 | import { AssEvent } from './models/AssEvent'; 4 | import { AssStyle } from './models/AssStyle'; 5 | 6 | export class ParseAss { 7 | private readonly instance; 8 | 9 | constructor(path: string) { 10 | this.instance = new lib.ParseAss(path); 11 | } 12 | 13 | init(): Promise { 14 | return this.instance.init(); 15 | } 16 | 17 | async readEvents(): Promise> { 18 | return this.instance.readEvents(); 19 | } 20 | 21 | readFonts(): Promise> { 22 | return this.instance.readFonts(); 23 | } 24 | 25 | readGraphics(): Promise> { 26 | return this.instance.readGraphics(); 27 | } 28 | 29 | readStyles(): Promise> { 30 | return this.instance.readStyles(); 31 | } 32 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/element/color.json: -------------------------------------------------------------------------------- 1 | { 2 | "color": [ 3 | { 4 | "name": "start_window_background", 5 | "value": "#f0f0f0" 6 | }, 7 | { 8 | "name": "start_window_background_blur", 9 | "value": "#ffffff" 10 | }, 11 | { 12 | "name": "search_component_blur", 13 | "value": "#b2e7e9ec" 14 | }, 15 | { 16 | "name": "shadow_color", 17 | "value": "#33000000" 18 | }, 19 | { 20 | "name": "sidebar_background_color", 21 | "value": "#f0f0f0" 22 | }, 23 | { 24 | "name": "text_color", 25 | "value": "#202020" 26 | }, 27 | { 28 | "name": "list_item_background", 29 | "value": "#FFFFFF" 30 | }, 31 | { 32 | "name": "custom_dialog_background_blur", 33 | "value": "#30ffffff" 34 | }, 35 | { 36 | "name": "main_color", 37 | "value": "#15559a" 38 | }, 39 | { 40 | "name": "sweet_music_color", 41 | "value": "#103fb6" 42 | } 43 | ] 44 | } -------------------------------------------------------------------------------- /entry/src/main/resources/dark/element/color.json: -------------------------------------------------------------------------------- 1 | { 2 | "color": [ 3 | { 4 | "name": "start_window_background", 5 | "value": "#202020" 6 | }, 7 | { 8 | "name": "start_window_background_blur", 9 | "value": "#303030" 10 | }, 11 | { 12 | "name": "search_component_blur", 13 | "value": "#b2292929" 14 | }, 15 | { 16 | "name": "shadow_color", 17 | "value": "#101010" 18 | }, 19 | { 20 | "name": "sidebar_background_color", 21 | "value": "#505050" 22 | }, 23 | { 24 | "name": "text_color", 25 | "value": "#f0f0f0" 26 | }, 27 | { 28 | "name": "list_item_background", 29 | "value": "#303030" 30 | }, 31 | { 32 | "name": "custom_dialog_background_blur", 33 | "value": "#30000000" 34 | }, 35 | { 36 | "name": "main_color", 37 | "value": "#15559a" 38 | }, 39 | { 40 | "name": "sweet_music_color", 41 | "value": "#103fb6" 42 | } 43 | ] 44 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/LockVideoBarComponent.ets: -------------------------------------------------------------------------------- 1 | import { ButtonFancyModifier, SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | 3 | @Component 4 | export struct LockVideoBarComponent { 5 | @Link isLock: boolean 6 | @Link screenHeight: number 7 | @Link screenWidth: number 8 | @Link showControl: boolean 9 | 10 | build() { 11 | Button({ type: ButtonType.Circle, stateEffect: true }) { //锁定播放栏 12 | SymbolGlyph(this.isLock ? $r('sys.symbol.lock_fill') : $r('sys.symbol.lock_open_fill')) 13 | .fontWeight(FontWeight.Bold) 14 | .attributeModifier(new SymbolGlyphFancyModifier(23, '', '')) 15 | .fontColor(['#f0f0f0']) 16 | } 17 | .zIndex(100) 18 | .attributeModifier(new ButtonFancyModifier(40, 40)) 19 | .backgroundColor('#50000000') 20 | .aspectRatio(1) 21 | .onClick(() => { 22 | this.isLock = !this.isLock 23 | this.showControl = true 24 | }) 25 | .position({ bottom: this.screenHeight / 2, left: this.screenHeight > this.screenWidth ? 30 : 50 }) 26 | } 27 | } -------------------------------------------------------------------------------- /entry/obfuscation-rules.txt: -------------------------------------------------------------------------------- 1 | # Define project specific obfuscation rules here. 2 | # You can include the obfuscation configuration files in the current module's build-profile.json5. 3 | # 4 | # For more details, see 5 | # https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/source-obfuscation-V5 6 | 7 | # Obfuscation options: 8 | # -disable-obfuscation: disable all obfuscations 9 | # -enable-property-obfuscation: obfuscate the property names 10 | # -enable-toplevel-obfuscation: obfuscate the names in the global scope 11 | # -compact: remove unnecessary blank spaces and all line feeds 12 | # -remove-log: remove all console.* statements 13 | # -print-namecache: print the name cache that contains the mapping from the old names to new names 14 | # -apply-namecache: reuse the given cache file 15 | 16 | # Keep options: 17 | # -keep-property-name: specifies property names that you want to keep 18 | # -keep-global-name: specifies names that you want to keep in the global scope 19 | 20 | -enable-property-obfuscation 21 | -enable-toplevel-obfuscation 22 | -enable-filename-obfuscation 23 | -enable-export-obfuscation -------------------------------------------------------------------------------- /entry/src/main/ets/utils/PathUtils.ets: -------------------------------------------------------------------------------- 1 | import { common } from '@kit.AbilityKit'; 2 | 3 | export class PathUtils { 4 | private static context: common.UIAbilityContext | null = null; 5 | 6 | // 获取沙箱根路径 7 | static get sandboxPath(): string { 8 | return this.context?.filesDir ?? ''; 9 | } 10 | 11 | static get coverPath(): string { 12 | return `${this.sandboxPath}/` 13 | } 14 | 15 | // 获取视频目录路径 16 | static get videoPath(): string { 17 | return `${this.sandboxPath}/video/`; 18 | } 19 | 20 | // 获取图片目录路径 21 | static get photoPath(): string { 22 | return `${this.sandboxPath}/photo/` 23 | } 24 | 25 | // 获取字幕目录路径 26 | static get subtitlePath(): string { 27 | return `${this.sandboxPath}/subtitle/`; 28 | } 29 | 30 | // 获取临时目录路径 31 | static get tempPath(): string { 32 | return this.context?.tempDir ?? ''; 33 | } 34 | 35 | static get appContext(): common.UIAbilityContext | null { 36 | return PathUtils.context; 37 | } 38 | 39 | // 初始化上下文(在Ability的onCreate中调用) 40 | static init(context: common.UIAbilityContext) { 41 | PathUtils.context = context; 42 | } 43 | } -------------------------------------------------------------------------------- /entry/src/main/ets/modules/subtitles/README.md: -------------------------------------------------------------------------------- 1 | # subtitles 2 | 3 | ## 简介 4 | 5 | [subtitles],是一个 OpenHarmony/HarmonyOS 字幕文件解析库,基于 rsubs-lib 开发,支持使用 API12 以上。 6 | 7 | ## 下载安装 8 | 9 | ```shell 10 | ohpm install @dove/subtitles 11 | ``` 12 | 13 | ## 接口和使用 14 | 15 | ### 转换ass字幕文件 16 | 17 | ```typescript 18 | export declare class ParseAss { 19 | constructor(path: string) 20 | init(): Promise //必须先执行初始化进行解析 21 | readEvents(): Promise> 22 | readFonts(): Promise> 23 | readGraphics(): Promise> 24 | readStyles(): Promise> 25 | } 26 | ``` 27 | 28 | ### 转换srt字幕文件 29 | 30 | ```typescript 31 | export declare class ParseSrt { 32 | constructor(path: string) 33 | init(): Promise //必须先执行初始化进行解析 34 | readLines(): Promise> 35 | } 36 | ``` 37 | 38 | ### 转换vtt字幕文件 39 | 40 | ```typescript 41 | export declare class ParseVtt { 42 | constructor(path: string) 43 | init(): Promise //必须先执行初始化进行解析 44 | readLines(): Promise> 45 | readRegions(): Promise> 46 | readStyles(): Promise> 47 | } 48 | ``` -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/SubtitleDisplayBuilder.ets: -------------------------------------------------------------------------------- 1 | import { Setting } from '../../utils/ObservedUtil' 2 | 3 | class SubtitleDisplayValue { 4 | subtitle: string = '' 5 | screenWidth: number = 0 6 | screenHeight: number = 0 7 | showControl: boolean = false 8 | subTitleVisibility: Visibility = Visibility.None 9 | playAreaWidth: number = 0 10 | } 11 | 12 | @Builder 13 | export function SubtitleDisplayBuilder(subtitleDisplayValue: SubtitleDisplayValue) { 14 | Stack({ alignContent: Alignment.BottomEnd }) { //字幕 15 | Text(subtitleDisplayValue.subtitle) 16 | .fontColor('#F0F0F0') 17 | .fontSize(Setting.subtitleSize) 18 | .fontWeight(FontWeight.Medium) 19 | .textAlign(TextAlign.Center) 20 | .fontWeight(FontWeight.Normal)// 常规字重更清晰 21 | .textShadow({ color: Color.Black, type: ShadowType.BLUR, radius: 6 }) 22 | .opacity(0.95) // 轻微透明度 23 | } 24 | .visibility(subtitleDisplayValue.subTitleVisibility) 25 | .align(Alignment.Center) 26 | .width(subtitleDisplayValue.playAreaWidth) 27 | .offset({ x: 0, y: subtitleDisplayValue.screenHeight / 2 - 40 }) 28 | .animation({ duration: 300, curve: Curve.Smooth }) 29 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/SortComponent.ets: -------------------------------------------------------------------------------- 1 | import { MenuFancyModifier } from '../../common/AttributeModifierConfig' 2 | import { SymbolGlyphModifier } from '@kit.ArkUI' 3 | 4 | @Component 5 | export struct SortComponent { 6 | onSortByName = () => { 7 | } 8 | onSortByTime = () => { 9 | } 10 | onSortByDatetime = () => { 11 | } 12 | 13 | build() { 14 | Menu() { 15 | MenuItem({ 16 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.textformat')), 17 | content: $r('app.string.sort_by_name') 18 | }) 19 | .onClick(() => { 20 | this.onSortByName() 21 | }) 22 | MenuItem({ 23 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.calendar')), 24 | content: $r('app.string.sort_by_datetime') 25 | }) 26 | .onClick(() => { 27 | this.onSortByDatetime() 28 | }) 29 | MenuItem({ 30 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 31 | content: $r('app.string.sort_by_time') 32 | }) 33 | .onClick(() => { 34 | this.onSortByTime() 35 | }) 36 | }.attributeModifier(new MenuFancyModifier()) 37 | } 38 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/ImportProgressBuilder.ets: -------------------------------------------------------------------------------- 1 | export class ImportProgressBuilderParams { 2 | processedItems: number = 0 3 | totalItems: number = 0 4 | } 5 | 6 | @Builder 7 | export function ImportProgressBuilder(params: ImportProgressBuilderParams) { 8 | Stack() { 9 | Column() { 10 | Progress({ value: params.processedItems, total: params.totalItems, type: ProgressType.Ring }) 11 | .width(100) 12 | .height(100) 13 | .color(new LinearGradient([{ color: $r('app.color.sweet_music_color'), offset: 0.5 }, 14 | { color: $r('app.color.main_color'), offset: 1.0 }])) 15 | .style({ strokeWidth: 15, enableSmoothEffect: true, shadow: true }) 16 | .margin({ bottom: 8 }) // 添加底部间距分隔文字 17 | Text(params.processedItems + '/' + params.totalItems) 18 | .fontSize(16) 19 | .fontColor($r('app.color.main_color')) 20 | } 21 | .animation({ duration: 150, curve: Curve.Sharp }) 22 | .padding(30) 23 | .borderRadius(16) 24 | .shadow({ radius: 26, color: $r('app.color.shadow_color') }) 25 | .backgroundColor($r('app.color.start_window_background_blur')) 26 | .backdropBlur(150) 27 | }.width('100%') 28 | .height('100%') 29 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/SearchComponent.ets: -------------------------------------------------------------------------------- 1 | import { SearchShadowModifier } from '../../common/AttributeModifierConfig'; 2 | 3 | @Component 4 | export struct SearchComponent { 5 | @Link searchValue: string; 6 | controller: SearchController | undefined = undefined; 7 | onChange = () => { 8 | } 9 | onSubmit = (_value: string) => { 10 | } 11 | onEditChange = ((_isEditing: boolean) => { 12 | }) 13 | onSearchFocus = () => { 14 | } 15 | 16 | build() { 17 | Search({ 18 | controller: this.controller, 19 | placeholder: $r('app.string.search_placeholder'), 20 | value: $$this.searchValue 21 | }) 22 | .layoutWeight(1) 23 | .height(40) 24 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 25 | .animation({ duration: 300, curve: Curve.Ease }) 26 | .attributeModifier(new SearchShadowModifier()) 27 | .keyboardAppearance(KeyboardAppearance.IMMERSIVE)// 设置搜索框沉浸模式 28 | .onChange(() => { 29 | this.onChange() 30 | }) 31 | .onSubmit((value: string) => { 32 | this.onSubmit(value) 33 | }) 34 | .onEditChange((isEditing: boolean) => { 35 | this.onEditChange(isEditing) 36 | }) 37 | .onFocus(() => { 38 | this.onSearchFocus() 39 | }) 40 | } 41 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/BrightnessSwipingBuilder.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | 3 | class BrightnessValue { 4 | screenBrightness: number = 100 5 | } 6 | 7 | @Builder 8 | export function BrightnessSwipingBuilder(brightnessValue: BrightnessValue) { 9 | Stack() { //亮度提示 10 | Column() { 11 | SymbolGlyph($r('sys.symbol.sun_max')) 12 | .attributeModifier(new SymbolGlyphFancyModifier(23, '', '')) 13 | .fontColor(['#f0f0f0']) 14 | .rotate({ angle: brightnessValue.screenBrightness * 360 }) 15 | .animation({ duration: 300, curve: Curve.Smooth }) 16 | Slider({ 17 | value: brightnessValue.screenBrightness, 18 | min: 0, 19 | max: 1, 20 | step: 0.1, 21 | style: SliderStyle.NONE, 22 | direction: Axis.Horizontal, 23 | reverse: false 24 | }) 25 | .width(60) 26 | .height(30) 27 | .selectedColor($r('app.color.main_color')) 28 | .trackColor(Color.Black) 29 | .trackThickness(40) 30 | } 31 | } 32 | .padding({ 33 | left: 25, 34 | right: 25, 35 | top: 10, 36 | bottom: 10 37 | }) 38 | .borderRadius(1000) 39 | .backgroundColor('#30000000') 40 | .backdropBlur(100) 41 | .animation({ duration: 300, curve: Curve.Smooth }) 42 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/PlaybackSpeedComponent.ets: -------------------------------------------------------------------------------- 1 | import { SpeedDataSource } from '../../utils/DataUtil'; 2 | import VideoInfoUtil from '../../utils/VideoInfoUtil'; 3 | 4 | @Component 5 | export struct PlaybackSpeedMenuComponent { 6 | @State speedDataSource: SpeedDataSource = new SpeedDataSource([]) 7 | @Prop speed: number 8 | @Prop player: string 9 | onSpeedChange = (_speed: number) => { 10 | } 11 | 12 | aboutToAppear(): void { 13 | this.speedDataSource = new SpeedDataSource(VideoInfoUtil.getVideoSpeedList(this.player)) 14 | } 15 | 16 | build() { 17 | List() { 18 | LazyForEach(this.speedDataSource, (speedAvailable: number) => { 19 | ListItem() { 20 | Text(`${speedAvailable}x`).fontColor(this.speed && speedAvailable === this.speed ? 21 | $r('sys.color.white') : $r('app.color.text_color')) 22 | } 23 | .onClick(() => { 24 | this.onSpeedChange(speedAvailable); // 触发回调 25 | }) 26 | .height(40) 27 | .backgroundColor(this.speed && speedAvailable === this.speed ? 28 | $r('app.color.main_color') : '') 29 | .borderRadius(16) 30 | .width('100%') 31 | }, (speedAvailable: number) => speedAvailable.toString()) 32 | }.cachedCount(2) 33 | .width(100).height('auto').scrollBar(BarState.Off) 34 | } 35 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/NavigationCommon.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../interfaces/FileFolderInterface' 2 | import { VideoMetadata } from '../interfaces/VideoMetadataInterface' 3 | 4 | export default class NavigationAddress { 5 | static AV_PLAYER = 'player' 6 | static FFMPEG_PLAYER = 'FFMpegPlayer' 7 | static RED_PLAYER = 'RedPlayer' 8 | static SETTING_PAGE = 'SettingsPage' 9 | static ABOUT_PAGE = 'AboutPage' 10 | static RECENT_PLAY = 'RecentPlay' 11 | static PRIVACY_SPACE = 'PrivacySpace' 12 | static CRASH_PAGE = 'CrashPage' 13 | static PERSONALIZE_PAGE = 'PersonalizePage' 14 | static PLAYER_SETTING_PAGE = 'PlayerSettingPage' 15 | static PRIVACY_ERROR_LIST = 'PrivacySpaceErrorList' 16 | static MORE_SETTING_PAGE = 'MoreSettingPage' 17 | } 18 | 19 | export class PrivacySpaceParams { 20 | public needGotoSetting: boolean = false 21 | } 22 | 23 | // Navigation 传参设置 24 | export class PlayerParams { 25 | public metadata: VideoMetadata | undefined 26 | public metadata_list: VideoMetadata [] | undefined 27 | public file_folder: FileFolder | undefined 28 | 29 | constructor(metadata: VideoMetadata | undefined, metadata_list: VideoMetadata [] | undefined, 30 | file_folder: FileFolder | undefined) { 31 | this.metadata = metadata 32 | this.metadata_list = metadata_list 33 | this.file_folder = file_folder 34 | } 35 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/AIAsrComponent.ets: -------------------------------------------------------------------------------- 1 | import { AICaptionComponent, AICaptionController, AICaptionOptions } from '@kit.SpeechKit'; 2 | import { BusinessError, deviceInfo } from '@kit.BasicServicesKit'; 3 | 4 | @Component 5 | export struct AIAsr { 6 | @Link isShown: boolean 7 | private captionOption?: AICaptionOptions; 8 | private controller: AICaptionController = new AICaptionController(); 9 | 10 | aboutToAppear(): void { 11 | if (!this.captionOption) { 12 | this.captionOption = { 13 | initialOpacity: 0.5, 14 | onPrepared: () => { 15 | console.error('test AI字幕组件准备就绪') 16 | }, 17 | onError: (error: BusinessError) => { 18 | console.error(`test AI字幕组件错误。错误码: ${error.code}, 消息: ${error.message}`) 19 | } 20 | } 21 | } 22 | } 23 | 24 | build() { 25 | if (deviceInfo.sdkApiVersion >= 18) { 26 | if (canIUse('SystemCapability.AI.AICaption')) { 27 | Column() { 28 | AICaptionComponent({ 29 | isShown: this.isShown, 30 | controller: this.controller, 31 | options: this.captionOption 32 | }) 33 | .width('100%') 34 | .height(80) 35 | } 36 | .width('100%') 37 | .height('100%') 38 | .justifyContent(FlexAlign.End) 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /entry/src/main/ets/utils/VideoUtil.ets: -------------------------------------------------------------------------------- 1 | import { VideoListController } from '../component/VideoItemComponent/VideoItemComponent'; 2 | import Preferences from '../database/Preferences'; 3 | import { FileFolder } from '../interfaces/FileFolderInterface'; 4 | import { FileFolderDataSource } from './DataUtil'; 5 | import { PathUtils } from './PathUtils'; 6 | import ToolsUtil from './ToolsUtil'; 7 | 8 | export default class VideoUtils { 9 | static refresh(videoListController: VideoListController, fileFolderDataSource: FileFolderDataSource, 10 | folder?: FileFolder) { 11 | //获取最新的folders 12 | const folders: FileFolder[] = Preferences.getFileFolder(PathUtils.appContext!) 13 | //更新FileFolderDataSource 14 | fileFolderDataSource.updateData([...folders]) 15 | let currentFolder: FileFolder | undefined 16 | //获取当前的folder 17 | if (folder) { 18 | currentFolder = folders.find(item => item.name === folder.name) 19 | } else { 20 | currentFolder = folders[0] 21 | } 22 | if (!currentFolder) { 23 | ToolsUtil.showToast('出异常了:数据刷新失败') 24 | return; 25 | } 26 | if (videoListController?.folder?.video_list) { 27 | //更新videoDataSource 28 | videoListController.videoDataSource.updateData(currentFolder.video_list || []) 29 | //更新VideoListController 30 | videoListController.updateData(videoListController.videoDataSource, currentFolder!) 31 | } 32 | } 33 | } -------------------------------------------------------------------------------- /hvigor/hvigor-config.json5: -------------------------------------------------------------------------------- 1 | { 2 | "modelVersion": "5.0.1", 3 | "dependencies": { 4 | }, 5 | "properties": { 6 | "hvigor.incremental.optimization": true, 7 | "hvigor.task.schedule.optimization": true, 8 | "ohos.arkCompile.singleFileEmit": true, 9 | "ohos.arkCompile.noEmitJs": true 10 | }, 11 | "execution": { 12 | // "analyze": "normal", /* Define the build analyze mode. Value: [ "normal" | "advanced" | false ]. Default: "normal" */ 13 | // "daemon": true, /* Enable daemon compilation. Value: [ true | false ]. Default: true */ 14 | // "incremental": true, /* Enable incremental compilation. Value: [ true | false ]. Default: true */ 15 | // "parallel": true, /* Enable parallel compilation. Value: [ true | false ]. Default: true */ 16 | // "typeCheck": false, /* Enable typeCheck. Value: [ true | false ]. Default: false */ 17 | }, 18 | "logging": { 19 | // "level": "info" /* Define the log level. Value: [ "debug" | "info" | "warn" | "error" ]. Default: "info" */ 20 | }, 21 | "debugging": { 22 | // "stacktrace": false /* Disable stacktrace compilation. Value: [ true | false ]. Default: false */ 23 | }, 24 | "nodeOptions": { 25 | // "maxOldSpaceSize": 8192 /* Enable nodeOptions maxOldSpaceSize compilation. Unit M. Used for the daemon process. Default: 8192*/ 26 | // "exposeGC": true /* Enable to trigger garbage collection explicitly. Default: true*/ 27 | } 28 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/ObservedUtil.ets: -------------------------------------------------------------------------------- 1 | import { UIUtils } from '@kit.ArkUI'; 2 | import { BackgroundColorMode } from '../common/enum/BackgroundColorMode'; 3 | import { ListDisplayMode } from '../common/enum/ListDisplayMode'; 4 | 5 | class SettingUtil { 6 | public backgroundImageSrc: string = '' 7 | public backgroundDropBlur: number = 0 8 | public backgroundColorMode: BackgroundColorMode = BackgroundColorMode.AUTO 9 | public allowBackgroundPlay: boolean = true 10 | public allowPlayBackExist: boolean = false 11 | public fastForwardSeconds: string = '15' 12 | public recentPlay: boolean = true 13 | public defaultPlayer: string = 'FFMpeg播放器' 14 | public subtitleSize: string = '22' 15 | public allowDoubleFastForward: boolean = true 16 | public skipIntroTime: string = '0' 17 | public listDisplayMode: ListDisplayMode = ListDisplayMode.LIST_DISPLAY 18 | public smartRotation: boolean = true 19 | public longPressSpeed: number = 3 20 | public showSettingPageEntry: boolean = true 21 | public allowGestureEntry: boolean = true 22 | } 23 | 24 | class SafeHeightUtil { 25 | public topSafeHeight: number = 0 26 | public bottomSafeHeight: number = 0 27 | } 28 | 29 | class PrivacyBackgroundUtil { 30 | public isPrivacyBackground: boolean = false 31 | } 32 | 33 | class PcUtil { 34 | public pcMode: boolean = false 35 | } 36 | 37 | export const SafeHeight = UIUtils.makeObserved(new SafeHeightUtil()); 38 | 39 | export const Setting = UIUtils.makeObserved(new SettingUtil()); 40 | 41 | export const Privacy = UIUtils.makeObserved(new PrivacyBackgroundUtil()); 42 | 43 | export const Pc = UIUtils.makeObserved(new PcUtil()); -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/SwipingPlayTimePanelBuilder.ets: -------------------------------------------------------------------------------- 1 | import { ImageFancyModifier } from '../../common/AttributeModifierConfig' 2 | import TimeUtil from '../../utils/TimeUtil' 3 | import VideoOperateUtil from '../../utils/VideoOperateUtil' 4 | 5 | class SwipingPlayTimeValue { 6 | isSliderPlayTimeChange: boolean = false 7 | playTime: number = 0 8 | totalTime: number = 0 9 | pixelMap: PixelMap | null = null 10 | } 11 | 12 | @Builder 13 | export function SwipingPlayTimePanelBuilder(swipingPlayTimeValue: SwipingPlayTimeValue) { 14 | Column() { // 滑动控制栏 15 | Text(getFormattedTimeText(swipingPlayTimeValue)) 16 | .fontColor($r('sys.color.white')) 17 | .fontSize(25) 18 | .fontWeight(FontWeight.Bold) 19 | Image(swipingPlayTimeValue.pixelMap) 20 | .attributeModifier(new ImageFancyModifier(16, 100, 100)) 21 | 22 | } 23 | .padding({ 24 | left: 25, 25 | right: 25, 26 | top: 10, 27 | bottom: 10 28 | }) 29 | .borderRadius(16) 30 | .backgroundColor('#30000000') 31 | .backdropBlur(100) 32 | .animation({ duration: 300, curve: Curve.Smooth }) 33 | } 34 | 35 | export function getFormattedTimeText(swipingPlayTimeValue: SwipingPlayTimeValue): string { 36 | const currentTime = TimeUtil.convertMSToHHMMSS(swipingPlayTimeValue.playTime); 37 | const totalTime = TimeUtil.convertMSToHHMMSS(swipingPlayTimeValue.totalTime); 38 | if (swipingPlayTimeValue.isSliderPlayTimeChange) { 39 | return `${currentTime}/${totalTime}`; 40 | } else { 41 | const timeDiff = Math.floor((swipingPlayTimeValue.playTime - VideoOperateUtil.lastPlayTime) / 1000); 42 | const sign = timeDiff > 0 ? '+' : ''; 43 | return `${sign}${timeDiff}s : ${currentTime}/${totalTime}`; 44 | } 45 | } -------------------------------------------------------------------------------- /entry/src/test/LocalUnit.test.ets: -------------------------------------------------------------------------------- 1 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from '@ohos/hypium'; 2 | 3 | export default function localUnitTest() { 4 | describe('localUnitTest', () => { 5 | // Defines a test suite. Two parameters are supported: test suite name and test suite function. 6 | beforeAll(() => { 7 | // Presets an action, which is performed only once before all test cases of the test suite start. 8 | // This API supports only one parameter: preset action function. 9 | }); 10 | beforeEach(() => { 11 | // Presets an action, which is performed before each unit test case starts. 12 | // The number of execution times is the same as the number of test cases defined by **it**. 13 | // This API supports only one parameter: preset action function. 14 | }); 15 | afterEach(() => { 16 | // Presets a clear action, which is performed after each unit test case ends. 17 | // The number of execution times is the same as the number of test cases defined by **it**. 18 | // This API supports only one parameter: clear action function. 19 | }); 20 | afterAll(() => { 21 | // Presets a clear action, which is performed after all test cases of the test suite end. 22 | // This API supports only one parameter: clear action function. 23 | }); 24 | it('assertContain', 0, () => { 25 | // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. 26 | let a = 'abc'; 27 | let b = 'b'; 28 | // Defines a variety of assertion methods, which are used to declare expected boolean conditions. 29 | expect(a).assertContain(b); 30 | expect(a).assertEqual(a); 31 | }); 32 | }); 33 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/SettingComponent/SettingsToggleItem.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | 3 | @Component 4 | export struct SettingsToggleItem { 5 | @State symbol: Resource | undefined = undefined 6 | @State symbol1: Resource | undefined = undefined 7 | @State message: ResourceStr = '' 8 | @State enable: boolean = false 9 | onChange = (_checked: boolean) => { 10 | } 11 | 12 | build() { 13 | Row() { 14 | Flex({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) { 15 | Row({ space: 10 }) { 16 | SymbolGlyph(this.enable && this.symbol1 ? this.symbol1 : this.symbol) 17 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 18 | .symbolEffect(new ReplaceSymbolEffect(EffectScope.WHOLE), Number(this.enable)) 19 | Text(this.message) 20 | .fontSize(17) 21 | .fontWeight(FontWeight.Medium) 22 | .maxLines(1) 23 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 24 | .layoutWeight(1) 25 | }.layoutWeight(1) 26 | 27 | Toggle({ type: ToggleType.Switch, isOn: this.enable }) 28 | .onChange((checked: boolean) => { 29 | this.enable = checked 30 | this.onChange(checked) 31 | }) 32 | .selectedColor($r('app.color.main_color')) 33 | .width(45) 34 | .height(27); 35 | }.align(Alignment.Center) 36 | 37 | } 38 | .backgroundColor($r('app.color.start_window_background_blur')) 39 | .height(55) 40 | .borderRadius(16) 41 | .padding({ 42 | right: 15, 43 | left: 15, 44 | top: 10, 45 | bottom: 10 46 | }) 47 | .margin({ top: 5, bottom: 5 }) 48 | } 49 | } -------------------------------------------------------------------------------- /entry/src/ohosTest/ets/test/Ability.test.ets: -------------------------------------------------------------------------------- 1 | import { hilog } from '@kit.PerformanceAnalysisKit'; 2 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from '@ohos/hypium'; 3 | 4 | export default function abilityTest() { 5 | describe('ActsAbilityTest', () => { 6 | // Defines a test suite. Two parameters are supported: test suite name and test suite function. 7 | beforeAll(() => { 8 | // Presets an action, which is performed only once before all test cases of the test suite start. 9 | // This API supports only one parameter: preset action function. 10 | }) 11 | beforeEach(() => { 12 | // Presets an action, which is performed before each unit test case starts. 13 | // The number of execution times is the same as the number of test cases defined by **it**. 14 | // This API supports only one parameter: preset action function. 15 | }) 16 | afterEach(() => { 17 | // Presets a clear action, which is performed after each unit test case ends. 18 | // The number of execution times is the same as the number of test cases defined by **it**. 19 | // This API supports only one parameter: clear action function. 20 | }) 21 | afterAll(() => { 22 | // Presets a clear action, which is performed after all test cases of the test suite end. 23 | // This API supports only one parameter: clear action function. 24 | }) 25 | it('assertContain', 0, () => { 26 | // Defines a test case. This API supports three parameters: test case name, filter parameter, and test case function. 27 | hilog.info(0x0000, 'testTag', '%{public}s', 'it begin'); 28 | let a = 'abc'; 29 | let b = 'b'; 30 | // Defines a variety of assertion methods, which are used to declare expected boolean conditions. 31 | expect(a).assertContain(b); 32 | expect(a).assertEqual(a); 33 | }) 34 | }) 35 | } -------------------------------------------------------------------------------- /entry/src/main/ets/database/PreferencesUtil.ets: -------------------------------------------------------------------------------- 1 | import preferences from '@ohos.data.preferences' 2 | import ToolsUtil from '../utils/ToolsUtil' 3 | 4 | // 存储类 5 | export default class PreferencesUtil { 6 | static putPreferencesValue(context: Context, name: string, key: string, value: preferences.ValueType) { 7 | try { 8 | let pref = preferences.getPreferencesSync(context, { 9 | name: name 10 | }) 11 | pref.putSync(key, value) 12 | pref.flush()?.catch(() => { 13 | ToolsUtil.addLogger('flush error', true) 14 | }) 15 | } catch (e) { 16 | ToolsUtil.showToast(`数据库异常,保存失败,原因是:${e},ValueType: ${value},获取参数为 ${name}}: ${key}`) 17 | } 18 | } 19 | 20 | static getPreferencesValue(context: Context, name: string, key: string, defaultValue: preferences.ValueType) { 21 | try { 22 | let pref = preferences.getPreferencesSync(context, { 23 | name: name 24 | }) 25 | return pref.getSync(key, defaultValue) 26 | } catch (e) { 27 | ToolsUtil.showToast(`数据库异常,读取失败,原因是: ${e},初始化${defaultValue}时失败,获取参数为 ${name}}: ${key}`) 28 | return defaultValue 29 | } 30 | } 31 | 32 | static delPreferencesValue(context: Context, name: string, key: string) { 33 | try { 34 | let pref = preferences.getPreferencesSync(context, { 35 | name: name 36 | }) 37 | const value = pref.getSync(key, 'DEFAULT_VALUE'); 38 | if (value === 'DEFAULT_VALUE') { 39 | ToolsUtil.addLogger(`Key "${key}" not found, skip deletion`, false) 40 | return; // 键不存在时提前返回 41 | } 42 | pref.delete(key)?.catch(() => { 43 | ToolsUtil.addLogger(`delete error`, true) 44 | }) 45 | pref.flush()?.catch(() => { 46 | ToolsUtil.addLogger(`flush error`, true) 47 | }) 48 | } catch (e) { 49 | ToolsUtil.showToast(`数据库异常,删除失败,原因是 ${e} 删除${key}:${name}失败`) 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/VideoSliderComponent.ets: -------------------------------------------------------------------------------- 1 | import TimeUtil from '../../utils/TimeUtil' 2 | 3 | @Component 4 | export struct VideoSliderComponent { 5 | @Prop playTime: number 6 | @Prop totalTime: number 7 | @Link screenWidth: number 8 | @Link screenHeight: number 9 | @Prop playSpeed: number 10 | onSliderChange = (_value: number) => { 11 | } 12 | onSliderTouch = (_event: TouchEvent) => { 13 | } 14 | 15 | build() { 16 | Row({ space: 10 }) { 17 | Row() { // 播放时间进度 18 | Text(TimeUtil.convertMSToHHMMSS(Math.min(Math.max(this.playTime, 0), (this.totalTime || 0))) + '/' + 19 | TimeUtil.convertMSToHHMMSS(this.totalTime || 0)) 20 | .fontWeight(FontWeight.Medium) 21 | .fontSize(16) 22 | .fontColor($r('sys.color.white')) 23 | .textAlign(TextAlign.Start) 24 | }.backgroundColor('#50000000').padding(5).borderRadius(10).margin({ left: 5 }) 25 | 26 | Slider({ 27 | value: this.playTime, 28 | min: 0, 29 | max: this.totalTime, 30 | style: SliderStyle.InSet 31 | }) 32 | .onChange((value) => { 33 | this.onSliderChange(value) 34 | }) 35 | .onTouch((event) => { 36 | this.onSliderTouch(event) 37 | }) 38 | .layoutWeight(1) 39 | .animation({ duration: 100, curve: Curve.Smooth }) 40 | .selectedColor('#e0e0e0') 41 | if (this.screenWidth > this.screenHeight) { 42 | Row() { //剩余时间,剩余时间 = (总时长 - 已播放时间) / 倍速值 43 | Text(TimeUtil.convertMSToHHMMSS(Math.max(((this.totalTime || 0) - this.playTime) / (this.playSpeed || 1), 44 | 0))) 45 | .fontWeight(FontWeight.Medium) 46 | .fontSize(15) 47 | .fontColor($r('sys.color.white')) 48 | .textAlign(TextAlign.End) 49 | }.backgroundColor('#50000000').padding(5).borderRadius(10).margin({ right: 5 }) 50 | } 51 | } 52 | } 53 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/SettingComponent/SettingsClickItem.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | 3 | @Component 4 | export struct SettingsClickItem { 5 | @State symbol: Resource | undefined = undefined 6 | @State message: ResourceStr = '' 7 | @Prop showArrow: boolean = true 8 | onPress = () => { 9 | } 10 | onLongPress = () => { 11 | } 12 | 13 | build() { 14 | Button({ type: ButtonType.Normal, stateEffect: true }) { 15 | Flex({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) { 16 | Row({ space: 10 }) { 17 | if (this.symbol) { 18 | SymbolGlyph(this.symbol) 19 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 20 | .visibility(this.symbol ? Visibility.Visible : Visibility.None) 21 | } else { 22 | Stack() 23 | } 24 | Text(this.message) 25 | .fontSize(17) 26 | .fontWeight(FontWeight.Medium) 27 | .maxLines(1) 28 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 29 | .layoutWeight(1) 30 | }.layoutWeight(1) 31 | 32 | if (this.showArrow) { 33 | SymbolGlyph($r('sys.symbol.chevron_right')) 34 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 35 | .margin(5) 36 | } 37 | 38 | }.align(Alignment.Center) 39 | 40 | } 41 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.95 }) 42 | .onClick(() => { 43 | this.onPress() 44 | }) 45 | .gesture(LongPressGesture().onAction(() => { 46 | this.onLongPress() 47 | })) 48 | .backgroundColor($r('app.color.start_window_background_blur')) 49 | .height(55) 50 | .borderRadius(16) 51 | .padding({ 52 | right: 15, 53 | left: 15, 54 | top: 10, 55 | bottom: 10 56 | }) 57 | .margin({ top: 5, bottom: 5 }) 58 | } 59 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/SettingComponent/SettingsMenuItem.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | 3 | @Component 4 | export struct SettingsMenuItem { 5 | @State symbol: Resource | undefined = undefined 6 | @State message: ResourceStr = '' 7 | @Prop selected: number = 0 8 | @State list: ResourceStr[] = [] 9 | @State private select: SelectOption[] = [] 10 | onChange = (_index: number) => { 11 | } 12 | 13 | aboutToAppear(): void { 14 | for (let text of this.list) { 15 | this.select.push({ value: text }) 16 | } 17 | } 18 | 19 | build() { 20 | Flex({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) { 21 | Row({ space: 10 }) { 22 | SymbolGlyph(this.symbol) 23 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 24 | Text(this.message) 25 | .fontSize(17) 26 | .fontWeight(FontWeight.Medium) 27 | .maxLines(1) 28 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 29 | .layoutWeight(1) 30 | } 31 | .layoutWeight(1) 32 | 33 | Select(this.select) 34 | .selected(this.selected) 35 | .value(this.list[this.selected]) 36 | .selectedOptionFont({ weight: FontWeight.Medium }) 37 | .selectedOptionFontColor($r('app.color.text_color')) 38 | .selectedOptionBgColor($r('app.color.main_color')) 39 | .selectedOptionFontColor($r('sys.color.white')) 40 | .menuBackgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THIN) 41 | .onSelect((index: number) => { 42 | this.selected = index 43 | this.onChange(index) 44 | }) 45 | .divider(null) 46 | } 47 | .align(Alignment.Center) 48 | .backgroundColor($r('app.color.start_window_background_blur')) 49 | .height(65) 50 | .borderRadius(16) 51 | .padding({ 52 | right: 15, 53 | left: 15, 54 | top: 10, 55 | bottom: 10 56 | }) 57 | .margin({ top: 5, bottom: 5 }) 58 | } 59 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/InfoConfirmDialog.ets: -------------------------------------------------------------------------------- 1 | //信息确认弹窗 2 | @CustomDialog 3 | export struct InfoConfirmDialog { 4 | confirm?: () => void 5 | controller?: CustomDialogController 6 | @Prop textInfo: ResourceStr = '' 7 | 8 | build() { 9 | Column({ space: 25 }) { 10 | Text(this.textInfo) 11 | .fontSize(25) 12 | .fontColor($r('app.color.text_color')) 13 | .fontWeight(FontWeight.Bold) 14 | .margin({ top: 10 }) 15 | 16 | Flex({ justifyContent: FlexAlign.SpaceAround }) { 17 | Button({ type: ButtonType.Normal, stateEffect: true }) { 18 | Row({ space: 5 }) { 19 | SymbolGlyph($r('sys.symbol.xmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 20 | Text($r('app.string.cancel')) 21 | .fontSize(18) 22 | .fontColor($r('app.color.text_color')) 23 | .fontWeight(FontWeight.Medium) 24 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 25 | } 26 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 27 | .backgroundColor($r('sys.color.button_background_color_transparent')) 28 | .borderRadius(16) 29 | .height(40) 30 | .onClick(() => this.controller?.close()) 31 | 32 | Button({ type: ButtonType.Normal, stateEffect: true }) { 33 | Row({ space: 5 }) { 34 | SymbolGlyph($r('sys.symbol.checkmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 35 | Text($r('app.string.sure')).fontSize(18).fontColor($r('app.color.text_color')).fontWeight(FontWeight.Medium) 36 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 37 | } 38 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 39 | .backgroundColor($r('sys.color.button_background_color_transparent')) 40 | .borderRadius(16) 41 | .height(40) 42 | .onClick(() => { 43 | this.controller?.close() 44 | if (this.confirm) { 45 | this.confirm() 46 | } 47 | }) 48 | }.margin({ bottom: 10 }) 49 | }.padding(20) 50 | } 51 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/VideoListItemComponent.ets: -------------------------------------------------------------------------------- 1 | import { PathUtils } from '../../utils/PathUtils'; 2 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface'; 3 | import { fileUri } from '@kit.CoreFileKit'; 4 | import { ImageFancyModifier, SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig'; 5 | import VideoInfoUtil from '../../utils/VideoInfoUtil'; 6 | import { VideoListController } from './VideoItemComponent'; 7 | import { VideoInfoBuilder } from './VideoInfoBuilder'; 8 | 9 | @Reusable 10 | @Component 11 | export default struct VideoListItem { 12 | @Prop item: VideoMetadata 13 | @Consume videoListController: VideoListController 14 | @State choose: boolean = false 15 | @Watch('checkChoose') @Link delMultipleList: VideoMetadata[] 16 | 17 | checkChoose() { 18 | this.choose = this.delMultipleList.some(item => item.date === this.item.date) 19 | } 20 | 21 | build() { 22 | Row() { 23 | Image(fileUri.getUriFromPath(PathUtils.coverPath + this.item.date)) 24 | .attributeModifier(new ImageFancyModifier(16, 80, 120)) 25 | Column() { 26 | Row() { 27 | Text(VideoInfoUtil.getVideoTitle(this.item)) 28 | .fontSize(16) 29 | .fontWeight(FontWeight.Bold) 30 | .maxLines(3) 31 | .textOverflow({ overflow: TextOverflow.Ellipsis }) 32 | .layoutWeight(1) 33 | .wordBreak(WordBreak.BREAK_ALL) 34 | if (this.videoListController.multipleChooseState === Visibility.Visible) { 35 | SymbolGlyph(this.choose ? $r('sys.symbol.checkmark_circle') : $r('sys.symbol.circle')) 36 | .attributeModifier(new SymbolGlyphFancyModifier(25, '', '')) 37 | .onAppear(() => { 38 | this.checkChoose() 39 | }) 40 | } 41 | }.padding({ left: 5 }) 42 | 43 | VideoInfoBuilder({ item: this.item, showProgress: true }) 44 | } 45 | .alignItems(HorizontalAlign.Start) 46 | .padding({ left: 15, right: 15 }) 47 | .layoutWeight(1) 48 | } 49 | .justifyContent(FlexAlign.Center) 50 | .width('100%').height('auto') 51 | } 52 | } 53 | 54 | -------------------------------------------------------------------------------- /entry/src/main/ets/component/SettingComponent/SettingsCheckboxItem.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | import { 3 | ItemRestriction, SegmentButton, SegmentButtonOptions, SegmentButtonTextItem 4 | } from '@kit.ArkUI' 5 | 6 | @Component 7 | export struct SettingsCheckboxItem { 8 | @State symbol: Resource | undefined = undefined 9 | @State message: ResourceStr = '' 10 | @Prop tips: ResourceStr[] 11 | @State tabOptions: SegmentButtonOptions = SegmentButtonOptions.tab({ 12 | buttons: [{ text: this.tips[0] }, { text: this.tips[1] }, { 13 | text: this.tips[2] 14 | }] as ItemRestriction, 15 | backgroundBlurStyle: BlurStyle.BACKGROUND_THICK, 16 | selectedBackgroundColor: $r('app.color.main_color'), 17 | selectedFontColor: $r('sys.color.white') 18 | }); 19 | @State @Watch('onSegmentButtonChange') tabSelectedIndexes: number[] = [1]; 20 | 21 | onSegmentButtonChange() { 22 | this.onButtonClick(Number(this.tabSelectedIndexes.toString())) 23 | } 24 | 25 | onButtonClick = (_tabSelectedIndexes: number) => { 26 | } 27 | 28 | build() { 29 | Row() { 30 | Column() { 31 | Row({ space: 10 }) { 32 | SymbolGlyph(this.symbol) 33 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 34 | .symbolEffect(new ReplaceSymbolEffect(EffectScope.WHOLE)) 35 | Text(this.message) 36 | .fontSize(17) 37 | .fontWeight(FontWeight.Medium) 38 | .maxLines(1) 39 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 40 | .layoutWeight(1) 41 | }.height('auto').width('100%').margin({ bottom: 10 }) 42 | 43 | SegmentButton({ 44 | options: this.tabOptions, 45 | selectedIndexes: $tabSelectedIndexes, 46 | }) 47 | }.height('100%').width('100%') 48 | } 49 | .align(Alignment.Center) 50 | .backgroundColor($r('app.color.start_window_background_blur')) 51 | .height(80) 52 | .borderRadius(16) 53 | .padding({ 54 | right: 15, 55 | left: 15, 56 | top: 10, 57 | bottom: 10 58 | }) 59 | .margin({ top: 5, bottom: 5 }) 60 | } 61 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/SideBarComponent/SideBar.ets: -------------------------------------------------------------------------------- 1 | import { ImageFancyModifier } from '../../common/AttributeModifierConfig'; 2 | import TimeUtil from '../../utils/TimeUtil'; 3 | import ToolsUtil from '../../utils/ToolsUtil'; 4 | 5 | 6 | export class AppInfoBuilderParams { 7 | topSafeHeight: number = 0; 8 | } 9 | 10 | @Builder 11 | export function AppInfoBuilder(params: AppInfoBuilderParams) { 12 | Stack() { 13 | Image($r("app.media.Background")) 14 | .attributeModifier(new ImageFancyModifier(16, '100%', 120)) 15 | .zIndex(1) 16 | Row() { 17 | Image($r("app.media.Foreground")) 18 | .width(50) 19 | .height(50) 20 | .interpolation(ImageInterpolation.Medium)// 用于重采样后的抗锯齿 21 | .draggable(false)// 禁止长按手势拖动 22 | .autoResize(true) // 重采样,可减少内存占用 23 | Text(TimeUtil.getTextByTime()) 24 | .fontSize(20) 25 | .fontColor('#ffff') 26 | .margin({ left: 5, right: 12 }) 27 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 28 | }.width('100%') 29 | .justifyContent(FlexAlign.Center) 30 | .zIndex(2) 31 | }.margin({ top: params.topSafeHeight }).clickEffect({ level: ClickEffectLevel.HEAVY, scale: 0.9 }).onClick(() => { 32 | ToolsUtil.startVibration() 33 | ToolsUtil.showToast('不管多么难熬,人生都要眉开眼笑 ( •̀ ω •́ )✧') 34 | }) 35 | } 36 | 37 | @Observed 38 | export class SideBarController { 39 | mode: SideBarContainerType = SideBarContainerType.Embed 40 | visibility: Visibility = Visibility.Hidden 41 | overlay: boolean = false 42 | isShow: boolean = false 43 | 44 | public open() { 45 | if (this.mode === SideBarContainerType.Overlay) { 46 | this.visibility = Visibility.Visible 47 | this.overlay = true 48 | this.isShow = true 49 | } 50 | if (this.visibility == Visibility.Hidden) { 51 | this.overlay = false 52 | } 53 | } 54 | 55 | public close(isPlayAnimation: boolean) { 56 | if (this.mode === SideBarContainerType.Overlay) { 57 | this.visibility = Visibility.Hidden 58 | isPlayAnimation ? setTimeout(() => { 59 | this.isShow = false 60 | this.overlay = false 61 | }, 200) : this.isShow = false 62 | } 63 | this.overlay = false 64 | } 65 | } -------------------------------------------------------------------------------- /entry/src/main/ets/database/VideoMetaData.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../interfaces/FileFolderInterface'; 2 | import { VideoMetadata } from '../interfaces/VideoMetadataInterface'; 3 | import { PathUtils } from '../utils/PathUtils'; 4 | import Preferences from './Preferences'; 5 | import PreferencesUtil from './PreferencesUtil'; 6 | import { VideoListController } from '../component/VideoItemComponent/VideoItemComponent'; 7 | import FileFolderUtil from '../utils/FileFolderUtil'; 8 | import { FileFolderDataSource } from '../utils/DataUtil'; 9 | import { DeleteType } from '../common/enum/DeleteType'; 10 | 11 | export class VideoMetaDataOperator { 12 | static getVideoMetaDataByFolderName(folderName: string): VideoMetadata[] { 13 | return Preferences.getFileFolder(PathUtils.appContext!).filter(item => item.name === folderName)[0].video_list 14 | } 15 | 16 | static migrationRootVideoMetaToRootFolder() { 17 | // 判断首页Folder存不存在, 迁移数据到FileFolder中 18 | const folder = Preferences.getFileFolder(PathUtils.appContext!).find(item => item.name === '首页') 19 | if (!folder) { 20 | const rootFolder: FileFolder = { 21 | name: '首页', 22 | date: '0', 23 | video_list: PreferencesUtil.getPreferencesValue(PathUtils.appContext!, 'sweet_video', 'recent_video_meta_data', 24 | []) as VideoMetadata[] 25 | } 26 | Preferences.saveFileFolder(PathUtils.appContext!, 27 | [rootFolder, ...Preferences.getFileFolder(PathUtils.appContext!)]) 28 | } 29 | } 30 | 31 | static async deleteItem(videoListController: VideoListController, fileFolderSource: FileFolderDataSource, 32 | deleteType: DeleteType, deleteItem: VideoMetadata) { 33 | await videoListController.videoDataSource.deleteItem(deleteType, deleteItem); 34 | FileFolderUtil.delVideoInFileFolder(PathUtils.appContext!, videoListController, fileFolderSource) 35 | } 36 | } 37 | 38 | export class FolderOperator { 39 | static getFolderByName(folderName: string): FileFolder { 40 | return Preferences.getFileFolder(PathUtils.appContext!).find(item => item.name === folderName)! 41 | } 42 | 43 | static saveFolders(folders: FileFolder[]) { 44 | Preferences.saveFileFolder(PathUtils.appContext!, folders) 45 | } 46 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/FastForwardPanelBuilder.ets: -------------------------------------------------------------------------------- 1 | import SelectFileUtil from '../../utils/SelectFileUtil' 2 | import VideoInfoUtil from '../../utils/VideoInfoUtil' 3 | 4 | class FastForwardInfo { 5 | speed: number = 1 6 | location: number = 0 7 | } 8 | 9 | @Builder 10 | export function FastForwardPanelBuilder(fastForwardInfo: FastForwardInfo) { 11 | Stack({ alignContent: Alignment.BottomEnd }) { 12 | Column() { 13 | Text(VideoInfoUtil.optimizedFormat(fastForwardInfo.speed) + ' ⏩') 14 | .fontColor($r('app.color.main_color')).fontSize(15) 15 | .fontWeight(FontWeight.Bold) 16 | Row() { 17 | if (SelectFileUtil.getPrevAndNextItem(VideoInfoUtil.optimizedFormat(fastForwardInfo.speed), 18 | VideoInfoUtil.getVideoSpeedTextList())[0] !== '') { 19 | Text(`← ${SelectFileUtil.getPrevAndNextItem(VideoInfoUtil.optimizedFormat(fastForwardInfo.speed), 20 | VideoInfoUtil.getVideoSpeedTextList())[0]}`) 21 | .fontColor($r('sys.color.white')).margin({ right: 10 }).fontSize(10) 22 | } 23 | if (SelectFileUtil.getPrevAndNextItem(VideoInfoUtil.optimizedFormat(fastForwardInfo.speed), 24 | VideoInfoUtil.getVideoSpeedTextList())[1] !== '') { 25 | Text(`${SelectFileUtil.getPrevAndNextItem(VideoInfoUtil.optimizedFormat(fastForwardInfo.speed), 26 | VideoInfoUtil.getVideoSpeedTextList())[1]} →`) 27 | .fontColor($r('sys.color.white')).fontSize(10) 28 | } 29 | }.justifyContent(FlexAlign.SpaceBetween).margin({ top: 5 }) 30 | } 31 | } 32 | .padding({ 33 | left: 10, 34 | right: 10, 35 | top: 10, 36 | bottom: 10 37 | }) 38 | .offset({ 39 | x: 0, 40 | y: (fastForwardInfo.location === -160 || fastForwardInfo.location === -80) ? 41 | fastForwardInfo.location : 42 | (fastForwardInfo.location !== VideoInfoUtil.play_area_height && VideoInfoUtil.play_area_height !== 0 ? 43 | 0 - VideoInfoUtil.play_area_height / 2 + 20 : 44 | 0 - fastForwardInfo.location / 2 + 20) 45 | }) 46 | .borderRadius(16) // 胶囊形状背景 47 | .backgroundColor('#30000000') // 半透明黑色背景 48 | .backdropBlur(100) // 背景模糊效果 49 | .animation({ duration: 300, curve: Curve.Smooth }) 50 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/TimeUtil.ets: -------------------------------------------------------------------------------- 1 | // 时间类,格式化时间 2 | export default class TimeUtil { 3 | static convertMSToHHMMSS(ms: number | undefined): string { 4 | if (!ms) { 5 | return '00:00' 6 | } 7 | // 计算总秒数 8 | const totalSeconds = Math.floor(ms / 1000) // 将毫秒转换为秒 9 | const hours = Math.floor(totalSeconds / 3600) // 计算小时 10 | const minutes = Math.floor((totalSeconds % 3600) / 60) // 计算剩余分钟 11 | const seconds = totalSeconds % 60 // 计算剩余秒数 12 | // 格式化为两位数的字符串,确保单个位数前加零 13 | const hh = hours.toString().padStart(2, '0') 14 | const mm = minutes.toString().padStart(2, '0') 15 | const ss = seconds.toString().padStart(2, '0') 16 | // 根据小时数返回格式化的时间字符串 17 | return hours > 0 ? `${hh}:${mm}:${ss}` : `${mm}:${ss}` 18 | } 19 | 20 | static convertSSToMMSS(seconds: number | undefined): string { 21 | if (seconds === undefined || seconds === null || isNaN(seconds)) { 22 | return '00:00'; 23 | } 24 | const totalSeconds = Math.floor(seconds); 25 | const minutes = Math.floor(totalSeconds / 60); 26 | const remainingSeconds = totalSeconds % 60; 27 | // 格式化显示 (分钟不补零,秒数补零) 28 | const mm = minutes.toString(); // 分钟保持原始数值 29 | const ss = remainingSeconds.toString().padStart(2, '0'); 30 | return `${mm}:${ss}`; 31 | } 32 | 33 | // 字幕时间格式化 34 | static formatTime(ms: number): string { 35 | const totalSeconds = Math.floor(ms / 1000); 36 | const minutes = Math.floor(totalSeconds / 60); 37 | const seconds = totalSeconds % 60; 38 | const milliseconds = ms % 1000; 39 | 40 | return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString() 41 | .padStart(3, '0')}`; 42 | } 43 | 44 | static getTextByTime() { 45 | const now = new Date(); 46 | const hours = now.getHours(); 47 | if (hours === 15) { 48 | return '流心,饮茶啦!'; 49 | } 50 | // 常规时间问候 51 | if (hours >= 0 && hours < 5) { 52 | return '流心晚安~'; 53 | } else if (hours >= 5 && hours < 12) { 54 | return '流心早上好!'; 55 | } else if (hours >= 12 && hours < 14) { 56 | return '流心中午好!'; 57 | } else if (hours >= 14 && hours < 18) { 58 | return '流心下午好!'; 59 | } else { 60 | return '流心晚上好!'; 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /entry/src/main/ets/component/SettingComponent/SettingSliderItem.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig'; 2 | import TimeUtil from '../../utils/TimeUtil'; 3 | import ToolsUtil from '../../utils/ToolsUtil'; 4 | 5 | @Component 6 | export struct SettingSliderItem { 7 | @State symbol: Resource | undefined = undefined 8 | @State message: ResourceStr = '' 9 | @Prop selected: number = 0 10 | @State textSliderMode: boolean = false 11 | onChangeComplete = (_value: number) => { 12 | 13 | } 14 | 15 | build() { 16 | Flex({ justifyContent: FlexAlign.SpaceBetween, alignItems: ItemAlign.Center }) { 17 | Row({ space: 12 }) { 18 | SymbolGlyph(this.symbol) 19 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 20 | if (this.textSliderMode) { 21 | Text(this.message + ':' + (this.selected === 0 ? '关闭' : TimeUtil.convertSSToMMSS(this.selected))) 22 | .fontSize(17) 23 | .fontWeight(FontWeight.Medium) 24 | .maxLines(1) 25 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 26 | } else { 27 | Text(this.message) 28 | .fontSize(17) 29 | .fontWeight(FontWeight.Medium) 30 | .maxLines(1) 31 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 32 | } 33 | } 34 | .width('100%') 35 | .justifyContent(FlexAlign.Start) // 文字区域两端对齐 36 | 37 | Slider({ 38 | value: this.selected, 39 | min: 0, 40 | max: 300, 41 | style: SliderStyle.InSet 42 | }).onChange((value) => { 43 | this.selected = value 44 | ToolsUtil.startVibration() 45 | }) 46 | .onTouch((event) => { 47 | if (event.type == TouchType.Up) { 48 | this.onChangeComplete(this.selected) 49 | } 50 | }) 51 | .selectedColor($r('app.color.main_color')).width('100%') // 进度条单独占满宽度 52 | } 53 | .align(Alignment.Center) 54 | .backgroundColor($r('app.color.start_window_background_blur')) 55 | .height(65) 56 | .borderRadius(16) 57 | .padding({ 58 | right: 10, 59 | left: 15, 60 | top: 10, 61 | bottom: 10 62 | }) 63 | .margin({ top: 5, bottom: 5 }) 64 | } 65 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/BiometricAccessUtil.ets: -------------------------------------------------------------------------------- 1 | import { userAuth } from '@kit.UserAuthenticationKit'; 2 | 3 | // 生物识别工具类 4 | export default class BiometricAccessUtil { 5 | // 1.认证类型列表 6 | static authType: userAuth.UserAuthType[] = [ 7 | userAuth.UserAuthType.PIN, // PIN密码(模拟器支持) 8 | userAuth.UserAuthType.FINGERPRINT, // 指纹 9 | userAuth.UserAuthType.FACE// 面容ID 10 | ] 11 | // 2.认证信任等级 12 | static authTrustLevel: userAuth.AuthTrustLevel = userAuth.AuthTrustLevel.ATL3 13 | // 4.配置认证界面 14 | static widgetParam: userAuth.WidgetParam = { 15 | title: '请进行身份认证', // 用户认证界面的标题, 仅在 PIN/指纹 密码时展示 16 | } 17 | // 3.设置认证参数 18 | private static authParam: userAuth.AuthParam = { 19 | challenge: new Uint8Array([49, 49, 49, 49, 49, 49]), // 挑战值,用来防重放攻击。 20 | authType: BiometricAccessUtil.authType, // 认证类型列表 21 | authTrustLevel: BiometricAccessUtil.authTrustLevel, // 认证信任等级 22 | } 23 | 24 | // 5.查询认证能力是否支持 25 | static checkUserAuthSupport() { 26 | const authTypeRes = BiometricAccessUtil.authType.map((item) => { 27 | try { 28 | // 查询认证能力是否支持,注意这里需要用 try catch 29 | userAuth.getAvailableStatus(item, BiometricAccessUtil.authTrustLevel) 30 | return true 31 | } catch { 32 | return false 33 | } 34 | }) 35 | // 有一项支持即可 36 | return authTypeRes.some(v => v === true) 37 | } 38 | 39 | // 6.发起原生用户生物认证检测 40 | static startUserAuth(): Promise { 41 | return new Promise((resolve, reject) => { 42 | try { 43 | // 1. 获取认证对象 44 | const userAuthInstance = 45 | userAuth.getUserAuthInstance(BiometricAccessUtil.authParam, BiometricAccessUtil.widgetParam) 46 | // 2. 订阅用户身份认证结果 47 | userAuthInstance.on('result', { 48 | // 返回认证结果 49 | onResult(result) { 50 | // 如果验证成功/失败 51 | if (result.result === userAuth.UserAuthResultCode.SUCCESS) { 52 | resolve(true) 53 | } else { 54 | resolve(false) 55 | } 56 | // 认证结束,取消订阅认证结果(释放资源) 57 | userAuthInstance.off('result') 58 | } 59 | }) 60 | // 3. 执行用户生物认证检测 61 | userAuthInstance.start() 62 | } catch (error) { 63 | reject(error) 64 | } 65 | }) 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/VolumeSwipingPanelBuilder.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | import VideoOperateUtil from '../../utils/VideoOperateUtil' 3 | import { AVVolumePanel } from '@kit.AudioKit' 4 | 5 | class VolumeValue { 6 | videoVolume: number = 0 7 | videoVolumeShow: number = 0 8 | onSwipingVoice: boolean = false 9 | } 10 | 11 | @Builder 12 | export function VolumeSwipingPanelBuilder(volumeValue: VolumeValue) { 13 | Stack() { //音量提示 14 | AVVolumePanel({ 15 | volumeLevel: volumeValue.videoVolume, 16 | volumeParameter: { 17 | position: { 18 | x: -1, 19 | y: -1 20 | } 21 | } 22 | }).visibility(Visibility.None) 23 | if (volumeValue.onSwipingVoice) { 24 | Column() { 25 | SymbolGlyph(getVoiceIcon(volumeValue.videoVolume)) 26 | .attributeModifier(new SymbolGlyphFancyModifier(23, '', '')) 27 | .fontColor(['#f0f0f0']) 28 | Slider({ 29 | value: volumeValue.videoVolumeShow, 30 | min: VideoOperateUtil.min_volume, 31 | max: VideoOperateUtil.max_volume, 32 | step: 0.1, 33 | style: SliderStyle.NONE, 34 | direction: Axis.Horizontal, 35 | reverse: false 36 | }) 37 | .width(60) 38 | .height(30) 39 | .selectedColor($r('app.color.main_color')) 40 | .trackColor(Color.Black) 41 | .trackThickness(40) 42 | } 43 | .padding({ 44 | left: 25, 45 | right: 25, 46 | top: 10, 47 | bottom: 10 48 | }) 49 | .borderRadius(1000) 50 | .backgroundColor('#30000000') 51 | .backdropBlur(100) 52 | .animation({ duration: 300, curve: Curve.Smooth }) 53 | } 54 | } 55 | } 56 | 57 | export function getVoiceIcon(videoVolume: number) { 58 | if (videoVolume <= VideoOperateUtil.min_volume) { 59 | return $r('sys.symbol.speaker_slash'); // 静音 60 | } 61 | if (videoVolume >= VideoOperateUtil.max_volume * 2 / 3) { 62 | return $r('sys.symbol.speaker_wave_3'); // 高音量 63 | } 64 | if (videoVolume >= VideoOperateUtil.max_volume * 1 / 3) { 65 | return $r('sys.symbol.speaker_wave_2'); // 中音量 66 | } 67 | return $r('sys.symbol.speaker_wave_1'); // 低音量 68 | } -------------------------------------------------------------------------------- /entry/build-profile.json5: -------------------------------------------------------------------------------- 1 | { 2 | "apiType": "stageMode", 3 | "buildOption": { 4 | }, 5 | "buildOptionSet": [ 6 | { 7 | "name": "release", 8 | // 打包时候排除x64 so包,减少包体积 9 | "nativeLib": { 10 | "filter": { 11 | "excludes": [ 12 | "**/x86_64/*.so" 13 | ] 14 | } 15 | }, 16 | // 这里开启了纹理压缩,用于提升内置图片加载速度 17 | "resOptions": { 18 | "compression": { 19 | "media": { 20 | "enable": true 21 | }, 22 | "filters": [ 23 | { 24 | "method": { 25 | "type": "sut", 26 | // conversion type 27 | "blocks": "4x4" 28 | // The extended parameters of the conversion type 29 | }, 30 | // Specifies the files used for compression. Only files that meet all conditions and are not excluded can be compressed 31 | "files": { 32 | "path": [ 33 | "./**/*" 34 | ], 35 | // All files in the specified resource directory 36 | "size": [ 37 | [ 38 | 0, 39 | '1000k' 40 | ] 41 | ], 42 | // Files with a specified size of less than 1000k 43 | // Pictures with a resolution smaller than 3000 x 3000 44 | "resolution": [ 45 | [ 46 | { 47 | "width": 0, 48 | "height": 0 49 | }, 50 | // minimum width and height 51 | { 52 | "width": 3000, 53 | "height": 3000 54 | } 55 | // Maximum width and height 56 | ] 57 | ] 58 | } 59 | } 60 | ] 61 | } 62 | }, 63 | "arkOptions": { 64 | "obfuscation": { 65 | "ruleOptions": { 66 | "enable": false, 67 | "files": [ 68 | "./obfuscation-rules.txt" 69 | ] 70 | } 71 | } 72 | } 73 | }, 74 | ], 75 | "targets": [ 76 | { 77 | "name": "default" 78 | }, 79 | { 80 | "name": "ohosTest", 81 | } 82 | ] 83 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/DeletedVideosDialog.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface' 2 | import DataSyncUtil from '../../utils/DataSyncUtil' 3 | import { DeletedVideosDataSource } from '../../utils/DataUtil' 4 | 5 | @CustomDialog 6 | export struct DeletedVideosDialog { 7 | @State deletedVideosDataSource: DeletedVideosDataSource = new DeletedVideosDataSource([]) 8 | controller?: CustomDialogController 9 | private metadata: VideoMetadata[] = [] 10 | 11 | aboutToAppear(): void { 12 | this.metadata = DataSyncUtil.deletedVideos as VideoMetadata[] 13 | this.deletedVideosDataSource = new DeletedVideosDataSource(this.metadata) 14 | DataSyncUtil.deletedVideos.length = 0 15 | } 16 | 17 | build() { 18 | Column() { 19 | Column() { 20 | Text('已失效视频') 21 | .fontSize(25) 22 | .fontColor($r('app.color.text_color')) 23 | .fontWeight(FontWeight.Bold) 24 | Text(`视频已失效,请重新导入,共 ${this.deletedVideosDataSource.totalCount()} 项`) 25 | .fontSize(14) 26 | .fontColor($r('app.color.text_color')) 27 | .margin({ top: 4 }) 28 | } 29 | .alignItems(HorizontalAlign.Start) 30 | .padding({ bottom: 12 }) 31 | 32 | List({ space: 8 }) { 33 | LazyForEach(this.deletedVideosDataSource, (item: VideoMetadata) => { 34 | ListItem() { 35 | Text(item?.title) 36 | .fontSize(14) 37 | .fontColor($r('app.color.text_color')) 38 | .margin({ bottom: 4 }) 39 | .width('100%') 40 | }.reuseId(item?.title) 41 | }, (item: VideoMetadata) => item.uri) 42 | } 43 | .cachedCount(12) 44 | .width('auto') 45 | .height('auto') 46 | .divider({ strokeWidth: 0 }) 47 | .scrollBar(BarState.Off) // 动态显示滚动条 48 | .edgeEffect(EdgeEffect.Spring) 49 | .layoutWeight(1) // 确保列表区域自动扩展 50 | 51 | Button({ type: ButtonType.Capsule, stateEffect: true }) { 52 | Text($r('app.string.cancel')) 53 | .fontSize(18) 54 | .fontColor($r('app.color.text_color')) 55 | } 56 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 57 | .backgroundColor($r('sys.color.button_background_color_transparent')) 58 | .borderRadius(16) 59 | .height(60) 60 | .width(80) 61 | .margin({ bottom: 10 }) 62 | .onClick(() => { 63 | this.controller?.close() 64 | }) 65 | } 66 | .padding(20) 67 | } 68 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/AudioTrackComponent.ets: -------------------------------------------------------------------------------- 1 | import { AudioTrack } from '../../interfaces/AudioTrackInterface' 2 | import { ImageFancyModifier } from '../../common/AttributeModifierConfig' 3 | import { AudioTrackSource } from '../../utils/DataUtil' 4 | import VideoOperateUtil from '../../utils/VideoOperateUtil' 5 | 6 | @Component 7 | export struct AudioTrackComponent { 8 | @State audioTrackDataSource: AudioTrackSource = new AudioTrackSource([]) 9 | @Prop audioTrack: AudioTrack[] 10 | @Link audioTrackSelected: number 11 | onSelect = (_index: number) => { 12 | } 13 | 14 | aboutToAppear(): void { 15 | this.audioTrackDataSource = new AudioTrackSource(this.audioTrack) 16 | } 17 | 18 | build() { 19 | Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) { 20 | if (this.audioTrack.length === 0) { 21 | Row() { 22 | Text('实验性音轨解码') 23 | .fontSize(18) 24 | .height(40) 25 | .flexGrow(1) 26 | .flexShrink(1) 27 | .margin({ left: 5 }) 28 | Image($r('app.media.ffmpeg')) 29 | .attributeModifier(new ImageFancyModifier(10, 30, 30)) 30 | .width(95) 31 | }.backgroundColor($r('app.color.main_color')) 32 | .borderRadius(16).width('100%') 33 | } 34 | if (VideoOperateUtil.validateAudioTracks(this.audioTrack)) { 35 | LazyForEach(this.audioTrackDataSource, (item: AudioTrack, index: number) => { 36 | Row() { // 音轨 37 | Text(`${item.index} : ${item.name}(${VideoOperateUtil.getLanguageName(item.language)})`) 38 | .fontSize(18) 39 | .height(40) 40 | .flexGrow(1) 41 | .flexShrink(1) 42 | .textOverflow({ overflow: TextOverflow.Ellipsis }) 43 | .fontColor(this.audioTrackSelected === index ? $r('sys.color.white') : $r('app.color.text_color')) 44 | .margin({ left: 5 }) 45 | if (item.mime.split('/')[1] === 'av3a') { 46 | Image($r('app.media.audio_vivid_icon')) 47 | .attributeModifier(new ImageFancyModifier(8, 85, 30)) 48 | .width(95) 49 | } 50 | } 51 | .backgroundColor(this.audioTrackSelected === index ? $r('app.color.main_color') : '') 52 | .borderRadius(16) 53 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 54 | .width('100%') 55 | .onClick(() => { 56 | this.onSelect(index) 57 | }) 58 | }, (item: AudioTrack) => item.name + item.index) 59 | } 60 | }.width(235) 61 | } 62 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/TaskpoolUtil.ets: -------------------------------------------------------------------------------- 1 | import { taskpool } from '@kit.ArkTS'; 2 | import { VideoSource } from '../common/enum/VideoSource'; 3 | import { FileFolder } from '../interfaces/FileFolderInterface'; 4 | import { VideoMetadata } from '../interfaces/VideoMetadataInterface'; 5 | import { PathUtils } from './PathUtils'; 6 | import RecentPlayUtil from './RecentPlayUtil'; 7 | import SelectFileUtil from './SelectFileUtil'; 8 | import SubtitleUtil from './SubtitleUtil'; 9 | 10 | @Concurrent 11 | async function checkUriAvailable(fileFolderList: FileFolder[]): Promise { 12 | const deletedVideos: VideoMetadata[] = [] 13 | const cleanupPromises: Promise[] = [] 14 | const videosToCheck: VideoMetadata[] = [] 15 | for (let folderIndex = 0; folderIndex < fileFolderList.length; folderIndex++) { 16 | const folder = fileFolderList[folderIndex] 17 | for (let videoIndex = 0; videoIndex < folder.video_list.length; videoIndex++) { 18 | const video = folder.video_list[videoIndex] 19 | if (SelectFileUtil.isPathFromSource(video.uri, VideoSource.EXTERNAL_DEVICE)) { 20 | continue 21 | } 22 | videosToCheck.push(video) 23 | } 24 | } 25 | const checkPromises = videosToCheck.map(async (video: VideoMetadata) => { 26 | try { 27 | let exists = false 28 | try { 29 | exists = await SelectFileUtil.isFileExist(video.uri, false) 30 | } catch (isExistError) { 31 | console.error('调用isFileExist失败:', isExistError) 32 | exists = false 33 | } 34 | if (!exists) { 35 | deletedVideos.push(video) 36 | const cleanupTask = (async () => { 37 | try { 38 | await Promise.all([ 39 | SubtitleUtil.deleteSubtitle(PathUtils.subtitlePath, video.date!), 40 | RecentPlayUtil.delData(PathUtils.appContext!, video.uri), 41 | SelectFileUtil.deleteCover(video.date!) 42 | ]) 43 | } catch (error) { 44 | console.warn(`清理视频资源失败: ${video.uri}`, error) 45 | } 46 | })() 47 | cleanupPromises.push(cleanupTask) 48 | } 49 | } catch (error) { 50 | console.warn(`检查文件存在性失败: ${video.uri}`, error) 51 | } 52 | }) 53 | await Promise.all(checkPromises) 54 | await Promise.all(cleanupPromises) 55 | return deletedVideos 56 | } 57 | 58 | export function checkUriProcess(param: FileFolder[], callback: (re: Object) => void) { 59 | taskpool.execute(new taskpool.Task(checkUriAvailable, param), taskpool.Priority.HIGH).then((result: Object) => { 60 | callback(result) 61 | }).catch((err: string) => { 62 | console.error("taskPoolTest test occur error: " + err) 63 | }) 64 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/VideoCardItemComponent.ets: -------------------------------------------------------------------------------- 1 | import { PathUtils } from '../../utils/PathUtils'; 2 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface'; 3 | import { fileUri } from '@kit.CoreFileKit'; 4 | import { ImageFancyModifier, SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig'; 5 | import VideoInfoUtil from '../../utils/VideoInfoUtil'; 6 | import { VideoListController } from './VideoItemComponent'; 7 | import { VideoInfoBuilder } from './VideoInfoBuilder'; 8 | import { ListDisplayMode } from '../../common/enum/ListDisplayMode'; 9 | import { Setting } from '../../utils/ObservedUtil'; 10 | 11 | @Reusable 12 | @Component 13 | export default struct VideoCardItem { 14 | @Prop item: VideoMetadata 15 | @Consume videoListController: VideoListController 16 | @State choose: boolean = false 17 | @Watch('checkChoose') @Link delMultipleList: VideoMetadata[] 18 | 19 | checkChoose() { 20 | this.choose = this.delMultipleList.some(item => item.date === this.item.date) 21 | } 22 | 23 | build() { 24 | Column() { 25 | Image(fileUri.getUriFromPath(PathUtils.coverPath + this.item.date)) 26 | .attributeModifier(new ImageFancyModifier({ 27 | topLeft: 16, 28 | topRight: 16, 29 | bottomLeft: 16, 30 | bottomRight: 16 31 | }, '', '')) 32 | .width('100%') 33 | .height('auto') 34 | .constraintSize({ 35 | maxHeight: Setting.listDisplayMode === ListDisplayMode.GRID_DISPLAY ? 100 : 300, 36 | minHeight: Setting.listDisplayMode === ListDisplayMode.GRID_DISPLAY ? 100 : undefined 37 | }) 38 | .objectFit(ImageFit.Auto) 39 | Row() { 40 | Text(VideoInfoUtil.getVideoTitle(this.item)) 41 | .fontSize(15) 42 | .fontWeight(FontWeight.Bold) 43 | .maxLines(Setting.listDisplayMode === ListDisplayMode.GRID_DISPLAY ? 1 : 5) 44 | .textAlign(TextAlign.Start) 45 | .textOverflow({ overflow: TextOverflow.Ellipsis }) 46 | .layoutWeight(1)// 占据剩余空间 47 | .wordBreak(WordBreak.BREAK_ALL) 48 | if (this.videoListController.multipleChooseState === Visibility.Visible) { 49 | SymbolGlyph(this.choose ? $r('sys.symbol.checkmark_circle') : $r('sys.symbol.circle')) 50 | .attributeModifier(new SymbolGlyphFancyModifier(25, '', '')) 51 | .onAppear(() => { 52 | this.checkChoose() 53 | }) 54 | } 55 | } 56 | .width('100%') 57 | .padding({ top: 5, left: 5 }) 58 | 59 | VideoInfoBuilder({ item: this.item, showProgress: true }) 60 | } 61 | .width('100%') 62 | .height('auto') 63 | } 64 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/EditPasswordDialog.ets: -------------------------------------------------------------------------------- 1 | import PrivacySpaceUtil from '../../utils/PrivacySpaceUtil' 2 | 3 | // 重设密码弹窗 4 | @CustomDialog 5 | export struct EditPasswordDialog { 6 | confirm?: (name: string | undefined) => void 7 | controller?: CustomDialogController 8 | passwd: string | undefined = undefined 9 | 10 | build() { 11 | Column({ space: 25 }) { 12 | Text(PrivacySpaceUtil.getPrivacyMode() ? '修改密码 (/▽\)' : $r('app.string.set_passwd')) 13 | .fontSize(25) 14 | .fontColor($r('app.color.text_color')) 15 | .fontWeight(FontWeight.Bold) 16 | .margin({ top: 10 }) 17 | TextInput() 18 | .keyboardAppearance(KeyboardAppearance.IMMERSIVE)// 设置搜索框沉浸模式 19 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 20 | .onChange((input: string) => this.passwd = input) 21 | .type(InputType.NEW_PASSWORD) 22 | .enableAutoFill(true) 23 | .id('input_pwd') 24 | .onAppear(() => { 25 | focusControl.requestFocus('input_pwd') 26 | }) 27 | 28 | Flex({ justifyContent: FlexAlign.SpaceAround }) { 29 | Button({ type: ButtonType.Normal, stateEffect: true }) { 30 | Row({ space: 5 }) { 31 | SymbolGlyph($r('sys.symbol.xmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 32 | Text($r('app.string.cancel')) 33 | .fontSize(18) 34 | .fontColor($r('app.color.text_color')) 35 | .fontWeight(FontWeight.Medium) 36 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 37 | } 38 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 39 | .backgroundColor($r('sys.color.titlebar_icon_background_color')) 40 | .borderRadius(16) 41 | .height(40) 42 | .onClick(() => this.controller?.close()) 43 | 44 | Button({ type: ButtonType.Normal, stateEffect: true }) { 45 | Row({ space: 5 }) { 46 | SymbolGlyph($r('sys.symbol.checkmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 47 | Text($r('app.string.sure')).fontSize(18).fontColor($r('app.color.text_color')).fontWeight(FontWeight.Medium) 48 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 49 | } 50 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 51 | .backgroundColor($r('sys.color.titlebar_icon_background_color')) 52 | .borderRadius(16) 53 | .height(40) 54 | .onClick(() => { 55 | this.controller?.close() 56 | if (this.confirm) { 57 | this.confirm(this.passwd) 58 | } 59 | }) 60 | }.margin({ bottom: 10 }) 61 | }.padding(20) 62 | } 63 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/EditFileFolderNameDialog.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../../interfaces/FileFolderInterface' 2 | import DataSyncUtil from '../../utils/DataSyncUtil' 3 | 4 | // 重命名文件夹弹窗 5 | @CustomDialog 6 | export struct EditFileFolderNameDialog { 7 | confirm?: (name: string | undefined) => void 8 | controller?: CustomDialogController 9 | file_folder_name: string | undefined = undefined 10 | 11 | aboutToAppear(): void { 12 | let tmp = DataSyncUtil.editingFolder as FileFolder 13 | this.file_folder_name = tmp?.name 14 | } 15 | 16 | build() { 17 | Column({ space: 25 }) { 18 | Text('修改新文件夹名') 19 | .fontSize(25) 20 | .fontColor($r('app.color.text_color')) 21 | .fontWeight(FontWeight.Bold) 22 | .margin({ top: 10 }) 23 | TextInput({ text: this.file_folder_name }) 24 | .keyboardAppearance(KeyboardAppearance.IMMERSIVE)// 设置搜索框沉浸模式 25 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 26 | .onChange((input: string) => this.file_folder_name = input) 27 | .id('input_name') 28 | .onAppear(() => { 29 | focusControl.requestFocus('input_name') 30 | }) 31 | 32 | Flex({ justifyContent: FlexAlign.SpaceAround }) { 33 | Button({ type: ButtonType.Normal, stateEffect: true }) { 34 | Row({ space: 5 }) { 35 | SymbolGlyph($r('sys.symbol.xmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 36 | Text($r('app.string.cancel')) 37 | .fontSize(18) 38 | .fontColor($r('app.color.text_color')) 39 | .fontWeight(FontWeight.Medium) 40 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 41 | } 42 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 43 | .backgroundColor($r('sys.color.titlebar_icon_background_color')) 44 | .borderRadius(16) 45 | .height(40) 46 | .onClick(() => this.controller?.close()) 47 | 48 | Button({ type: ButtonType.Normal, stateEffect: true }) { 49 | Row({ space: 5 }) { 50 | SymbolGlyph($r('sys.symbol.checkmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 51 | Text($r('app.string.sure')).fontSize(18).fontColor($r('app.color.text_color')).fontWeight(FontWeight.Medium) 52 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 53 | } 54 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 55 | .backgroundColor($r('sys.color.titlebar_icon_background_color')) 56 | .borderRadius(16) 57 | .height(40) 58 | .onClick(() => { 59 | this.controller?.close() 60 | if (this.confirm) { 61 | this.confirm(this.file_folder_name) 62 | } 63 | }) 64 | }.margin({ bottom: 10 }) 65 | }.padding(20) 66 | } 67 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/SettingComponent/SettingTimeItem.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig'; 2 | import TimeUtil from '../../utils/TimeUtil'; 3 | 4 | @Component 5 | export struct SettingTimeItem { 6 | @State symbol: Resource | undefined = undefined 7 | @State message: ResourceStr = '' 8 | @State selectedIndex: number[] = [0, 0, 0, 0] 9 | @Prop selected: number = 0; 10 | private TenthsOfMinutes: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] 11 | private UnitOfTMinutes: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] 12 | private TenthsOfSecond: string[] = ['0', '1', '2', '3', '4', '5'] 13 | private UnitOfSecond: string[] = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9'] 14 | private multi: string[][] = [this.TenthsOfMinutes, this.UnitOfTMinutes, this.TenthsOfSecond, this.UnitOfSecond] 15 | onChange = (_value: number) => { 16 | } 17 | 18 | aboutToAppear() { 19 | const timeStr = this.convertToTimeFormat(this.selected); 20 | this.selectedIndex = this.timeToIndexes(timeStr); 21 | } 22 | 23 | build() { 24 | Column() { 25 | Row({ space: 10 }) { 26 | SymbolGlyph(this.symbol) 27 | .attributeModifier(new SymbolGlyphFancyModifier(22, '', '')) 28 | Text(this.message + ':') 29 | .fontSize(17) 30 | .fontWeight(FontWeight.Medium) 31 | .maxLines(1) 32 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 33 | Text(this.selected === 0 ? '不跳过' : TimeUtil.convertSSToMMSS(this.selected)) 34 | .fontSize(17) 35 | .fontWeight(FontWeight.Medium) 36 | .width('100%') // 宽度充满父容器 37 | } 38 | .width('100%') 39 | .margin({ bottom: 10 }) // 与下部分间距 40 | 41 | TextPicker({ range: this.multi, selected: this.selectedIndex }) 42 | .width('100%')// 宽度充满父容器 43 | .onChange((value: string | string[], index: number | number[]) => { 44 | if (Array.isArray(index)) { 45 | this.selectedIndex = index 46 | const minutes = parseInt(value[0] + value[1], 10); 47 | const seconds = parseInt(value[2] + value[3], 10); 48 | const totalSeconds = minutes * 60 + seconds; 49 | this.onChange(totalSeconds) 50 | } 51 | }) 52 | .divider(null) 53 | } 54 | .backgroundColor($r('app.color.start_window_background_blur')) 55 | .borderRadius(16) 56 | .padding(15) // 统一内边距 57 | .margin({ top: 5, bottom: 5 }) 58 | } 59 | 60 | private convertToTimeFormat(totalSeconds: number): string { 61 | const minutes = Math.floor(totalSeconds / 60); 62 | const seconds = totalSeconds % 60; 63 | return `${minutes.toString().padStart(2, '0')}${seconds.toString().padStart(2, '0')}`; 64 | } 65 | 66 | private timeToIndexes(timeStr: string): number[] { 67 | return timeStr.split('').map((char, i) => this.multi[i].indexOf(char)); 68 | } 69 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/EditMetadataDialog.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface'; 2 | import DataSyncUtil from '../../utils/DataSyncUtil'; 3 | 4 | // 重命名视频弹窗 5 | @CustomDialog 6 | export struct EditMetadataDialog { 7 | confirm?: (name: string | undefined) => void 8 | controller?: CustomDialogController 9 | name: string | undefined = undefined 10 | 11 | aboutToAppear(): void { 12 | let videoMetadata = DataSyncUtil.editingVideo as VideoMetadata 13 | this.name = videoMetadata?.title 14 | } 15 | 16 | build() { 17 | Column({ space: 25 }) { 18 | Text($r('app.string.rename')) 19 | .fontSize(25) 20 | .fontColor($r('app.color.text_color')) 21 | .fontWeight(FontWeight.Bold) 22 | .margin({ top: 10 }) 23 | TextInput({ text: this.name?.slice(0, this.name?.lastIndexOf('.')) }) 24 | .id('change_name') 25 | .keyboardAppearance(KeyboardAppearance.IMMERSIVE)// 设置搜索框沉浸模式 26 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 27 | .onChange((input: string) => this.name = input + "." + this.name?.split('.')[this.name?.split('.').length - 1]) 28 | .onAppear(() => { 29 | focusControl.requestFocus('change_name') 30 | }) 31 | Flex({ justifyContent: FlexAlign.SpaceAround }) { 32 | Button({ type: ButtonType.Normal, stateEffect: true }) { 33 | Row({ space: 5 }) { 34 | SymbolGlyph($r('sys.symbol.xmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 35 | Text($r('app.string.cancel')) 36 | .fontSize(18) 37 | .fontColor($r('app.color.text_color')) 38 | .fontWeight(FontWeight.Medium) 39 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 40 | } 41 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 42 | .backgroundColor($r('sys.color.titlebar_icon_background_color')) 43 | .borderRadius(16) 44 | .height(40) 45 | .onClick(() => this.controller?.close()) 46 | 47 | Button({ type: ButtonType.Normal, stateEffect: true }) { 48 | Row({ space: 5 }) { 49 | SymbolGlyph($r('sys.symbol.checkmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 50 | Text($r('app.string.sure')).fontSize(18).fontColor($r('app.color.text_color')).fontWeight(FontWeight.Medium) 51 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 52 | } 53 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 54 | .backgroundColor($r('sys.color.titlebar_icon_background_color')) 55 | .borderRadius(16) 56 | .height(40) 57 | .onClick(() => { 58 | this.controller?.close() 59 | if (this.confirm) { 60 | this.confirm(this.name) 61 | } 62 | }) 63 | }.margin({ bottom: 10 }) 64 | }.padding(20) 65 | } 66 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/AddFileFolderNameDialog.ets: -------------------------------------------------------------------------------- 1 | import { FileFolderDataSource } from '../../utils/DataUtil' 2 | import FileFolderUtil from '../../utils/FileFolderUtil' 3 | import { PathUtils } from '../../utils/PathUtils' 4 | 5 | // 添加文件夹弹窗 6 | @CustomDialog 7 | export struct AddFileFolderNameDialog { //编辑元数据弹窗 8 | controller?: CustomDialogController 9 | file_folder_name: string | undefined = undefined 10 | @Consume fileFolderSource: FileFolderDataSource 11 | 12 | build() { 13 | Column({ space: 25 }) { 14 | Text('输入新文件夹名') 15 | .fontSize(25) 16 | .fontColor($r('app.color.text_color')) 17 | .fontWeight(FontWeight.Bold) 18 | .margin({ top: 10 }) 19 | TextInput() 20 | .keyboardAppearance(KeyboardAppearance.IMMERSIVE)// 设置搜索框沉浸模式 21 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 22 | .onChange((input: string) => this.file_folder_name = input) 23 | .id('input_name') 24 | .onAppear(() => { 25 | focusControl.requestFocus('input_name') 26 | }) 27 | 28 | Flex({ justifyContent: FlexAlign.SpaceAround }) { 29 | Button({ type: ButtonType.Normal, stateEffect: true }) { 30 | Row({ space: 5 }) { 31 | SymbolGlyph($r('sys.symbol.xmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 32 | Text($r('app.string.cancel')) 33 | .fontSize(18) 34 | .fontColor($r('app.color.text_color')) 35 | .fontWeight(FontWeight.Medium) 36 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 37 | } 38 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 39 | .backgroundColor($r('sys.color.button_background_color_transparent')) 40 | .borderRadius(16) 41 | .height(40) 42 | .onClick(() => this.controller?.close()) 43 | 44 | Button({ type: ButtonType.Normal, stateEffect: true }) { 45 | Row({ space: 5 }) { 46 | SymbolGlyph($r('sys.symbol.checkmark')).fontSize(18).fontColor([$r('app.color.text_color')]) 47 | Text($r('app.string.sure')).fontSize(18).fontColor($r('app.color.text_color')).fontWeight(FontWeight.Medium) 48 | }.alignItems(VerticalAlign.Center).padding({ left: 12, right: 12 }) 49 | } 50 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 51 | .backgroundColor($r('sys.color.button_background_color_transparent')) 52 | .borderRadius(16) 53 | .height(40) 54 | .enabled(this.file_folder_name?.trim() != '') 55 | .onClick(() => { 56 | if (!this.file_folder_name || this.file_folder_name.trim() == '') { 57 | return 58 | } 59 | FileFolderUtil.createNewFolder(PathUtils.appContext!, this.file_folder_name, this.fileFolderSource) 60 | this.controller?.close() 61 | }) 62 | }.margin({ bottom: 10 }) 63 | }.padding(20) 64 | } 65 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/VideoInfoBuilder.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface'; 2 | import { ImageFancyModifier } from '../../common/AttributeModifierConfig'; 3 | import VideoInfoUtil from '../../utils/VideoInfoUtil'; 4 | import { media } from '@kit.MediaKit'; 5 | import TimeUtil from '../../utils/TimeUtil'; 6 | import { VideoMetadataFromPlayer } from '../../interfaces/VideoMetadataFromPlayerInterface'; 7 | 8 | class VideoInfoBuilderParams { 9 | item: VideoMetadata | undefined = undefined 10 | nowPlaying?: VideoMetadataFromPlayer | undefined = undefined 11 | showProgress: boolean = true 12 | } 13 | 14 | @Builder 15 | export function VideoInfoBuilder(params: VideoInfoBuilderParams) { 16 | Row() { 17 | // HDR类型图标 18 | if (params.item?.hdr_type === media.HdrType.AV_HDR_TYPE_VIVID) { 19 | Image($r("app.media.hdr_vivid_icon")) 20 | .attributeModifier(new ImageFancyModifier(8, 65, 25)) 21 | .height(22) // 统一高度 22 | .width('auto') 23 | } 24 | // 分辨率显示逻辑 25 | else { 26 | if (VideoInfoUtil.videoWidthAndHeightFormat(params.item!.size.toString()).includes('HD')) { 27 | Text(VideoInfoUtil.videoWidthAndHeightFormat(params.item!.size.toString())) 28 | .height(22)// 统一高度 29 | .fontWeight(FontWeight.Bold) 30 | .fontSize(10) 31 | .opacity(0.8) 32 | .backgroundColor('#de9e44') 33 | .fontColor($r('sys.color.black')) 34 | .padding(5) 35 | .borderRadius(8) 36 | } else if (VideoInfoUtil.videoWidthAndHeightFormat(params.item!.size.toString()) === '0 x 0') { 37 | Text($r('app.string.unknown_resolution')) 38 | .height(22)// 统一高度 39 | .fontSize(10) 40 | .opacity(0.8) 41 | .backgroundColor('#c1b2a3') 42 | .fontColor($r('sys.color.black')) 43 | .padding(5) 44 | .borderRadius(8) 45 | } else { 46 | Text(VideoInfoUtil.videoWidthAndHeightFormat(params.item!.size.toString())) 47 | .fontSize(15) 48 | .opacity(0.8) 49 | .fontColor(params.nowPlaying && params.nowPlaying?.date === params.item?.date ? 50 | $r('sys.color.white') : $r('app.color.text_color')) 51 | } 52 | } 53 | 54 | Text(" " + TimeUtil.convertMSToHHMMSS(params.item!.time)) 55 | .fontSize(15) 56 | .opacity(0.8) 57 | .fontColor(params.nowPlaying && params.nowPlaying?.date === params.item?.date ? 58 | $r('sys.color.white') : $r('app.color.text_color')) 59 | if (params.showProgress) { 60 | Progress({ value: params.item?.last_play, total: params.item?.time, type: ProgressType.Ring }) 61 | .width(30).color($r('app.color.main_color')).height(30) 62 | .style({ strokeWidth: 5, shadow: true, enableSmoothEffect: true }) 63 | } 64 | } 65 | .justifyContent(FlexAlign.Start) 66 | .width('100%') 67 | .height(35) // 固定行高 68 | .padding({ right: 10, left: 5 }) 69 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/FileFolderComponent/FileFolderListComponent.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../../interfaces/FileFolderInterface' 2 | import { ButtonFancyModifier, SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 3 | import { VideoListController } from '../VideoItemComponent/VideoItemComponent' 4 | 5 | class FileFolderListBuilderValue { 6 | folder: FileFolder | undefined = undefined 7 | videoListController: VideoListController | undefined = undefined 8 | } 9 | 10 | @Builder 11 | export function FileFolderListBuilder(fileFolderListBuilderValue: FileFolderListBuilderValue) { 12 | Button({ type: ButtonType.Normal, stateEffect: true }) { 13 | Row() { 14 | SymbolGlyph(fileFolderListBuilderValue.videoListController?.folder.date === 15 | fileFolderListBuilderValue.folder?.date ? 16 | $r('sys.symbol.doc_plaintext_and_folder') : $r('sys.symbol.folder')) 17 | .attributeModifier(new SymbolGlyphFancyModifier(25, '', '')) 18 | .fontColor(fileFolderListBuilderValue.videoListController?.folder.date === 19 | fileFolderListBuilderValue.folder?.date ? 20 | [$r('app.color.main_color'), $r('app.color.text_color')] : [$r('app.color.text_color')]) 21 | Column() { 22 | Text(fileFolderListBuilderValue.folder?.name) 23 | .width('100%') 24 | .fontColor(fileFolderListBuilderValue.videoListController?.folder.date === 25 | fileFolderListBuilderValue.folder?.date ? 26 | $r('app.color.main_color') : $r('app.color.text_color')) 27 | .fontSize(fileFolderListBuilderValue.videoListController?.folder.date === 28 | fileFolderListBuilderValue.folder?.date ? 18 : 15) 29 | .fontWeight(fileFolderListBuilderValue.videoListController?.folder.date === 30 | fileFolderListBuilderValue.folder?.date ? FontWeight.Bold : 31 | FontWeight.Medium) 32 | .textAlign(TextAlign.Start) 33 | .maxLines(1) 34 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 35 | if (fileFolderListBuilderValue.videoListController?.folder?.date === fileFolderListBuilderValue.folder?.date) { 36 | Text(fileFolderListBuilderValue.videoListController?.folder?.video_list.length === 0 ? `空空如也~` : 37 | `共 ${fileFolderListBuilderValue.videoListController?.folder?.video_list.length} 部视频`) 38 | .fontColor($r('app.color.main_color')) 39 | .fontSize(12) 40 | .textAlign(TextAlign.Start) 41 | .width('100%') 42 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 43 | } 44 | } 45 | .margin({ left: 5 }) 46 | .layoutWeight(1) 47 | .justifyContent(FlexAlign.Center) 48 | } 49 | .width('100%') 50 | .alignItems(VerticalAlign.Center) 51 | } 52 | .align(Alignment.Start) 53 | .padding({ left: 15, right: 15 }) 54 | .attributeModifier(new ButtonFancyModifier('100%', 60)) 55 | .animation({ duration: 300, curve: Curve.Ease }) 56 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/SubtitlePanelComponent.ets: -------------------------------------------------------------------------------- 1 | import { deviceInfo } from '@kit.BasicServicesKit' 2 | import { SubtitleMode } from '../../common/enum/SubtitleMode' 3 | import ToolsUtil from '../../utils/ToolsUtil' 4 | 5 | @Component 6 | export struct SubtitlePanelComponent { 7 | @Link subTitleVisibility: Visibility 8 | @Link isAIAsrShown: boolean 9 | @Prop subtitleSelected: SubtitleMode 10 | 11 | build() { 12 | List() { 13 | ListItem() { 14 | Text('内嵌字幕:' + (this.subTitleVisibility === Visibility.Visible ? '显示(如果有)' : '字幕隐藏')) 15 | .fontColor(this.subtitleSelected === SubtitleMode.INNER_SUBTITLE ? $r('sys.color.white') : 16 | $r('app.color.text_color')) 17 | } 18 | .onClick(() => { 19 | this.isAIAsrShown = false 20 | this.subTitleVisibility === Visibility.Visible && this.subtitleSelected === SubtitleMode.INNER_SUBTITLE ? 21 | this.subTitleVisibility = Visibility.None : 22 | this.subTitleVisibility = Visibility.Visible 23 | this.subtitleSelected = SubtitleMode.INNER_SUBTITLE 24 | }) 25 | .height(40) 26 | .backgroundColor(this.subtitleSelected === SubtitleMode.INNER_SUBTITLE ? $r('app.color.main_color') : '') 27 | .borderRadius(16) 28 | .width('100%') 29 | 30 | ListItem() { 31 | Text('外挂字幕' + (this.subTitleVisibility === Visibility.Visible ? '显示(如果有)' : '字幕隐藏')) 32 | .fontColor(this.subtitleSelected === SubtitleMode.EXTERNAL_SUBTITLE ? $r('sys.color.white') : 33 | $r('app.color.text_color')) 34 | } 35 | .onClick(() => { 36 | this.isAIAsrShown = false 37 | this.subTitleVisibility === Visibility.Visible && this.subtitleSelected === SubtitleMode.EXTERNAL_SUBTITLE ? 38 | this.subTitleVisibility = Visibility.None : 39 | this.subTitleVisibility = Visibility.Visible 40 | this.subtitleSelected = SubtitleMode.EXTERNAL_SUBTITLE 41 | }) 42 | .height(40) 43 | .backgroundColor(this.subtitleSelected === SubtitleMode.EXTERNAL_SUBTITLE ? $r('app.color.main_color') : '') 44 | .borderRadius(16) 45 | .width('100%') 46 | 47 | if (deviceInfo.sdkApiVersion >= 18 && canIUse('SystemCapability.AI.AICaption')) { 48 | ListItem() { 49 | Text('AI 字幕:' + (this.isAIAsrShown ? '显示' : '隐藏')) 50 | .fontColor(this.subtitleSelected === SubtitleMode.AI_SUBTITLE ? $r('sys.color.white') : 51 | $r('app.color.text_color')) 52 | } 53 | .onClick(() => { 54 | this.isAIAsrShown = !this.isAIAsrShown 55 | if (this.isAIAsrShown) { 56 | ToolsUtil.showToast('AI 字幕将在控制栏隐藏后出现') 57 | } 58 | this.subTitleVisibility = Visibility.None 59 | this.subtitleSelected = SubtitleMode.AI_SUBTITLE 60 | }) 61 | .height(40) 62 | .backgroundColor(this.subtitleSelected === SubtitleMode.AI_SUBTITLE ? $r('app.color.main_color') : '') 63 | .borderRadius(16) 64 | .width('100%') 65 | } 66 | } 67 | .width(200).height('auto').scrollBar(BarState.Off) 68 | } 69 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/FileFolderComponent/FileFolderMenu.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../../interfaces/FileFolderInterface' 2 | import { FileFolderDataSource } from '../../utils/DataUtil' 3 | import { SymbolGlyphModifier } from '@kit.ArkUI' 4 | import DataSyncUtil from '../../utils/DataSyncUtil' 5 | import { VideoListController } from '../VideoItemComponent/VideoItemComponent' 6 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface' 7 | import FileFolderUtil from '../../utils/FileFolderUtil' 8 | import { PathUtils } from '../../utils/PathUtils' 9 | import { FolderOperator } from '../../database/VideoMetaData' 10 | import { AddFileFolderNameDialog } from '../Dialog/AddFileFolderNameDialog' 11 | import VideoUtils from '../../utils/VideoUtil' 12 | import { DefaultDialogShadow, MenuFancyModifier } from '../../common/AttributeModifierConfig' 13 | 14 | 15 | @Component 16 | export struct FileFolderMenu { 17 | @Consume fileFolderSource: FileFolderDataSource 18 | @Consume videoListController: VideoListController 19 | @Prop video_item: VideoMetadata 20 | addFileFolderNameDialogController: CustomDialogController = new CustomDialogController({ 21 | builder: AddFileFolderNameDialog(), cornerRadius: 20, shadow: DefaultDialogShadow 22 | }) 23 | 24 | build() { 25 | Menu() { 26 | MenuItem({ 27 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.folder_badge_plus')), 28 | content: '添加文件夹' 29 | }).onClick(() => { 30 | this.addFileFolderNameDialogController.open() 31 | }) 32 | LazyForEach(this.fileFolderSource, (item: FileFolder) => { 33 | if (item.date !== this.videoListController.folder.date) { 34 | MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r("sys.symbol.folder")), content: item.name }) 35 | .onClick(async () => { 36 | //批量移动 37 | let folders: FileFolder[] = [] 38 | if (DataSyncUtil.delMultipleList.length > 0) { 39 | folders = FileFolderUtil.transferVideosToFileFolder(PathUtils.appContext!, DataSyncUtil.delMultipleList, 40 | this.videoListController.folder.date, item.date) 41 | } else { //非批量操作 42 | folders = FileFolderUtil.transferVideosToFileFolder(PathUtils.appContext!, [this.video_item], 43 | this.videoListController.folder.date, item.date) 44 | } 45 | this.fileFolderSource.updateData(folders) 46 | this.videoListController.videoDataSource.updateData(FolderOperator.getFolderByName(this.videoListController.folder.name) 47 | .video_list) 48 | this.videoListController.updateData(this.videoListController.videoDataSource, 49 | this.videoListController.folder) 50 | 51 | VideoUtils.refresh(this.videoListController, this.fileFolderSource, this.videoListController.folder) 52 | this.videoListController.closeMultipleChoose() 53 | }) 54 | } 55 | }, (item: FileFolder) => item.date + item.name + item.video_list.toString()) 56 | }.attributeModifier(new MenuFancyModifier()) 57 | } 58 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/RecentPlayUtil.ets: -------------------------------------------------------------------------------- 1 | import { Context } from '@kit.AbilityKit'; 2 | import { VideoMetadata } from '../interfaces/VideoMetadataInterface'; 3 | import Preferences from '../database/Preferences'; 4 | import { PathUtils } from './PathUtils'; 5 | 6 | // 最近播放类 7 | export default class RecentPlayUtil { 8 | public static deque: VideoMetadata[] = []; 9 | private static readonly MAX_SIZE = 30; 10 | private static videoIdMap: Map = new Map(); 11 | private static initialized = false; 12 | 13 | static addPlayback(context: Context, item: VideoMetadata): void { 14 | if (!RecentPlayUtil.initialized) { 15 | RecentPlayUtil.init(context) 16 | } 17 | if (item?.uri == null) { 18 | return; 19 | } 20 | if (RecentPlayUtil.videoIdMap.has(item.uri)) { 21 | const existingIndex = RecentPlayUtil.videoIdMap.get(item.uri)!; 22 | RecentPlayUtil.deque.splice(existingIndex, 1); 23 | } 24 | RecentPlayUtil.deque.unshift(item); 25 | if (RecentPlayUtil.deque.length > RecentPlayUtil.MAX_SIZE) { 26 | const removed = RecentPlayUtil.deque.pop()!; 27 | RecentPlayUtil.videoIdMap.delete(removed.uri); 28 | } 29 | RecentPlayUtil.updateMapIndicesAfterAdd(); // 增量更新替代全量重建 30 | RecentPlayUtil.persistData(context); 31 | } 32 | 33 | static getRecentPlaybacks(): VideoMetadata[] { 34 | RecentPlayUtil.loadData(PathUtils.appContext!) 35 | return [...RecentPlayUtil.deque]; 36 | } 37 | 38 | public static delData(context: Context, uri: string) { 39 | if (RecentPlayUtil.videoIdMap.size === 0) { // 如果为空,则初始化一下 40 | RecentPlayUtil.init(context) 41 | } 42 | if (RecentPlayUtil.videoIdMap.has(uri)) { 43 | const index = RecentPlayUtil.videoIdMap.get(uri)!; 44 | RecentPlayUtil.deque.splice(index, 1); 45 | RecentPlayUtil.videoIdMap.delete(uri); 46 | // 更新后续元素的索引 47 | for (let i = index; i < RecentPlayUtil.deque.length; i++) { 48 | RecentPlayUtil.videoIdMap.set(RecentPlayUtil.deque[i].uri, i); 49 | } 50 | RecentPlayUtil.persistData(context); 51 | } 52 | } 53 | 54 | private static init(context: Context) { 55 | if (context) { 56 | RecentPlayUtil.loadData(context); 57 | RecentPlayUtil.initialized = true 58 | } 59 | } 60 | 61 | private static updateMapIndices() { 62 | RecentPlayUtil.videoIdMap.clear(); 63 | RecentPlayUtil.deque.forEach((item, index) => { 64 | RecentPlayUtil.videoIdMap.set(item.uri, index); 65 | }); 66 | } 67 | 68 | private static persistData(context: Context) { 69 | Preferences.saveRecentPlay(context, RecentPlayUtil.deque) 70 | } 71 | 72 | // 增量更新索引(替代全量重建) 73 | private static updateMapIndicesAfterAdd() { 74 | // 新插入项索引为0,后续元素索引+1 75 | RecentPlayUtil.videoIdMap.set(RecentPlayUtil.deque[0].uri, 0); 76 | for (let i = 1; i < RecentPlayUtil.deque.length; i++) { 77 | RecentPlayUtil.videoIdMap.set(RecentPlayUtil.deque[i].uri, i); 78 | } 79 | } 80 | 81 | private static loadData(context: Context) { 82 | RecentPlayUtil.deque = Preferences.getRecentPlay(context) 83 | RecentPlayUtil.updateMapIndices(); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /entry/src/main/resources/rawfile/README.md: -------------------------------------------------------------------------------- 1 |

一款运行在 HarmonyOS Next 上的精致、简约的原生视频播放器

2 |

A slick, minimalist video player that runs on HarmonyOS Next

3 | 4 | ## 流心播放器交流群 5 | 6 | QQ群:973792610 7 | 8 | ## 欢迎安装流心播放器 9 | 10 | **流心播放器已经上架华为应用市场** 11 | 12 | - [安装链接](https://appgallery.huawei.com/app/detail?id=com.example.sweetvideo&channelId=SHARE&source=appshare) 13 | 14 | ## 功能排期 15 | 16 | - [ ] FFMpeg播放器选集功能 17 | - [x] 完全的字幕功能支持(ASS、SRT)【已经支持外挂】 18 | - [ ] 视频标签(点击标签即可跳转该视频对应时间) 19 | 20 | ### 远期支持 21 | 22 | - [ ] 播放器移植 23 | - [ ] WebDAV 支持 24 | - [ ] Emby 支持 25 | - [ ] FTP 支持 26 | 27 | ## 简介 28 | 29 | - 一款运行在 HarmonyOS Next 上精致、简约的视频(音乐)原生播放器,使用 ArkTS 语言开发,具有美观的设计和优雅的动画。 30 | - 基于开源项目`流心播放器` https://gitee.com/lqsxy/sweetvideo/tree/master 31 | - 本应用根据原作者授权并基于 MIT 协议二次开发而来。 32 | 33 | ### 流心项目 AI 解读 34 | 35 | - https://deepwiki.com/Yebingiscn/SweetVideo 36 | (英文,可以精准提问) 37 | - https://zread.ai/Yebingiscn/SweetVideo 38 | (中文,只能简单提问(需登录)) 39 | 40 | ## 内置播放器 41 | 42 | | 系统播放器(avplayer) | FFMpeg播放器(ijkplayer) | 红薯播放器(REDPlayer) | 43 | |-------------------------|----------------------|-------------------------| 44 | | 支持格式较少,不支持杜比/DTS 音轨 | 占用高,容易闪退 | 格式支持较少 | 45 | | 支持 HDR/Audio Vivid,流畅省电 | 支持格式丰富,支持rmvb格式 | 流畅播放 4K HDR 杜比 / DTS 视频 | 46 | 47 | ## 支持的视频 / 音乐格式 48 | 49 | | 类型 | 格式列表 | 50 | |------------|-------------------------------------------------------------------------------------------------------| 51 | | 视频容器 | `mp4`, `flv`, `mkv`, `ts`, `mov`, `rmvb`, `wmv`, `avi`, `m4v` | 52 | | 音频编码(音乐格式) | `wav`, `mp3`, `flac`, `m4a`, `ape`, `aac`, `ogg`, `amr`, `aif`, `aiff` , `dts`, `wma`, `dff`, `av3a` | 53 | 54 | ## 支持的字幕格式 55 | 56 | | 类型 | 格式列表 | 57 | |------------|----------------------------| 58 | | 外挂字幕(标准格式) | `srt`, `vtt`, `ass` | 59 | | 内嵌字幕 | mkv 格式为 txt 的字幕,只支持读取第一条字幕 | 60 | | AI 字幕 | 需鸿蒙 6 及以上 | 61 | 62 | ## 特别鸣谢 && 欢迎参与共建及须知 63 | 64 | > 欢迎提交 PR,一起共同建设流心播放器 \ 65 | > 提交 PR 请遵照以往提交格式,如`[fix]`是修复内容 `[update]`是优化内容 `[new]`是新增内容 \ 66 | > 如果提交中有没有解决的地方请注明 67 | 68 | - 流心视频开源项目作者:鱼Salmon 69 | - 图标、头图等素材:科蓝kl 70 | - 记账本 R 作者:漫漫是我宝 71 | - 测试视频提供:萧十一狼 72 | - 折叠屏适配:微车游 73 | - AloePlayer 作者:Aloereed 74 | - 浑天编辑器作者:向着星辰与深渊 ⭐︎ 75 | - kimufly 76 | - Fpark 77 | 78 | ## 流心播放器由以下开源项目或开源组件提供支持 79 | 80 | - 播放器R 81 | - 浑天编辑器 82 | - Seline Bili 83 | - 蓝牙调试器(BluetoothDebugger) 84 | - [流心视频](https://gitee.com/lqsxy/sweetvideo/tree/master) 85 | - [pinyin4js](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fpinyin4js) 86 | - [ohos_ijkplayer](https://gitee.com/openharmony-sig/ohos_ijkplayer/tree/master) 87 | - [REDPlayer](https://github.com/RTE-Dev/REDPlayer) 88 | - [subtitles](https://atomgit.com/wysp2012/ohos_napi/) 89 | - [juniversalchardet](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fjuniversalchardet) 90 | 91 | ## 赞助作者 92 | 93 | 流心播放器是我最重要的一个项目,我希望无论我以后处在什么样的境地下都能坚持维护他,把他变得更好、更强大。 \ 94 | 但是不可避免的我们生活的其他部分依然有波折,所以如果你喜欢流心的话,可以请作者喝杯咖啡或者吃碗泡面~ \ 95 | [爱发电](https://www.ifdian.net/a/SweetVideo) 96 | 97 | ### 赞助感谢 98 | 99 | - CowBoy 100 | - 羽 101 | - 无名 102 | - 爱发电用户_NhwD 103 | - 爱发电用户_PbvN 104 | - 歪比歪比 105 | - 爱发电用户_e83w 106 | - 爱发电用户_15800 107 | - 爱发电用户_6092b -------------------------------------------------------------------------------- /entry/src/main/ets/component/Dialog/VideoDetailDialog.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface'; 2 | import DataSyncUtil from '../../utils/DataSyncUtil'; 3 | import TimeUtil from '../../utils/TimeUtil'; 4 | import ToolsUtil from '../../utils/ToolsUtil'; 5 | import VideoInfoUtil from '../../utils/VideoInfoUtil'; 6 | 7 | // 视频详情弹窗 8 | @CustomDialog 9 | export struct VideoDetailDialog { 10 | controller?: CustomDialogController 11 | private metadata?: VideoMetadata 12 | 13 | aboutToAppear(): void { 14 | const metadata = DataSyncUtil.editingVideo as VideoMetadata 15 | this.metadata = metadata 16 | } 17 | 18 | build() { 19 | Column({ space: 25 }) { 20 | Text('详情') 21 | .fontSize(25) 22 | .fontColor($r('app.color.text_color')) 23 | .fontWeight(FontWeight.Bold) 24 | .margin({ top: 10 }) 25 | List() { 26 | ListItem() { 27 | this.buildDetailItem('视频格式:', String(this.metadata?.format)) 28 | }.width('100%') 29 | 30 | ListItem() { 31 | this.buildDetailItem('视频分辨率:', 32 | VideoInfoUtil.videoWidthAndHeightFormat(this.metadata?.size.toString()!) === '0 x 0' ? 33 | ToolsUtil.getStringResource($r('app.string.unknown_resolution').id) : 34 | VideoInfoUtil.videoWidthAndHeightFormat(this.metadata?.size.toString()!)) 35 | }.width('100%') 36 | 37 | ListItem() { 38 | this.buildDetailItem('视频大小:', String(this.metadata?.video_size)) 39 | }.width('100%') 40 | 41 | ListItem() { 42 | this.buildDetailItem('上次播放时间:', TimeUtil.convertMSToHHMMSS(this.metadata?.last_play)) 43 | }.width('100%') 44 | 45 | ListItem() { 46 | this.buildDetailItem('片头时间:', TimeUtil.convertMSToHHMMSS(this.metadata?.start_time)) 47 | }.width('100%') 48 | 49 | ListItem() { 50 | this.buildDetailItem('片尾时间:', TimeUtil.convertMSToHHMMSS(this.metadata?.end_time)) 51 | }.width('100%') 52 | 53 | ListItem() { 54 | this.buildDetailItem('视频路径:', decodeURI(this.metadata?.uri || '')) 55 | }.width('100%') 56 | 57 | ListItem() { 58 | this.buildDetailItem('外挂字幕:', this.metadata?.external_subtitle_format || '无字幕') 59 | }.width('100%') 60 | }.height('auto').width('auto') 61 | .scrollBar(BarState.Off) 62 | .edgeEffect(EdgeEffect.Spring) // 滚动边缘效果 63 | Button({ type: ButtonType.Normal, stateEffect: true }) { 64 | Text($r('app.string.cancel')) 65 | .fontSize(18) 66 | .fontColor($r('app.color.text_color')) 67 | } 68 | .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.9 }) 69 | .backgroundColor($r('sys.color.button_background_color_transparent')) 70 | .borderRadius(16) 71 | .height(60) 72 | .width(80) 73 | .onClick(() => this.controller?.close()) 74 | .margin({ bottom: 10 }) 75 | }.padding(20) 76 | } 77 | 78 | @Builder 79 | private buildDetailItem(label: string | Resource, value: string | number | undefined) { 80 | Row() { 81 | Text(label) 82 | .fontSize(15) 83 | .fontColor($r('app.color.text_color')) 84 | .flexShrink(0) 85 | .width(85) 86 | .textAlign(TextAlign.Start) 87 | 88 | Text(value?.toString() || '--') 89 | .fontSize(15) 90 | .fontColor($r('app.color.text_color')) 91 | .maxLines(8) 92 | .wordBreak(WordBreak.BREAK_ALL) 93 | .textOverflow({ overflow: TextOverflow.Ellipsis }) 94 | .width('70%') 95 | } 96 | .width('100%') 97 | .justifyContent(FlexAlign.SpaceBetween) 98 | .padding({ top: 8, bottom: 8 }) 99 | } 100 | } -------------------------------------------------------------------------------- /entry/src/main/ets/common/AttributeModifierConfig.ets: -------------------------------------------------------------------------------- 1 | import { CommonModifier } from '@kit.ArkUI'; 2 | import { ShadowFancy } from '../interfaces/ShadowFancyInterface'; 3 | 4 | export class ShadowModifier extends CommonModifier { 5 | constructor() { 6 | super() 7 | } 8 | 9 | applyNormalAttribute(instance: CommonAttribute): void { 10 | instance.shadow({ radius: 26, color: $r('app.color.shadow_color') }) 11 | .backgroundColor($r('app.color.start_window_background_blur')) 12 | .backdropBlur(150) 13 | } 14 | } 15 | 16 | export class SearchShadowModifier extends CommonModifier { 17 | constructor() { 18 | super() 19 | } 20 | 21 | applyNormalAttribute(instance: CommonAttribute): void { 22 | instance.shadow({ radius: 32, color: $r('app.color.shadow_color') }) 23 | .backgroundColor($r('app.color.search_component_blur')) 24 | .backgroundEffect({ radius: 100 }) 25 | .useEffect(true, EffectType.WINDOW_EFFECT) 26 | } 27 | } 28 | 29 | export class ImageFancyModifier implements AttributeModifier { 30 | private borderRadius: Length | BorderRadiuses | LocalizedBorderRadiuses; 31 | private width: number | string; 32 | private height: number | string; 33 | 34 | constructor(borderRadius: Length | BorderRadiuses | LocalizedBorderRadiuses, width: number | string, 35 | height: number | string) { 36 | this.borderRadius = borderRadius; 37 | this.width = width; 38 | this.height = height; 39 | } 40 | 41 | applyNormalAttribute(attr: ImageAttribute): void { 42 | attr 43 | .alt($r("app.media.sweet_video_alt")) 44 | .backgroundImageSize(ImageSize.Auto) 45 | .borderRadius(this.borderRadius) 46 | .width(this.width) 47 | .height(this.height) 48 | .interpolation(ImageInterpolation.Medium)// 用于重采样后的抗锯齿 49 | .draggable(false)// 禁止长按手势拖动 50 | .autoResize(true) // 重采样,可减少内存占用 51 | .opacity(0.98) 52 | .shadow({ 53 | radius: 18, 54 | type: ShadowType.BLUR, 55 | color: 'primary', 56 | fill: true 57 | }) 58 | } 59 | } 60 | 61 | export class SymbolGlyphFancyModifier implements AttributeModifier { 62 | private fontSize: number; 63 | private width: number | string; 64 | private height: number | string; 65 | 66 | constructor(fontSize: number, width: number | string, height: number | string) { 67 | this.fontSize = fontSize; 68 | this.width = width; 69 | this.height = height; 70 | } 71 | 72 | applyNormalAttribute(attr: SymbolGlyphAttribute): void { 73 | attr 74 | .fontSize(this.fontSize) 75 | .fontColor([$r('app.color.text_color')]) 76 | .width(this.width) 77 | .height(this.height) 78 | .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 }) 79 | .effectStrategy(SymbolEffectStrategy.SCALE) 80 | } 81 | } 82 | 83 | export class ButtonFancyModifier implements AttributeModifier { 84 | private width: number | string; 85 | private height: number | string; 86 | 87 | constructor(width: number | string, height: number | string) { 88 | this.width = width; 89 | this.height = height; 90 | } 91 | 92 | applyNormalAttribute(attr: ButtonAttribute): void { 93 | attr 94 | .borderRadius(16) 95 | .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 }) 96 | .width(this.width) 97 | .height(this.height) 98 | .backgroundColor($r('app.color.start_window_background_blur')) 99 | } 100 | } 101 | 102 | export class MenuFancyModifier extends CommonModifier { 103 | constructor() { 104 | super() 105 | } 106 | 107 | applyNormalAttribute(instance: MenuAttribute): void { 108 | instance.font({ size: 15, weight: FontWeight.Normal }) 109 | } 110 | } 111 | 112 | export const DefaultDialogShadow: ShadowFancy = { 113 | radius: 26, 114 | color: $r('app.color.shadow_color'), 115 | offsetX: 0, 116 | offsetY: 0 117 | }; -------------------------------------------------------------------------------- /entry/src/main/ets/pages/SettingPage/PersonalizePage.ets: -------------------------------------------------------------------------------- 1 | import { SettingsClickItem } from '../../component/SettingComponent/SettingsClickItem'; 2 | import SelectFileUtil from '../../utils/SelectFileUtil'; 3 | import ToolsUtil from '../../utils/ToolsUtil'; 4 | import { PathUtils } from '../../utils/PathUtils'; 5 | import { SettingSliderItem } from '../../component/SettingComponent/SettingSliderItem'; 6 | import Preferences from '../../database/Preferences'; 7 | import { SettingsCheckboxItem } from '../../component/SettingComponent/SettingsCheckboxItem'; 8 | import { SafeHeight, Setting } from '../../utils/ObservedUtil'; 9 | 10 | @Component 11 | export struct PersonalizePage { 12 | private backgroundColorDisplayMode: ResourceStr[] = ['自动', '浅色', '深色'] 13 | 14 | build() { 15 | NavDestination() { 16 | List() { 17 | ListItem() { 18 | SettingsCheckboxItem({ 19 | symbol: $r('sys.symbol.sun'), 20 | message: '显示模式', 21 | tips: this.backgroundColorDisplayMode, 22 | tabSelectedIndexes: [Setting.backgroundColorMode], 23 | onButtonClick: (tabSelectedIndexes: number) => { 24 | Setting.backgroundColorMode = tabSelectedIndexes 25 | Preferences.saveBackgroundColorMode(PathUtils.appContext!, tabSelectedIndexes) 26 | ToolsUtil.setColorMode(PathUtils.appContext!, tabSelectedIndexes) 27 | } 28 | }) 29 | } 30 | 31 | ListItem() { 32 | SettingsClickItem({ 33 | symbol: $r('sys.symbol.picture'), 34 | message: '自定义背景', 35 | onPress: async () => { 36 | const imageUris = await SelectFileUtil.selectPhoto() 37 | if (imageUris.length === 0) { 38 | ToolsUtil.showToast('没有照片被选择') 39 | return 40 | } 41 | Setting.backgroundImageSrc = '' 42 | SelectFileUtil.saveImageToPrivacySpace(imageUris[0]) 43 | setTimeout(() => { 44 | Setting.backgroundImageSrc = SelectFileUtil.getImageUri() 45 | ToolsUtil.showToast('设置成功') 46 | }, 100) 47 | } 48 | }) 49 | } 50 | 51 | if (Setting.backgroundImageSrc.length > 0) { 52 | ListItem() { 53 | SettingsClickItem({ 54 | symbol: $r('sys.symbol.picture_damage'), 55 | message: '删除自定义背景', 56 | onPress: async () => { 57 | await SelectFileUtil.deletePhoto() 58 | Setting.backgroundImageSrc = '' 59 | ToolsUtil.showToast('删除成功') 60 | Preferences.saveBackgroundDropBlur(PathUtils.appContext!, 0) 61 | } 62 | }) 63 | } 64 | 65 | ListItem() { 66 | SettingSliderItem({ 67 | symbol: $r('sys.symbol.livephoto'), 68 | message: '背景模糊', 69 | textSliderMode: false, 70 | selected: Setting.backgroundDropBlur, 71 | onChangeComplete: (value: number) => { 72 | Setting.backgroundDropBlur = value 73 | Preferences.saveBackgroundDropBlur(PathUtils.appContext!, Setting.backgroundDropBlur) 74 | } 75 | }) 76 | } 77 | } 78 | } 79 | .align(Alignment.Top) 80 | .borderRadius(16) 81 | .margin({ left: 16, right: 16 }) 82 | .clip(true) 83 | .scrollBar(BarState.Off) 84 | .height('100%') 85 | .width('95%') 86 | .layoutWeight(1) 87 | .contentEndOffset(SafeHeight.bottomSafeHeight) 88 | } 89 | .hideToolBar(true) 90 | .title('个性化') 91 | .backgroundColor($r('app.color.start_window_background')) 92 | .padding({ top: SafeHeight.topSafeHeight }) 93 | .backgroundImage(Setting.backgroundImageSrc, ImageRepeat.NoRepeat) 94 | .backgroundImageSize(ImageSize.Cover) 95 | .backgroundImagePosition(Alignment.Center) 96 | .backdropBlur(Setting.backgroundDropBlur) 97 | } 98 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/AnimationUtil.ets: -------------------------------------------------------------------------------- 1 | import curves from '@ohos.curves' 2 | import Curves from '@ohos.curves' 3 | 4 | /** 5 | * 动画工具类,提供统一的动画效果管理 6 | */ 7 | export class XAnimation { 8 | // 动画模式,默认为自动模式 9 | private static mode: string = 'auto' 10 | 11 | /** 12 | * 设置动画模式 13 | * @param mode 动画模式: 'auto'(自动), 'up'(向上), 'bottom'(向下), 'left'(向左), 'right'(向右) 14 | */ 15 | public static setAnimation(mode: string) { 16 | XAnimation.mode = mode 17 | } 18 | 19 | /** 20 | * 根据方向获取对应的过渡动画效果 21 | * @param dir 动画进入方向: 'left' | 'right' | 'up' | 'bottom' 22 | * @param delay 动画延迟时间(毫秒),默认为0 23 | * @param hasAnimate 是否启用动画,默认为true 24 | * @returns 对应方向的过渡动画效果对象 25 | */ 26 | public static getAnimation(dir: 'left' | 'right' | 'up' | 'bottom', 27 | delay: number = 0, 28 | hasAnimate: boolean = true) { 29 | // 从上方进入的动画效果:透明度渐显 + 向上平移 30 | const MoveFromTop: object = 31 | TransitionEffect.OPACITY.animation(hasAnimate ? { duration: 200, curve: Curve.Ease, delay: delay } : undefined) 32 | .combine(TransitionEffect.translate({ x: 0, y: -100 }) 33 | .animation(hasAnimate ? { curve: Curves.responsiveSpringMotion(0.4, 0.75), delay: delay } : undefined)) 34 | 35 | // 从下方进入的动画效果:透明度渐显 + 向下平移 36 | const MoveFromBottom: object = 37 | TransitionEffect.OPACITY.animation(hasAnimate ? { duration: 200, curve: Curve.Ease, delay: delay } : undefined) 38 | .combine(TransitionEffect.translate({ x: 0, y: 100 }) 39 | .animation(hasAnimate ? { curve: Curves.responsiveSpringMotion(0.4, 0.75), delay: delay } : undefined)) 40 | 41 | // 从起始位置进入的动画效果:透明度渐显 + 向左平移 42 | const MoveFromStart: object = 43 | TransitionEffect.OPACITY.animation(hasAnimate ? { duration: 200, curve: Curve.Ease, delay: delay } : undefined) 44 | .combine(TransitionEffect.translate({ x: -100, y: 0 }) 45 | .animation(hasAnimate ? { curve: Curves.responsiveSpringMotion(0.4, 0.75), delay: delay } : undefined)) 46 | 47 | // 从结束位置进入的动画效果:透明度渐显 + 向右平移 48 | const MoveFromEnd: object = 49 | TransitionEffect.OPACITY.animation(hasAnimate ? { duration: 200, curve: Curve.Ease, delay: delay } : undefined) 50 | .combine(TransitionEffect.translate({ x: 100, y: 0 }) 51 | .animation(hasAnimate ? { curve: Curves.responsiveSpringMotion(0.4, 0.75), delay: delay } : undefined)) 52 | 53 | // 根据当前模式返回相应的动画效果 54 | switch (XAnimation.mode) { 55 | case 'auto': { 56 | // 自动模式:根据传入的方向选择对应动画 57 | switch (dir) { 58 | case 'up': 59 | return MoveFromTop 60 | case 'bottom': 61 | return MoveFromBottom 62 | case 'left': 63 | return MoveFromStart 64 | case 'right': 65 | return MoveFromEnd 66 | } 67 | } 68 | // 固定模式:无论传入什么方向,都返回指定的动画效果 69 | case 'up': 70 | return MoveFromBottom 71 | case 'bottom': 72 | return MoveFromTop 73 | case 'left': 74 | return MoveFromEnd 75 | case 'right': 76 | return MoveFromStart 77 | default: 78 | return MoveFromBottom 79 | } 80 | } 81 | 82 | /** 83 | * 执行带动画效果的函数 84 | * @param fn 需要执行的函数 85 | * @param animate 是否启用动画,默认为true 86 | * @param s1 弹簧动画的第一个参数,默认为0.4 87 | * @param s2 弹簧动画的第二个参数,默认为0.85 88 | */ 89 | public static runWithAnimation( 90 | fn: Function, 91 | animate: boolean = true, 92 | s1: number = 0.4, 93 | s2: number = 0.85): void { 94 | if (animate) { 95 | // 使用弹簧动画执行函数 96 | animateToImmediately({ curve: curves.springMotion(s1, s2) }, () => { 97 | fn() 98 | }) 99 | } else { 100 | // 不使用动画直接执行函数 101 | fn() 102 | } 103 | } 104 | 105 | /** 106 | * 获取动画曲线参数配置 107 | * @param duration 动画持续时间(毫秒),默认为300 108 | * @param s1 弹簧动画的第一个参数,默认为0.4 109 | * @param s2 弹簧动画的第二个参数,默认为0.7 110 | * @returns 动画参数对象 111 | */ 112 | public static getAnimationCurve(duration: number = 300, s1: number = 0.4, s2: number = 0.7): AnimateParam { 113 | return { duration: duration, curve: curves.springMotion(s1, s2) } 114 | } 115 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/FileFolderComponent/FileFolderView.ets: -------------------------------------------------------------------------------- 1 | import { FileFolder } from '../../interfaces/FileFolderInterface' 2 | import { FileFolderDataSource, VideoDataSource } from '../../utils/DataUtil' 3 | import { SideBarController } from '../SideBarComponent/SideBar' 4 | import { VideoListController } from '../VideoItemComponent/VideoItemComponent' 5 | import { SymbolGlyphModifier } from '@kit.ArkUI' 6 | import DataSyncUtil from '../../utils/DataSyncUtil' 7 | import { PathUtils } from '../../utils/PathUtils' 8 | import FileFolderUtil from '../../utils/FileFolderUtil' 9 | import ToolsUtil from '../../utils/ToolsUtil' 10 | import { EditFileFolderNameDialog } from '../../component/Dialog/EditFileFolderNameDialog' 11 | import { FileFolderListBuilder } from './FileFolderListComponent' 12 | import { DeleteType } from '../../common/enum/DeleteType' 13 | import { DefaultDialogShadow, MenuFancyModifier } from '../../common/AttributeModifierConfig' 14 | 15 | @Component 16 | export struct FileFolderView { 17 | @Consume fileFolderSource: FileFolderDataSource 18 | @State fileFolderList: FileFolder[] = this.fileFolderSource.getAllData() 19 | editFileFolderNameDialogController: CustomDialogController = new CustomDialogController({ 20 | builder: EditFileFolderNameDialog({ 21 | confirm: async (file_folder: string | undefined) => { 22 | if (!file_folder || file_folder.trim() == '') { 23 | return 24 | } 25 | await FileFolderUtil.changeFileFolderName(PathUtils.appContext!, 26 | DataSyncUtil.editingFolder!, 27 | file_folder, this.fileFolderSource) 28 | } 29 | }), cornerRadius: 20, shadow: DefaultDialogShadow 30 | }) 31 | @Consume videoListController: VideoListController 32 | @Consume sideBarController: SideBarController 33 | 34 | build() { 35 | List() { 36 | LazyForEach(this.fileFolderSource, (item: FileFolder) => { 37 | ListItem() { 38 | FileFolderListBuilder({ 39 | folder: item, 40 | videoListController: this.videoListController 41 | }) 42 | } 43 | .onClick(() => { 44 | const video_list = item.video_list || [] 45 | this.videoListController.updateData(new VideoDataSource(video_list), item) 46 | this.sideBarController.close(true) 47 | }) 48 | .bindContextMenu(this.FileFolderSelectMenu(item), ResponseType.LongPress) 49 | .bindContextMenu(this.FileFolderSelectMenu(item), ResponseType.RightClick) 50 | .height(60) 51 | }, (item: FileFolder) => item.date) 52 | } 53 | .borderRadius(16) 54 | .width('100%') 55 | .height('auto') 56 | .align(Alignment.Start) 57 | .margin({ top: 10 }) 58 | .backgroundColor($r('app.color.start_window_background_blur')) 59 | .cachedCount(2) 60 | } 61 | 62 | @Builder 63 | FileFolderSelectMenu(file_folder: FileFolder) { 64 | Menu() { 65 | MenuItem({ 66 | symbolStartIcon: new SymbolGlyphModifier($r("sys.symbol.trash_fill")), 67 | content: $r('app.string.delete') 68 | }) 69 | .onClick(async () => { 70 | DataSyncUtil.delMultipleList = Array.from(file_folder.video_list); 71 | const deletePromises = DataSyncUtil.delMultipleList.map(item => { 72 | return new Promise(() => { 73 | this.videoListController.videoDataSource.deleteItem(DeleteType.SOFT, item) 74 | this.videoListController.closeMultipleChoose() 75 | }); 76 | }); 77 | await Promise.all(deletePromises) 78 | this.fileFolderSource.deleteData(this.fileFolderList.findIndex(i => i.date === file_folder.date)) 79 | this.fileFolderList = FileFolderUtil.deleteFileFolder(PathUtils.appContext!, file_folder) 80 | this.videoListController.videoDataSource.refreshData(this.videoListController.folder) 81 | }) 82 | MenuItem({ 83 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.rename')), 84 | content: $r('app.string.edit') 85 | }).onClick(() => { 86 | DataSyncUtil.editingFolder = file_folder 87 | this.editFileFolderNameDialogController.open() 88 | }) 89 | }.attributeModifier(new MenuFancyModifier).onAppear(() => { 90 | ToolsUtil.startVibration() 91 | }) 92 | } 93 | } -------------------------------------------------------------------------------- /entry/src/main/resources/zh_CN/element/string.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": [ 3 | { 4 | "name": "module_desc", 5 | "value": "模块描述" 6 | }, 7 | { 8 | "name": "EntryAbility_desc", 9 | "value": "description" 10 | }, 11 | { 12 | "name": "EntryAbility_label", 13 | "value": "流心播放器" 14 | }, 15 | { 16 | "name": "sure", 17 | "value": "确定" 18 | }, 19 | { 20 | "name": "cancel", 21 | "value": "取消" 22 | }, 23 | { 24 | "name": "rename", 25 | "value": "重命名" 26 | }, 27 | { 28 | "name": "search", 29 | "value": "搜索" 30 | }, 31 | { 32 | "name": "setting", 33 | "value": "设置" 34 | }, 35 | { 36 | "name": "set_passwd", 37 | "value": "设置隐私空间密码 (/▽\)" 38 | }, 39 | { 40 | "name": "enter_privacy_space", 41 | "value": "已进入隐私空间" 42 | }, 43 | { 44 | "name": "set_passwd_tip", 45 | "value": "密码已更改!首页双指向不同方向滑动或在搜索框输入密码即可进入隐私空间" 46 | }, 47 | { 48 | "name": "nothing", 49 | "value": "还没有视频捏 (~ ̄▽ ̄)~" 50 | }, 51 | { 52 | "name": "add_video_first", 53 | "value": "请先添加视频 ;)" 54 | }, 55 | { 56 | "name": "close_rotation_lock", 57 | "value": "旋转锁定已关闭" 58 | }, 59 | { 60 | "name": "open_rotation_lock", 61 | "value": "旋转锁定已开启" 62 | }, 63 | { 64 | "name": "no_search_result", 65 | "value": "无搜索结果 ╯︿╰" 66 | }, 67 | { 68 | "name": "unknown_size", 69 | "value": "未知大小" 70 | }, 71 | { 72 | "name": "soft_del_confirm", 73 | "value": "删除视频的快捷方式(不影响源文件),确认删除?" 74 | }, 75 | { 76 | "name": "hard_del_confirm", 77 | "value": "所选文件将从设备彻底删除,确认删除?(只支持有权限的文件)" 78 | }, 79 | { 80 | "name": "recent_del_confirm", 81 | "value": "移动到文件管理的最近删除(只支持有权限的文件)" 82 | }, 83 | { 84 | "name": "edit", 85 | "value": "重命名" 86 | }, 87 | { 88 | "name": "delete", 89 | "value": "删除" 90 | }, 91 | { 92 | "name": "import_from_files", 93 | "value": "从文件管理器导入" 94 | }, 95 | { 96 | "name": "import_from_album", 97 | "value": "从相册导入" 98 | }, 99 | { 100 | "name": "open_side_bar_tip", 101 | "value": "右滑屏幕来添加视频吧!ヾ(•ω•`)o" 102 | }, 103 | { 104 | "name": "unknown_resolution", 105 | "value": "未知分辨率" 106 | }, 107 | { 108 | "name": "video_error", 109 | "value": "尝试使用其他播放器播放>﹏<" 110 | }, 111 | { 112 | "name": "add_time_info", 113 | "value": "视频已经导入进来了!导入视频数:" 114 | }, 115 | { 116 | "name": "privacy_space", 117 | "value": "隐私空间" 118 | }, 119 | { 120 | "name": "search_placeholder", 121 | "value": "搜索标题..." 122 | }, 123 | { 124 | "name": "sort_by_name", 125 | "value": "按名称" 126 | }, 127 | { 128 | "name": "sort_by_time", 129 | "value": "按视频时长" 130 | }, 131 | { 132 | "name": "multiple_choice", 133 | "value": "多选" 134 | }, 135 | { 136 | "name": "select_all", 137 | "value": "全选" 138 | }, 139 | { 140 | "name": "delete_selected", 141 | "value": "删除所选" 142 | }, 143 | { 144 | "name": "FFMpeg_Player", 145 | "value": "FFMpeg 播放器" 146 | }, 147 | { 148 | "name": "re_import_info", 149 | "value": "个视频已失效(源视频可能已经被移动或者改名),请重新导入" 150 | }, 151 | { 152 | "name": "exit_privacy_space", 153 | "value": "退出隐私空间" 154 | }, 155 | { 156 | "name": "privacy_space_info", 157 | "value": "这里导入视频会被复制到应用私有目录下" 158 | }, 159 | { 160 | "name": "FILE_ACCESS_PERSIST_REASON", 161 | "value": "保存用户选择的文件,避免用户重复操作" 162 | }, 163 | { 164 | "name": "KEEP_BACKGROUND_RUNNING_REASON", 165 | "value": "用户退出到后台时继续播放音频" 166 | }, 167 | { 168 | "name": "VIBRATE_REASON", 169 | "value": "增加触感反馈,提升用户体验" 170 | }, 171 | { 172 | "name": "BIOMETRIC_ACCESS_REASON", 173 | "value": "用于隐私空间验证" 174 | }, 175 | { 176 | "name": "move_to_folder", 177 | "value": "移动到文件夹" 178 | }, 179 | { 180 | "name": "READ_WRITE_DOWNLOAD_DIRECTORY_REASON", 181 | "value": "读取下载文件夹中的视频" 182 | }, 183 | { 184 | "name": "sort_by_datetime", 185 | "value": "按添加时间" 186 | } 187 | ] 188 | } -------------------------------------------------------------------------------- /entry/src/main/module.json5: -------------------------------------------------------------------------------- 1 | { 2 | "module": { 3 | "name": "entry", 4 | "type": "entry", 5 | "description": "$string:module_desc", 6 | "mainElement": "EntryAbility", 7 | "deviceTypes": [ 8 | "phone", 9 | "tablet", 10 | "2in1" 11 | ], 12 | "deliveryWithInstall": true, 13 | "installationFree": false, 14 | "pages": "$profile:main_pages", 15 | "compressNativeLibs": true, 16 | // 压缩so包。减少包体积 17 | "abilities": [ 18 | { 19 | "name": "EntryAbility", 20 | "srcEntry": "./ets/entryability/EntryAbility.ets", 21 | "description": "$string:EntryAbility_desc", 22 | "icon": "$media:layered_image", 23 | "label": "$string:EntryAbility_label", 24 | "startWindowIcon": "$media:foreground_start_up", 25 | "startWindowBackground": "$color:start_window_background", 26 | "exported": true, 27 | "orientation": "auto_rotation_restricted", 28 | "supportWindowMode": [ 29 | "floating", 30 | "fullscreen", 31 | "split" 32 | ], 33 | "preferMultiWindowOrientation": "landscape_auto", 34 | "backgroundModes": [ 35 | "audioPlayback" 36 | ], 37 | // 随传感器旋转 38 | "skills": [ 39 | { 40 | "entities": [ 41 | "entity.system.home", 42 | "entity.system.browsable" 43 | ], 44 | "actions": [ 45 | "action.system.home", 46 | "ohos.want.action.sendData" 47 | ], 48 | "uris": [ 49 | { 50 | "scheme": "file", 51 | "type": "video/*", 52 | "linkFeature": "FileOpen", 53 | "maxFileSupported": 1 54 | }, 55 | { 56 | "scheme": "file", 57 | "type": "audio/*", 58 | "linkFeature": "FileOpen", 59 | "maxFileSupported": 1 60 | }, 61 | { 62 | "scheme": "file", 63 | "utd": "general.audio", 64 | "linkFeature": "FileOpen", 65 | "maxFileSupported": 1 66 | }, 67 | { 68 | "scheme": "https", 69 | "host": "https://github.com/Yebingiscn/SweetVideo" 70 | }, 71 | { 72 | "scheme": "https", 73 | "host": "https://github.com/Yebingiscn/SweetVideo/wiki/%E6%B5%81%E5%BF%83%E8%A7%86%E9%A2%91%E7%9A%84%E4%BD%BF%E7%94%A8%E6%9D%A1%E6%AC%BE%E4%B8%8E%E9%9A%90%E7%A7%81%E5%A3%B0%E6%98%8E" 74 | } 75 | ], 76 | "domainVerify": true 77 | } 78 | ] 79 | } 80 | ], 81 | "extensionAbilities": [ 82 | { 83 | "name": "EntryBackupAbility", 84 | "srcEntry": "./ets/entrybackupability/EntryBackupAbility.ets", 85 | "type": "backup", 86 | "exported": false, 87 | "metadata": [ 88 | { 89 | "name": "ohos.extension.backup", 90 | "resource": "$profile:backup_config" 91 | } 92 | ] 93 | } 94 | ], 95 | "requestPermissions": [ 96 | { 97 | "name": "ohos.permission.FILE_ACCESS_PERSIST", 98 | "reason": "$string:FILE_ACCESS_PERSIST_REASON", 99 | "usedScene": { 100 | "abilities": [ 101 | "EntryAbility" 102 | ], 103 | "when": "always" 104 | } 105 | }, 106 | { 107 | "name": "ohos.permission.KEEP_BACKGROUND_RUNNING", 108 | "reason": "$string:KEEP_BACKGROUND_RUNNING_REASON", 109 | "usedScene": { 110 | "abilities": [ 111 | "EntryAbility" 112 | ], 113 | "when": "always" 114 | } 115 | }, 116 | { 117 | "name": "ohos.permission.VIBRATE", 118 | "reason": "$string:VIBRATE_REASON", 119 | "usedScene": { 120 | "abilities": [ 121 | "EntryAbility" 122 | ], 123 | "when": "always" 124 | } 125 | }, 126 | { 127 | "name": "ohos.permission.ACCESS_BIOMETRIC", 128 | "reason": "$string:BIOMETRIC_ACCESS_REASON", 129 | "usedScene": { 130 | "abilities": [ 131 | "EntryAbility" 132 | ], 133 | "when": "always" 134 | } 135 | } 136 | ] 137 | } 138 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/BaseEventUtil.ets: -------------------------------------------------------------------------------- 1 | /** 2 | * 事件回调函数类型定义 3 | */ 4 | type EventCallback = (data: T) => void; 5 | 6 | /** 7 | * 事件监听器基础接口 8 | */ 9 | interface BaseEventListener { 10 | id: string; 11 | once: boolean; 12 | callback: EventCallback; 13 | } 14 | 15 | export class BaseListener { 16 | private dataEvents: Map[]> = new Map(); 17 | 18 | /** 19 | * @param eventName 事件名称 20 | * @param callback 回调函数 21 | * @param once 是否只执行一次,默认false 22 | * @returns 取消订阅的函数 23 | */ 24 | on(eventName: string, callback: EventCallback, once: boolean = false): () => void { 25 | const listenerId = this.generateId(); 26 | const listener: BaseEventListener = { 27 | id: listenerId, 28 | callback, 29 | once 30 | }; 31 | 32 | if (!this.dataEvents.has(eventName)) { 33 | this.dataEvents.set(eventName, []); 34 | } 35 | 36 | const listeners = this.dataEvents.get(eventName); 37 | if (listeners) { 38 | listeners.push(listener); 39 | } 40 | 41 | return () => this.off(eventName, listenerId); 42 | } 43 | 44 | /** 45 | * 订阅带数据的事件(只执行一次) 46 | * @param eventName 事件名称 47 | * @param callback 回调函数 48 | * @returns 取消订阅的函数 49 | */ 50 | once(eventName: string, callback: EventCallback): () => void { 51 | return this.on(eventName, callback, true); 52 | } 53 | 54 | off(eventName: string, listenerId?: string): void { 55 | // 处理数据事件 56 | const dataListeners = this.dataEvents.get(eventName); 57 | if (dataListeners) { 58 | if (listenerId) { 59 | const index = dataListeners.findIndex(listener => listener.id === listenerId); 60 | if (index !== -1) { 61 | dataListeners.splice(index, 1); 62 | if (dataListeners.length === 0) { 63 | this.dataEvents.delete(eventName); 64 | } 65 | return; 66 | } 67 | } else { 68 | this.dataEvents.delete(eventName); 69 | } 70 | } 71 | } 72 | 73 | /** 74 | * 发布事件 75 | * @param eventName 事件名称 76 | * @param data 事件数据 77 | */ 78 | emit(eventName: string, data: T): void { 79 | const listeners = this.dataEvents.get(eventName); 80 | if (!listeners || listeners.length === 0) { 81 | return; 82 | } 83 | 84 | const listenersToExecute = [...listeners]; 85 | 86 | listenersToExecute.forEach(listener => { 87 | try { 88 | (listener.callback as EventCallback)(data); 89 | if (listener.once) { 90 | this.off(eventName, listener.id); 91 | } 92 | } catch (error) { 93 | console.error(`事件回调执行错误 [${eventName}]:`, error); 94 | } 95 | }); 96 | } 97 | 98 | /** 99 | * 检查是否有事件监听器 100 | * @param eventName 事件名称 101 | * @returns 是否存在监听器 102 | */ 103 | hasListeners(eventName: string): boolean { 104 | const dataListeners = this.dataEvents.get(eventName); 105 | // const voidListeners = this.voidEvents.get(eventName); 106 | 107 | const hasDataListeners = dataListeners ? dataListeners.length > 0 : false; 108 | // const hasVoidListeners = voidListeners ? voidListeners.length > 0 : false; 109 | 110 | return hasDataListeners 111 | } 112 | 113 | /** 114 | * 获取事件监听器数量 115 | * @param eventName 事件名称(可选,不传则返回所有事件的监听器总数) 116 | * @returns 监听器数量 117 | */ 118 | getListenerCount(eventName?: string): number { 119 | if (eventName) { 120 | const dataCount = this.dataEvents.get(eventName)?.length || 0; 121 | return dataCount; 122 | } 123 | let total = 0; 124 | this.dataEvents.forEach(listeners => total += listeners.length); 125 | return total; 126 | } 127 | 128 | /** 129 | * 获取所有事件名称 130 | * @returns 事件名称数组 131 | */ 132 | getEventNames(): string[] { 133 | const dataEventNames = Array.from(this.dataEvents.keys()); 134 | return dataEventNames 135 | } 136 | 137 | /** 138 | * 清除所有事件监听器 139 | */ 140 | clear(): void { 141 | this.dataEvents.clear(); 142 | } 143 | 144 | /** 145 | * 清除指定事件的所有监听器 146 | * @param eventName 事件名称 147 | */ 148 | clearEvent(eventName: string): void { 149 | this.dataEvents.delete(eventName); 150 | // this.voidEvents.delete(eventName); 151 | } 152 | 153 | /** 154 | * 生成唯一ID 155 | * @returns 唯一ID字符串 156 | */ 157 | private generateId(): string { 158 | return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 159 | } 160 | } -------------------------------------------------------------------------------- /entry/src/main/ets/pages/PrivacyInfo.ets: -------------------------------------------------------------------------------- 1 | import WantProcessUtil from '../utils/WantProcessUtil'; 2 | import ToolsUtil from '../utils/ToolsUtil'; 3 | import { PathUtils } from '../utils/PathUtils'; 4 | 5 | @CustomDialog 6 | @Component 7 | export struct PrivacyPolicyDialog { 8 | controller?: CustomDialogController 9 | @State isPresent: boolean = false; 10 | cancel: () => void = () => { 11 | }; // 初始化为空函数 12 | confirm: () => void = () => { 13 | }; // 初始化为空函数 14 | 15 | @Builder 16 | privacyInfoBuilder() { 17 | Column() { 18 | Scroll() { 19 | Text() { 20 | Span(`《${ToolsUtil.getStringResource($r('app.string.EntryAbility_label').id)}的使用条款与隐私声明》`) 21 | .fontColor($r('app.color.main_color')) 22 | .onClick(() => { 23 | this.openWebUrl(); 24 | }) 25 | Span('\n\n' + 26 | '流心播放器(以下简称“本软件”)是由 郴州液态科技有限公司 (以下简称“我们”)开发,供您(以下称“用户”)免费使用的本地音视频播放软件。\n' + 27 | '\n' + 28 | '一、服务与内容\n' + 29 | '\n' + 30 | '本软件为本地的单机应用程序,不依赖于任何在线服务,亦不会通过网络提供功能支持。\n' + 31 | '本软件不提供任何形式的音视频文件,包括但不限于视频、视频资源或流媒体服务。\n' + 32 | '用户需自行准备视频文件,并确保所使用的视频文件的合法性和合规性,我们对用户导入的视频内容不承担任何责任。\n\n' + 33 | '二、隐私政策\n' + 34 | '\n' + 35 | '为了确保用户体验,用户使用所产生的部分数据会存储于本应用的沙箱中。\n' + 36 | '本软件尊重用户的隐私,不会收集、存储或上传任何形式的用户数据。\n' + 37 | '本软件的所有功能均在本地设备上运行,用户的数据完全由用户自行管理。\n' + 38 | '如用户主动分享数据或将数据导出至其他平台,相关责任由用户自行承担。\n\n' + 39 | '三、用户的权利\n' + 40 | '\n' + 41 | '用户有权根据本条款的约定,合法安装、使用本软件,并享受本软件提供的所有功能。 用户有权接收并安装本软件的更新和升级版本,前提是这些更新和升级版本依然遵守本使用条款。\n 用户有权自行管理、删除其在本软件中的所有本地数据(如视频文件、播放记录等)。\n 用户有权向本软件的开发者提供反馈、建议,我们将酌情考虑。请注意,如果这样做,将会授予我们在本软件中无偿使用和加入用户建议的权力。\n' + 42 | '\n' + 43 | '四、著作权及使用限制\n' + 44 | '\n' + 45 | '本软件及其相关文档(包括但不限于界面设计、功能逻辑、源代码及其他内容)的著作权归我们所有,受中华人民共和国的法律保护。\n\n' + 46 | '五、打赏服务\n' + 47 | '\n' + 48 | '本软件所有基础功能完全免费,但设有打赏功能,允许用户向我们自愿提供经济支持。\n' + 49 | '一旦打赏完成,我们将不提供退款服务。\n' + 50 | '用户在使用打赏功能时,应遵循合法、合规的支付行为,我们对打赏行为的合法性不承担任何责任。\n\n' + 51 | '六、服务内容变更\n' + 52 | '\n' + 53 | '我们保留随时变更、中断或终止服务而无需另外通知的权力。\n' + 54 | '用户接受我们行使变更、中断或终止服务的权力,我们无需对用户或第三方负任何责任\n\n' + 55 | '生效日期:2025 年 10月 24日') 56 | .fontSize(12) 57 | .fontColor($r('app.color.text_color')) 58 | } 59 | .fontSize(15) 60 | .fontColor($r('app.color.text_color')) 61 | }.scrollBar(BarState.Off).width('100%').height('85%').padding({ left: 15, right: 15 }) 62 | 63 | Row() { 64 | Button('拒绝并退出') 65 | .onClick(() => { 66 | this.cancel(); 67 | }) 68 | .fontColor(Color.Red) 69 | .fontSize(15) 70 | .backgroundColor(Color.Transparent) 71 | .align(Alignment.Center) 72 | .width('40%') 73 | 74 | Button('同意并继续') 75 | .onClick(() => { 76 | ToolsUtil.startVibration() 77 | this.confirm(); 78 | this.isPresent = false; // 直接关闭弹窗 79 | }) 80 | .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 }) 81 | .fontColor($r('sys.color.white')) 82 | .fontSize(17) 83 | .linearGradient({ 84 | direction: GradientDirection.Right, 85 | colors: [[$r('app.color.main_color'), 0.0], ['#ff48cdf6', 1.0]] 86 | }) 87 | .width('40%') 88 | .borderRadius(16) 89 | }.width('100%') 90 | .height(80) 91 | .justifyContent(FlexAlign.SpaceEvenly) 92 | } 93 | .width('100%') 94 | .margin({ top: 10 }).height('100%') 95 | } 96 | 97 | build() { 98 | Stack() { 99 | }.bindSheet($$this.isPresent, this.privacyInfoBuilder(), { 100 | height: SheetSize.FIT_CONTENT, 101 | preferType: SheetType.CENTER, 102 | enableOutsideInteractive: false, 103 | showClose: false, 104 | title: { title: '用户协议和隐私政策' }, 105 | onWillDismiss: ((DismissSheetAction: DismissSheetAction) => { 106 | if (DismissSheetAction.reason == DismissReason.SLIDE_DOWN || 107 | DismissSheetAction.reason == DismissReason.PRESS_BACK || 108 | DismissSheetAction.reason == DismissReason.TOUCH_OUTSIDE) { 109 | PathUtils.appContext?.terminateSelf()?.catch(() => { 110 | console.error('exit app error') 111 | }) 112 | } 113 | }), 114 | }) 115 | .onAppear(() => { 116 | console.error('test4') 117 | this.isPresent = !this.isPresent; 118 | }) 119 | } 120 | 121 | openWebUrl() { 122 | let link: string = WantProcessUtil.PRIVACY_LINK 123 | WantProcessUtil.linkToWeb(PathUtils.appContext!, link) 124 | } 125 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/VideoItemComponent/PrivacyPolicyComponent.ets: -------------------------------------------------------------------------------- 1 | import Preferences from '../../database/Preferences'; 2 | import { OOBEVersion } from '../../utils/DataSyncUtil'; 3 | import { SafeHeight } from '../../utils/ObservedUtil'; 4 | import { PathUtils } from '../../utils/PathUtils'; 5 | import ToolsUtil from '../../utils/ToolsUtil'; 6 | import WantProcessUtil from '../../utils/WantProcessUtil'; 7 | 8 | @Component 9 | export struct PrivacyPolicyComponent { 10 | @Link isPresent: boolean 11 | 12 | @Builder 13 | privacyInfoBuilder() { 14 | Column() { 15 | Scroll() { 16 | Text() { 17 | Span(`《${ToolsUtil.getStringResource($r('app.string.EntryAbility_label').id)}的使用条款与隐私声明》`) 18 | .fontColor($r('app.color.main_color')) 19 | .onClick(() => { 20 | this.openWebUrl(); 21 | }) 22 | Span('\n\n' + 23 | '流心播放器(以下简称“本软件”)是由 郴州液态科技有限公司 (以下简称“我们”)开发,供您(以下称“用户”)免费使用的本地音视频播放软件。\n' + 24 | '\n' + 25 | '一、服务与内容\n' + 26 | '\n' + 27 | '本软件为本地的单机应用程序,不依赖于任何在线服务,亦不会通过网络提供功能支持。\n' + 28 | '本软件不提供任何形式的音视频文件,包括但不限于视频、视频资源或流媒体服务。\n' + 29 | '用户需自行准备视频文件,并确保所使用的视频文件的合法性和合规性,我们对用户导入的视频内容不承担任何责任。\n\n' + 30 | '二、隐私政策\n' + 31 | '\n' + 32 | '为了确保用户体验,用户使用所产生的部分数据会存储于本应用的沙箱中。\n' + 33 | '本软件尊重用户的隐私,不会收集、存储或上传任何形式的用户数据。\n' + 34 | '本软件的所有功能均在本地设备上运行,用户的数据完全由用户自行管理。\n' + 35 | '如用户主动分享数据或将数据导出至其他平台,相关责任由用户自行承担。\n\n' + 36 | '三、用户的权利\n' + 37 | '\n' + 38 | '用户有权根据本条款的约定,合法安装、使用本软件,并享受本软件提供的所有功能。 用户有权接收并安装本软件的更新和升级版本,前提是这些更新和升级版本依然遵守本使用条款。\n 用户有权自行管理、删除其在本软件中的所有本地数据(如视频文件、播放记录等)。\n 用户有权向本软件的开发者提供反馈、建议,我们将酌情考虑。请注意,如果这样做,将会授予我们在本软件中无偿使用和加入用户建议的权力。\n' + 39 | '\n' + 40 | '四、著作权及使用限制\n' + 41 | '\n' + 42 | '本软件及其相关文档(包括但不限于界面设计、功能逻辑、源代码及其他内容)的著作权归我们所有,受中华人民共和国的法律保护。\n\n' + 43 | '五、打赏服务\n' + 44 | '\n' + 45 | '本软件所有基础功能完全免费,但设有打赏功能,允许用户向我们自愿提供经济支持。\n' + 46 | '一旦打赏完成,我们将不提供退款服务。\n' + 47 | '用户在使用打赏功能时,应遵循合法、合规的支付行为,我们对打赏行为的合法性不承担任何责任。\n\n' + 48 | '六、服务内容变更\n' + 49 | '\n' + 50 | '我们保留随时变更、中断或终止服务而无需另外通知的权力。\n' + 51 | '用户接受我们行使变更、中断或终止服务的权力,我们无需对用户或第三方负任何责任\n\n' + 52 | '生效日期:2025 年 10月 24日') 53 | .fontSize(12) 54 | .fontColor($r('app.color.text_color')) 55 | } 56 | .fontSize(15) 57 | .fontColor($r('app.color.text_color')) 58 | }.scrollBar(BarState.Off).width('100%').height('85%').padding({ left: 15, right: 15 }) 59 | 60 | Row() { 61 | Button('拒绝并退出') 62 | .onClick(() => { 63 | PathUtils.appContext!.terminateSelf()?.catch((error: Error) => { 64 | ToolsUtil.showToast('关闭弹窗失败' + error) 65 | }) 66 | }) 67 | .fontColor(Color.Red) 68 | .fontSize(15) 69 | .backgroundColor(Color.Transparent) 70 | .align(Alignment.Center) 71 | .width('40%') 72 | 73 | Button('同意并继续') 74 | .onClick(() => { 75 | ToolsUtil.startVibration() 76 | Preferences.saveOOBEVersion(PathUtils.appContext!, OOBEVersion) 77 | this.isPresent = false; // 直接关闭弹窗 78 | }) 79 | .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 }) 80 | .fontColor($r('sys.color.white')) 81 | .fontSize(17) 82 | .linearGradient({ 83 | direction: GradientDirection.Right, 84 | colors: [[$r('app.color.main_color'), 0.0], ['#ff48cdf6', 1.0]] 85 | }) 86 | .width('40%') 87 | .borderRadius(16) 88 | }.width('100%') 89 | .height(60) 90 | .justifyContent(FlexAlign.SpaceEvenly) 91 | } 92 | .width('100%') 93 | .height('100%').margin({ bottom: SafeHeight.bottomSafeHeight }) 94 | } 95 | 96 | build() { 97 | Stack() { 98 | }.bindSheet($$this.isPresent, this.privacyInfoBuilder, { 99 | detents: [SheetSize.FIT_CONTENT], 100 | height: SheetSize.LARGE, 101 | preferType: SheetType.CENTER, 102 | enableOutsideInteractive: false, 103 | showClose: false, 104 | dragBar: false, 105 | title: { title: '用户协议和隐私政策' }, 106 | onWillDismiss: ((DismissSheetAction: DismissSheetAction) => { 107 | if (DismissSheetAction.reason == DismissReason.SLIDE_DOWN || 108 | DismissSheetAction.reason == DismissReason.PRESS_BACK || 109 | DismissSheetAction.reason == DismissReason.TOUCH_OUTSIDE) { 110 | PathUtils.appContext?.terminateSelf()?.catch(() => { 111 | console.error('exit app error') 112 | }) 113 | } 114 | }), 115 | }) 116 | } 117 | 118 | openWebUrl() { 119 | let link: string = WantProcessUtil.PRIVACY_LINK 120 | WantProcessUtil.linkToWeb(PathUtils.appContext!, link) 121 | } 122 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/GestureComponent.ets: -------------------------------------------------------------------------------- 1 | @Component 2 | export struct GestureComponent { 3 | @Link showControl: boolean 4 | @Link isLock: boolean 5 | onDoubleClickAction = (_event: GestureEvent) => { 6 | }; 7 | onSingleClickAction = () => { 8 | } 9 | onVerticalPanStart = (_event: GestureEvent) => { 10 | } 11 | onVerticalPanUpdate = (_event: GestureEvent) => { 12 | } 13 | onVerticalPanEnd = () => { 14 | } 15 | onHorizonPanStart = (_event: GestureEvent) => { 16 | } 17 | onHorizonPanUpdate = (_event: GestureEvent) => { 18 | } 19 | onHorizonPanEnd = () => { 20 | } 21 | onPinchStart = (_event: GestureEvent) => { 22 | } 23 | onPinchUpdate = (_event: GestureEvent) => { 24 | } 25 | onLongPressAction = () => { 26 | } 27 | onLongPressEnd = () => { 28 | } 29 | onLongPressPanStart = (_event: GestureEvent) => { 30 | } 31 | onLongPressPanUpdate = (_event: GestureEvent) => { 32 | } 33 | 34 | build() { 35 | Column() 36 | .width('100%') 37 | .height(this.showControl ? '50%' : '80%')//触控区域 38 | .gesture( 39 | GestureGroup(GestureMode.Exclusive, // 将触摸事件跟滑动、长按分成两个手势组解绑互斥,触摸事件手势组为互斥事件 40 | GestureGroup(GestureMode.Exclusive, //双击事件组和单击事件组互斥独立 41 | TapGesture({ count: 2 })// 双击事件 42 | .onAction((event: GestureEvent) => { 43 | if (this.isLock) { 44 | return 45 | } 46 | this.onDoubleClickAction(event) 47 | }), 48 | TapGesture({ count: 1 })// 单击事件 49 | .onAction(() => { //单击屏幕 50 | this.onSingleClickAction() 51 | }), 52 | PanGesture({ fingers: 1, direction: PanDirection.Vertical })// 垂直滑动 53 | .allowedTypes([SourceTool.Unknown, SourceTool.Finger, SourceTool.MOUSE, SourceTool.TOUCHPAD, 54 | SourceTool.JOYSTICK]) 55 | .onActionStart((event: GestureEvent) => { // 滑动屏幕 56 | if (this.isLock) { 57 | return 58 | } 59 | this.onVerticalPanStart(event) 60 | }) 61 | .onActionUpdate((event: GestureEvent) => { 62 | if (this.isLock) { 63 | return 64 | } 65 | this.onVerticalPanUpdate(event) 66 | }) 67 | .onActionEnd(() => { 68 | if (this.isLock) { 69 | return 70 | } 71 | this.onVerticalPanEnd() 72 | }), 73 | PanGesture({ fingers: 1, direction: PanDirection.Horizontal })// 水平滑动事件滑动调整进度 74 | .onActionStart((event: GestureEvent) => { // 长按且滑动屏幕 75 | if (this.isLock) { 76 | return 77 | } 78 | this.onHorizonPanStart(event) 79 | }) 80 | .onActionUpdate((event: GestureEvent) => { 81 | if (this.isLock) { 82 | return 83 | } 84 | this.onHorizonPanUpdate(event) 85 | }) 86 | .onActionEnd(() => { 87 | if (this.isLock) { 88 | return 89 | } 90 | this.onHorizonPanEnd() 91 | }), 92 | PinchGesture({ fingers: 2, distance: 2 })//手势缩放 93 | .onActionStart((event: GestureEvent) => { 94 | if (this.isLock) { 95 | return 96 | } 97 | this.onPinchStart(event) 98 | }) 99 | .onActionUpdate((event: GestureEvent) => { 100 | if (this.isLock) { 101 | return 102 | } 103 | this.onPinchUpdate(event) 104 | }) 105 | ), 106 | GestureGroup(GestureMode.Parallel, // 不用互斥事件,会导致左右滑动调节倍速的功能失效 107 | LongPressGesture()// 长按手势 108 | .onAction(() => { //长按 109 | if (this.isLock) { 110 | return 111 | } 112 | this.onLongPressAction() 113 | }) 114 | .onActionEnd(() => { 115 | if (this.isLock) { 116 | return 117 | } 118 | this.onLongPressEnd() 119 | }), 120 | PanGesture({ 121 | fingers: 1, 122 | direction: PanDirection.Horizontal, 123 | })//水平滑动事件 长按加速滑动调整倍速 124 | .onActionStart((event: GestureEvent) => { // 长按且滑动屏幕 125 | if (this.isLock) { 126 | return 127 | } 128 | this.onLongPressPanStart(event) 129 | }) 130 | .onActionUpdate((event: GestureEvent) => { 131 | if (this.isLock) { 132 | return 133 | } 134 | this.onLongPressPanUpdate(event) 135 | }) 136 | ))) 137 | } 138 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/VideoTopComponent.ets: -------------------------------------------------------------------------------- 1 | import { ButtonFancyModifier, SymbolGlyphFancyModifier } from '../../common/AttributeModifierConfig' 2 | import { SubtitleMode } from '../../common/enum/SubtitleMode' 3 | import VideoInfoUtil from '../../utils/VideoInfoUtil' 4 | import VideoOperateUtil from '../../utils/VideoOperateUtil' 5 | 6 | @Component 7 | export struct VideoTopComponent { 8 | @State videoTitle: string = '' 9 | @Link subTitleVisibility: Visibility 10 | @Link isAIAsrShown: boolean 11 | @Prop subtitleSelected: SubtitleMode 12 | @Link screenExtendSelectedText: string | undefined 13 | @Link screenWidth: number 14 | @Link screenHeight: number 15 | @BuilderParam audioTrackMenuBuilder: () => void 16 | @BuilderParam subtitlePanelComponent: () => void 17 | @Link lockRotation: boolean 18 | @Link playAreaHeight: number 19 | @Link playAreaWidth: number 20 | onLockRotationClick = async () => { 21 | } 22 | onExitVideoClick = () => { 23 | } 24 | 25 | build() { 26 | Row({ space: 8 }) { 27 | SymbolGlyph($r('sys.symbol.chevron_left')) 28 | .attributeModifier(new SymbolGlyphFancyModifier(40, '', '')) 29 | .fontColor(['#f0f0f0']) 30 | .onClick(() => { 31 | this.onExitVideoClick() 32 | }) 33 | .padding({ right: 5 }) 34 | Row() { 35 | Text(this.videoTitle.slice(0, this.videoTitle?.lastIndexOf('.'))) 36 | .fontSize(20) 37 | .fontWeight(FontWeight.Medium) 38 | .maxLines(1) 39 | .textOverflow({ overflow: TextOverflow.MARQUEE }) 40 | .fontColor($r('sys.color.white')) 41 | .layoutWeight(1) // 关键布局属性 42 | Row({ space: 12 }) { 43 | SymbolGlyph(this.subTitleVisibility === Visibility.Visible ? 44 | $r('sys.symbol.textformat_size_square_fill') : $r('sys.symbol.textformat_size_square')) 45 | .attributeModifier(new SymbolGlyphFancyModifier(30, '', '')) 46 | .fontColor(['#f0f0f0']) 47 | .bindMenu(this.subtitlePanelComponent, { backgroundBlurStyle: BlurStyle.COMPONENT_ULTRA_THIN }) 48 | if (this.screenWidth > this.screenHeight) { 49 | SymbolGlyph($r('sys.symbol.opticaldisc')) 50 | .attributeModifier(new SymbolGlyphFancyModifier(30, '', '')) 51 | .fontColor(['#f0f0f0']) 52 | .bindMenu(this.audioTrackMenuBuilder, 53 | { backgroundBlurStyle: BlurStyle.COMPONENT_ULTRA_THIN }) 54 | } 55 | if (this.screenWidth > this.screenHeight) { 56 | Select(VideoInfoUtil.screenExtendList) 57 | .font({ size: 18, weight: FontWeight.Medium }) 58 | .value(this.screenExtendSelectedText) 59 | .selected(VideoInfoUtil.screenExtendMap.indexOf(this.screenExtendSelectedText!)) 60 | .fontColor($r('sys.color.white')) 61 | .onSelect((_index: number, text?: string | undefined) => { 62 | this.screenExtendSelectedText = text 63 | this.screenExtend(text!) 64 | }) 65 | .menuBackgroundBlurStyle(BlurStyle.COMPONENT_ULTRA_THIN) 66 | } 67 | }.alignItems(VerticalAlign.Center) 68 | }.height('100%').layoutWeight(1) 69 | 70 | Button({ type: ButtonType.Circle, stateEffect: true }) { // 旋转锁定 71 | SymbolGlyph(this.lockRotation ? $r('sys.symbol.lock_fill') : $r('sys.symbol.rotate_left')) 72 | .attributeModifier(new SymbolGlyphFancyModifier(23, '', '')) 73 | .fontColor(['#f0f0f0']) 74 | } 75 | .attributeModifier(new ButtonFancyModifier(35, 40)) 76 | .backgroundColor('#50000000') 77 | .onClick(async () => { 78 | await this.onLockRotationClick() 79 | }) 80 | } 81 | .padding({ left: 8, right: 8 }) 82 | .width('100%') 83 | .alignItems(VerticalAlign.Center) 84 | } 85 | 86 | private screenExtend(text: string) { 87 | if (VideoOperateUtil.aspectRatioMap.has(text)) { 88 | const play_area_size = VideoOperateUtil.setVideoAspectRatio( 89 | VideoOperateUtil.aspectRatioMap.get(text)!, 90 | VideoInfoUtil.play_area_width, 91 | VideoInfoUtil.play_area_height 92 | ); 93 | this.playAreaWidth = play_area_size[0]; 94 | this.playAreaHeight = play_area_size[1]; 95 | return; 96 | } 97 | switch (text) { 98 | case VideoInfoUtil.screenExtendMap[0]: 99 | this.playAreaHeight = VideoInfoUtil.play_area_height; 100 | this.playAreaWidth = VideoInfoUtil.play_area_width; 101 | break; 102 | case VideoInfoUtil.screenExtendMap[1]: 103 | this.playAreaHeight = this.screenHeight; 104 | this.playAreaWidth = this.screenWidth; 105 | break; 106 | default: 107 | const scale = text ? VideoInfoUtil.scale_factors.get(text) : undefined; 108 | if (scale) { 109 | this.playAreaHeight = scale * VideoInfoUtil.play_area_height; 110 | this.playAreaWidth = scale * VideoInfoUtil.play_area_width; 111 | } 112 | break; 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/VideoSettingComponent.ets: -------------------------------------------------------------------------------- 1 | import { SymbolGlyphModifier } from '@kit.ArkUI' 2 | import { VideoMetadataFromPlayer } from '../../interfaces/VideoMetadataFromPlayerInterface' 3 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface' 4 | import { PathUtils } from '../../utils/PathUtils' 5 | import TimeUtil from '../../utils/TimeUtil' 6 | import ToolsUtil from '../../utils/ToolsUtil' 7 | import VideoOperateUtil from '../../utils/VideoOperateUtil' 8 | import { RepeatMode } from '../../common/enum/RepeatMode' 9 | import { deviceInfo } from '@kit.BasicServicesKit' 10 | 11 | @Component 12 | export struct VideoSettingComponent { 13 | @Prop playTime: number 14 | @Prop nowPlaying: VideoMetadataFromPlayer 15 | @Prop videoMetaData: VideoMetadata[] 16 | @Link angle: number 17 | @Link repeatMode: RepeatMode 18 | @Link exitVideoTime: number 19 | forward_80_s = (_time: number) => { 20 | } 21 | onActiveEnhanceAudio = () => { 22 | } 23 | closeAfter15min = (_time: number) => { 24 | } 25 | closeAfter30min = (_time: number) => { 26 | } 27 | closeAfter60min = (_time: number) => { 28 | } 29 | toggleListenVideo = () => { 30 | } 31 | captureScreen = () => { 32 | } 33 | 34 | build() { 35 | Menu() { 36 | MenuItem({ 37 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 38 | content: '定时器', 39 | builder: (): void => this.MenuExtend() 40 | }) 41 | MenuItem({ 42 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.time_lapse')), 43 | content: '设置片头时间' 44 | }).onClick(() => { 45 | VideoOperateUtil.saveVideoStartTime(this.playTime, this.nowPlaying, PathUtils.appContext!) 46 | ToolsUtil.showToast(this.nowPlaying.title + '成功设置片头时间为:' + TimeUtil.convertMSToHHMMSS(this.playTime)) 47 | }) 48 | MenuItem({ 49 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.time_lapse')), 50 | content: '设置片尾时间' 51 | }).onClick(() => { 52 | VideoOperateUtil.saveVideoEndTime(this.playTime, this.nowPlaying, PathUtils.appContext!) 53 | ToolsUtil.showToast(this.nowPlaying.title + '成功设置片尾时间为:' + TimeUtil.convertMSToHHMMSS(this.playTime)) 54 | }) 55 | MenuItem({ 56 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.camera')), 57 | content: '截图并保存' 58 | }).onClick(() => { 59 | this.captureScreen() 60 | }) 61 | if (deviceInfo.sdkApiVersion >= 20) { 62 | MenuItem({ 63 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.headphones')), 64 | content: '听视频' 65 | }).onClick(() => { 66 | this.toggleListenVideo() 67 | }) 68 | } 69 | MenuItem({ 70 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trapezoid_and_line_vertical')), 71 | content: '屏幕镜像' 72 | }).onClick(() => { 73 | this.angle === 0 ? this.angle = 180 : this.angle = 0 74 | }) 75 | MenuItem({ 76 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.fast_forward')), 77 | content: '前进 80s' 78 | }).onClick(() => { 79 | this.forward_80_s(this.playTime + 80 * 1000) 80 | }) 81 | MenuItem({ 82 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.service')), 83 | content: '启用增强型音频解码(仅在放不出来声音时使用)' 84 | }).onClick(() => { 85 | this.onActiveEnhanceAudio() 86 | }) 87 | } 88 | } 89 | 90 | @Builder 91 | MenuExtend() { 92 | Menu() { 93 | MenuItem({ 94 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 95 | content: '15min' 96 | }).onClick(() => { 97 | clearTimeout(this.exitVideoTime) 98 | ToolsUtil.showToast('设置成功,15分钟后自动关闭') 99 | this.closeAfter15min(15 * 60000) 100 | }) 101 | MenuItem({ 102 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 103 | content: '30min' 104 | }).onClick(() => { 105 | clearTimeout(this.exitVideoTime) 106 | ToolsUtil.showToast('设置成功,30分钟后自动关闭') 107 | this.closeAfter30min(30 * 60000) 108 | }) 109 | MenuItem({ 110 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 111 | content: '60min' 112 | }).onClick(() => { 113 | clearTimeout(this.exitVideoTime) 114 | ToolsUtil.showToast('设置成功,60分钟后自动关闭') 115 | this.closeAfter60min(60 * 60000) 116 | }) 117 | MenuItem({ 118 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 119 | content: '播完本集' 120 | }).onClick(() => { 121 | clearTimeout(this.exitVideoTime) 122 | ToolsUtil.showToast('设置成功,播完本集后自动关闭') 123 | this.repeatMode = RepeatMode.ONCE 124 | }) 125 | MenuItem({ 126 | symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.clock')), 127 | content: '不启用' 128 | }).onClick(() => { 129 | clearTimeout(this.exitVideoTime) 130 | ToolsUtil.showToast('设置成功,已清除定时器') 131 | }) 132 | } 133 | } 134 | } -------------------------------------------------------------------------------- /entry/src/main/resources/en_US/element/string.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": [ 3 | { 4 | "name": "module_desc", 5 | "value": "module description" 6 | }, 7 | { 8 | "name": "EntryAbility_desc", 9 | "value": "description" 10 | }, 11 | { 12 | "name": "EntryAbility_label", 13 | "value": "Sweet Video" 14 | }, 15 | { 16 | "name": "sure", 17 | "value": "Sure" 18 | }, 19 | { 20 | "name": "cancel", 21 | "value": "Cancel" 22 | }, 23 | { 24 | "name": "rename", 25 | "value": "Rename" 26 | }, 27 | { 28 | "name": "setting", 29 | "value": "Setting" 30 | }, 31 | { 32 | "name": "search", 33 | "value": "Search" 34 | }, 35 | { 36 | "name": "set_passwd", 37 | "value": "Set password" 38 | }, 39 | { 40 | "name": "enter_privacy_space", 41 | "value": "Entered privacy space" 42 | }, 43 | { 44 | "name": "set_passwd_tip", 45 | "value": "Password has been changed! Double-finger swipe in opposite directions on the home screen or Enter the password in the search box to enter the privacy space" 46 | }, 47 | { 48 | "name": "nothing", 49 | "value": "Nothing (~ ̄▽ ̄)~" 50 | }, 51 | { 52 | "name": "add_video_first", 53 | "value": "Add video first" 54 | }, 55 | { 56 | "name": "close_rotation_lock", 57 | "value": "Rotation lock closed" 58 | }, 59 | { 60 | "name": "open_rotation_lock", 61 | "value": "Rotation lock opened" 62 | }, 63 | { 64 | "name": "no_search_result", 65 | "value": "No search result" 66 | }, 67 | { 68 | "name": "unknown_size", 69 | "value": "Unknown size" 70 | }, 71 | { 72 | "name": "soft_del_confirm", 73 | "value": "Just delete the video import record? Confirm deletion?" 74 | }, 75 | { 76 | "name": "hard_del_confirm", 77 | "value": "After deletion, the selected files cannot be recovered. Confirm deletion?" 78 | }, 79 | { 80 | "name": "recent_del_confirm", 81 | "value": "After moving to the \"Recently Deleted\" folder in File Management, files will be retained for up to 30 days before being permanently deleted." 82 | }, 83 | { 84 | "name": "edit", 85 | "value": "Edit" 86 | }, 87 | { 88 | "name": "delete", 89 | "value": "Delete" 90 | }, 91 | { 92 | "name": "import_from_files", 93 | "value": "Import from files" 94 | }, 95 | { 96 | "name": "import_from_album", 97 | "value": "Import from albums" 98 | }, 99 | { 100 | "name": "open_side_bar_tip", 101 | "value": "Swipe left to add the video ヾ(•ω•`)o" 102 | }, 103 | { 104 | "name": "unknown_resolution", 105 | "value": "Unknown" 106 | }, 107 | { 108 | "name": "video_error", 109 | "value": "Video exception, try to play using FFMpeg player" 110 | }, 111 | { 112 | "name": "add_time_info", 113 | "value": "Adding videos success! Total number of videos: " 114 | }, 115 | { 116 | "name": "privacy_space", 117 | "value": "privacy space" 118 | }, 119 | { 120 | "name": "search_placeholder", 121 | "value": "Search Title..." 122 | }, 123 | { 124 | "name": "sort_by_name", 125 | "value": "By name" 126 | }, 127 | { 128 | "name": "sort_by_time", 129 | "value": "By add time" 130 | }, 131 | { 132 | "name": "multiple_choice", 133 | "value": "Multiple choice" 134 | }, 135 | { 136 | "name": "select_all", 137 | "value": "Select All" 138 | }, 139 | { 140 | "name": "delete_selected", 141 | "value": "Delete Selected" 142 | }, 143 | { 144 | "name": "FFMpeg_Player", 145 | "value": "FFMpeg Player" 146 | }, 147 | { 148 | "name": "re_import_info", 149 | "value": "Please re-import if it has been invalidated" 150 | }, 151 | { 152 | "name": "exit_privacy_space", 153 | "value": "Exit privacy space" 154 | }, 155 | { 156 | "name": "privacy_space_info", 157 | "value": "Here the imported video will be copied to the app's private directory" 158 | }, 159 | { 160 | "name": "FILE_ACCESS_PERSIST_REASON", 161 | "value": "Save the file selected by the user to avoid repeated operations" 162 | }, 163 | { 164 | "name": "KEEP_BACKGROUND_RUNNING_REASON", 165 | "value": "Continues playing audio when the user exits to the background" 166 | }, 167 | { 168 | "name": "VIBRATE_REASON", 169 | "value": "Adding tactile feedback to improve user experience" 170 | }, 171 | { 172 | "name": "BIOMETRIC_ACCESS_REASON", 173 | "value": "Used for privacy space validation" 174 | }, 175 | { 176 | "name": "move_to_folder", 177 | "value": "Move to folder" 178 | }, 179 | { 180 | "name": "READ_WRITE_DOWNLOAD_DIRECTORY_REASON", 181 | "value": "read videos from download" 182 | }, 183 | { 184 | "name": "sort_by_datetime", 185 | "value": "sort by add datetime" 186 | } 187 | ] 188 | } -------------------------------------------------------------------------------- /entry/src/main/resources/base/element/string.json: -------------------------------------------------------------------------------- 1 | { 2 | "string": [ 3 | { 4 | "name": "module_desc", 5 | "value": "module description" 6 | }, 7 | { 8 | "name": "EntryAbility_desc", 9 | "value": "description" 10 | }, 11 | { 12 | "name": "EntryAbility_label", 13 | "value": "流心播放器" 14 | }, 15 | { 16 | "name": "sure", 17 | "value": "Sure" 18 | }, 19 | { 20 | "name": "cancel", 21 | "value": "Cancel" 22 | }, 23 | { 24 | "name": "rename", 25 | "value": "Rename" 26 | }, 27 | { 28 | "name": "setting", 29 | "value": "Setting" 30 | }, 31 | { 32 | "name": "search", 33 | "value": "Search" 34 | }, 35 | { 36 | "name": "set_passwd", 37 | "value": "Set password" 38 | }, 39 | { 40 | "name": "enter_privacy_space", 41 | "value": "Entered privacy space" 42 | }, 43 | { 44 | "name": "set_passwd_tip", 45 | "value": "Password has been changed! Double-finger swipe in opposite directions on the home screen or Enter the password in the search box to enter the privacy space" 46 | }, 47 | { 48 | "name": "nothing", 49 | "value": "Nothing." 50 | }, 51 | { 52 | "name": "add_video_first", 53 | "value": "add video first" 54 | }, 55 | { 56 | "name": "open_rotation_lock", 57 | "value": "rotation lock opened" 58 | }, 59 | { 60 | "name": "close_rotation_lock", 61 | "value": "rotation lock closed" 62 | }, 63 | { 64 | "name": "no_search_result", 65 | "value": "no search result" 66 | }, 67 | { 68 | "name": "unknown_size", 69 | "value": "unknown size" 70 | }, 71 | { 72 | "name": "soft_del_confirm", 73 | "value": "Just delete the video import record? Confirm deletion?" 74 | }, 75 | { 76 | "name": "hard_del_confirm", 77 | "value": "After deletion, the selected files cannot be recovered. Confirm deletion?" 78 | }, 79 | { 80 | "name": "recent_del_confirm", 81 | "value": "After moving to the \"Recently Deleted\" folder in File Management, files will be retained for up to 30 days before being permanently deleted." 82 | }, 83 | { 84 | "name": "edit", 85 | "value": "Edit" 86 | }, 87 | { 88 | "name": "delete", 89 | "value": "Delete" 90 | }, 91 | { 92 | "name": "import_from_files", 93 | "value": "Import from files" 94 | }, 95 | { 96 | "name": "import_from_album", 97 | "value": "Import from albums" 98 | }, 99 | { 100 | "name": "open_side_bar_tip", 101 | "value": "Swipe left or click the button in the top left corner to add the first video!" 102 | }, 103 | { 104 | "name": "unknown_resolution", 105 | "value": "Unknown" 106 | }, 107 | { 108 | "name": "video_error", 109 | "value": "Video exception, try to play using Another player" 110 | }, 111 | { 112 | "name": "add_time_info", 113 | "value": "Adding videos, please wait ~" 114 | }, 115 | { 116 | "name": "privacy_space", 117 | "value": "privacy space" 118 | }, 119 | { 120 | "name": "search_placeholder", 121 | "value": "Search Title..." 122 | }, 123 | { 124 | "name": "sort_by_name", 125 | "value": "Sort by name" 126 | }, 127 | { 128 | "name": "sort_by_time", 129 | "value": "Sort by add time" 130 | }, 131 | { 132 | "name": "multiple_choice", 133 | "value": "Multiple choice" 134 | }, 135 | { 136 | "name": "select_all", 137 | "value": "Select All" 138 | }, 139 | { 140 | "name": "delete_selected", 141 | "value": "Delete Selected" 142 | }, 143 | { 144 | "name": "FFMpeg_Player", 145 | "value": "FFMpeg Player" 146 | }, 147 | { 148 | "name": "re_import_info", 149 | "value": "Please re-import if it has been invalidated" 150 | }, 151 | { 152 | "name": "exit_privacy_space", 153 | "value": "Exit privacy space" 154 | }, 155 | { 156 | "name": "privacy_space_info", 157 | "value": "Long press the privacy space button to reset the password \n and enter the password in the input box to access the privacy space." 158 | }, 159 | { 160 | "name": "FILE_ACCESS_PERSIST_REASON", 161 | "value": "Save the file selected by the user to avoid repeated operations" 162 | }, 163 | { 164 | "name": "KEEP_BACKGROUND_RUNNING_REASON", 165 | "value": "Continues playing audio when the user exits to the background" 166 | }, 167 | { 168 | "name": "VIBRATE_REASON", 169 | "value": "Adding tactile feedback to improve user experience" 170 | }, 171 | { 172 | "name": "BIOMETRIC_ACCESS_REASON", 173 | "value": "Used for privacy space validation" 174 | }, 175 | { 176 | "name": "move_to_folder", 177 | "value": "Move to folder" 178 | }, 179 | { 180 | "name": "READ_WRITE_DOWNLOAD_DIRECTORY_REASON", 181 | "value": "read videos from download" 182 | }, 183 | { 184 | "name": "sort_by_datetime", 185 | "value": "sort by add datetime" 186 | } 187 | ] 188 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

logo

2 |
3 |

流心播放器

4 |

5 | 6 | GitHub release (latest by date) 7 | 8 | 9 | HarmonyOS Version 10 | 11 | 12 | API Version 13 | 14 | 15 | GitHub all stars 16 | 17 | 18 | QQ Group 19 | 20 | 21 | GitHub license 22 | 23 | 24 | GitHub fork 25 | 26 | Ask DeepWiki 27 | 28 |

29 |
30 |

一款运行在 HarmonyOS Next 上的精致、简约的原生视频播放器

31 |

A slick, minimalist video player that runs on HarmonyOS Next

32 | 33 | ## 流心播放器交流群 34 | 35 | QQ群:973792610 36 | 37 | ## 欢迎安装流心播放器 38 | 39 | **流心播放器已经上架华为应用市场** 40 | 41 | - [安装链接](https://appgallery.huawei.com/app/detail?id=com.example.sweetvideo&channelId=SHARE&source=appshare) 42 | 43 | ## 功能排期 44 | 45 | - [ ] FFMpeg播放器选集功能 46 | - [x] 完全的字幕功能支持(ASS、SRT)【已经支持外挂】 47 | - [ ] 视频标签(点击标签即可跳转该视频对应时间) 48 | 49 | ### 远期支持 50 | 51 | - [ ] 播放器移植 52 | - [ ] WebDAV 支持 53 | - [ ] Emby 支持 54 | - [ ] FTP 支持 55 | 56 | ## 简介 57 | 58 | - 一款运行在 HarmonyOS Next 上精致、简约的视频(音乐)原生播放器,使用 ArkTS 语言开发,具有美观的设计和优雅的动画。 59 | - 基于开源项目`流心播放器` https://gitee.com/lqsxy/sweetvideo/tree/master 60 | - 本应用根据原作者授权并基于 MIT 协议二次开发而来。 61 | 62 | ### 流心项目 AI 解读 63 | 64 | - https://deepwiki.com/Yebingiscn/SweetVideo 65 | (英文,可以精准提问) 66 | - https://zread.ai/Yebingiscn/SweetVideo 67 | (中文,只能简单提问(需登录)) 68 | 69 | ## 内置播放器 70 | 71 | | 系统播放器(avplayer) | FFMpeg播放器(ijkplayer) | 红薯播放器(REDPlayer) | 72 | |-------------------------|----------------------|-------------------------| 73 | | 支持格式较少,不支持杜比/DTS 音轨 | 占用高,容易闪退 | 格式支持较少 | 74 | | 支持 HDR/Audio Vivid,流畅省电 | 支持格式丰富,支持rmvb格式 | 流畅播放 4K HDR 杜比 / DTS 视频 | 75 | 76 | ## 支持的视频 / 音乐格式 77 | 78 | | 类型 | 格式列表 | 79 | |------------|-------------------------------------------------------------------------------------------------------| 80 | | 视频容器 | `mp4`, `flv`, `mkv`, `ts`, `mov`, `rmvb`, `wmv`, `avi`, `m4v` | 81 | | 音频编码(音乐格式) | `wav`, `mp3`, `flac`, `m4a`, `ape`, `aac`, `ogg`, `amr`, `aif`, `aiff` , `dts`, `wma`, `dff`, `av3a` | 82 | 83 | ## 支持的字幕格式 84 | 85 | | 类型 | 格式列表 | 86 | |------------|----------------------------| 87 | | 外挂字幕(标准格式) | `srt`, `vtt`, `ass` | 88 | | 内嵌字幕 | mkv 格式为 txt 的字幕,只支持读取第一条字幕 | 89 | | AI 字幕 | 需鸿蒙 6 及以上 | 90 | 91 | ## 特别鸣谢 && 欢迎参与共建及须知 92 | 93 | > 欢迎提交 PR,一起共同建设流心播放器 \ 94 | > 提交 PR 请遵照以往提交格式,如`[fix]`是修复内容 `[update]`是优化内容 `[new]`是新增内容 \ 95 | > 如果提交中有没有解决的地方请注明 96 | 97 | - 流心视频开源项目作者:鱼Salmon 98 | - 图标、头图等素材:科蓝kl 99 | - 记账本 R 作者:漫漫是我宝 100 | - 测试视频提供:萧十一狼 101 | - 折叠屏适配:微车游 102 | - AloePlayer 作者:Aloereed 103 | - 浑天编辑器作者:向着星辰与深渊 ⭐︎ 104 | - kimufly 105 | - Fpark 106 | 107 | ## 流心播放器由以下开源项目或开源组件提供支持 108 | 109 | - 播放器R 110 | - 浑天编辑器 111 | - Seline Bili 112 | - 蓝牙调试器(BluetoothDebugger) 113 | - [流心视频](https://gitee.com/lqsxy/sweetvideo/tree/master) 114 | - [pinyin4js](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fpinyin4js) 115 | - [ohos_ijkplayer](https://gitee.com/openharmony-sig/ohos_ijkplayer/tree/master) 116 | - [REDPlayer](https://github.com/RTE-Dev/REDPlayer) 117 | - [subtitles](https://atomgit.com/wysp2012/ohos_napi/) 118 | - [juniversalchardet](https://ohpm.openharmony.cn/#/cn/detail/@ohos%2Fjuniversalchardet) 119 | 120 | ## 赞助作者 121 | 122 | 流心播放器是我最重要的一个项目,我希望无论我以后处在什么样的境地下都能坚持维护他,把他变得更好、更强大。 \ 123 | 但是不可避免的我们生活的其他部分依然有波折,所以如果你喜欢流心的话,可以请作者喝杯咖啡或者吃碗泡面~ \ 124 | [爱发电](https://www.ifdian.net/a/SweetVideo) 125 | 126 | ### 赞助感谢 127 | 128 | - CowBoy 129 | - 羽 130 | - 无名 131 | - 爱发电用户_NhwD 132 | - 爱发电用户_PbvN 133 | - 歪比歪比 134 | - 爱发电用户_e83w 135 | - 爱发电用户_15800 136 | - 爱发电用户_6092b -------------------------------------------------------------------------------- /entry/src/main/ets/component/PlayerComponent/PlayerSideBarComponent.ets: -------------------------------------------------------------------------------- 1 | import { VideoMetadataFromPlayer } from '../../interfaces/VideoMetadataFromPlayerInterface' 2 | import { VideoMetadata } from '../../interfaces/VideoMetadataInterface' 3 | import { fileUri } from '@kit.CoreFileKit' 4 | import { PathUtils } from '../../utils/PathUtils' 5 | import { ImageFancyModifier } from '../../common/AttributeModifierConfig' 6 | import { PlayerSideBarSource } from '../../utils/DataUtil' 7 | import VideoInfoUtil from '../../utils/VideoInfoUtil' 8 | import { VideoInfoBuilder } from '../VideoItemComponent/VideoInfoBuilder' 9 | import { XAnimation } from '../../utils/AnimationUtil' 10 | import { SafeHeight } from '../../utils/ObservedUtil' 11 | 12 | @Component 13 | export struct PlayerSideBarComponent { 14 | @State playerSideBarSource: PlayerSideBarSource = new PlayerSideBarSource([]) 15 | @Link @Watch('onSideBarVisibleChange') sideBarStatusTmp: Visibility 16 | @State video_meta_data: VideoMetadata[] = [] 17 | @Prop now_playing: VideoMetadataFromPlayer | undefined 18 | @Link sideBarStatus: boolean 19 | private listScroller: Scroller = new Scroller(); 20 | onItemClick = (_item: VideoMetadata) => { 21 | } 22 | 23 | aboutToAppear(): void { 24 | this.playerSideBarSource = new PlayerSideBarSource(this.video_meta_data) 25 | } 26 | 27 | build() { 28 | List({ scroller: this.listScroller, space: 10 }) { 29 | LazyForEach(this.playerSideBarSource, (item: VideoMetadata, index: number) => { 30 | ListItem() { 31 | VideoItemBuilder({ 32 | item: item, 33 | now_playing: this.now_playing 34 | }) 35 | } 36 | .padding({ top: index === 0 ? 10 : 0 }) 37 | .borderRadius(16) 38 | .onClick(() => { 39 | if (this.sideBarStatusTmp === Visibility.Hidden) { 40 | return 41 | } 42 | this.now_playing?.date === item.date ? this.closeSideBar() : this.onItemClick(item) 43 | }) 44 | .width('100%') 45 | .height('auto') 46 | .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.9 }) 47 | }, (item: VideoMetadata) => item.date) 48 | } 49 | .clip(false) 50 | .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) // 滚动边缘效果 51 | .chainAnimation(true) 52 | .scrollBar(BarState.Off) 53 | .onAppear(() => { 54 | this.onSideBarVisibleChange() 55 | }) 56 | .gesture(SwipeGesture({ direction: SwipeDirection.Horizontal }).onAction((event: GestureEvent) => { 57 | if (event) { 58 | this.closeSideBar() 59 | } 60 | })) 61 | .visibility(this.sideBarStatusTmp) 62 | .width('100%') 63 | .height('100%') 64 | .backgroundColor($r('app.color.start_window_background')) 65 | .padding({ 66 | left: 10, 67 | right: 5, 68 | top: SafeHeight.topSafeHeight, 69 | }) 70 | .transition(TransitionEffect.translate({ x: 300 }).animation(XAnimation.getAnimationCurve())) 71 | .borderRadius(16) 72 | .cachedCount(6) 73 | } 74 | 75 | onSideBarVisibleChange() { 76 | if (this.sideBarStatusTmp === Visibility.Visible) { 77 | setTimeout(() => { 78 | const targetIndex = this.video_meta_data.findIndex( 79 | item => item?.date === this.now_playing?.date 80 | ) 81 | if (targetIndex >= 0) { 82 | this.listScroller.scrollToIndex(targetIndex, false); 83 | } 84 | }, 50) 85 | } 86 | } 87 | 88 | private closeSideBar() { 89 | this.sideBarStatusTmp = Visibility.Hidden 90 | setTimeout(() => { 91 | this.sideBarStatus = false 92 | }, 250) 93 | } 94 | } 95 | 96 | class VideoItemValue { 97 | item: VideoMetadata | undefined = undefined 98 | now_playing?: VideoMetadataFromPlayer | undefined = undefined 99 | longPressPreview?: boolean = false 100 | } 101 | 102 | @Builder 103 | export function VideoItemBuilder(videoItemValue: VideoItemValue) { 104 | Row() { 105 | Image(fileUri.getUriFromPath(PathUtils.coverPath + videoItemValue.item?.date)) 106 | .attributeModifier(new ImageFancyModifier(16, 80, 120)) 107 | Column() { 108 | Row() { 109 | Text(VideoInfoUtil.getVideoTitle(videoItemValue.item!)) 110 | .fontSize(16) 111 | .fontWeight(FontWeight.Bold) 112 | .maxLines(4) 113 | .textOverflow({ overflow: TextOverflow.Ellipsis }) 114 | .width('100%') 115 | .margin({ left: 5 }) 116 | .wordBreak(WordBreak.BREAK_ALL) 117 | .fontColor(videoItemValue.now_playing && videoItemValue.now_playing?.date === videoItemValue.item?.date ? 118 | $r('sys.color.white') : $r('app.color.text_color')) 119 | } 120 | 121 | VideoInfoBuilder({ item: videoItemValue.item, showProgress: false, nowPlaying: videoItemValue.now_playing }) 122 | }.alignItems(HorizontalAlign.Start) 123 | .padding({ left: 15, right: 15 }) 124 | .layoutWeight(1) 125 | } 126 | .backgroundColor( 127 | videoItemValue.longPressPreview 128 | ? $r('app.color.start_window_background_blur') 129 | : videoItemValue.now_playing && videoItemValue.now_playing?.date === videoItemValue.item?.date 130 | ? $r('app.color.main_color') 131 | : $r('app.color.start_window_background') 132 | ) 133 | .justifyContent(FlexAlign.Center) 134 | .width('100%') 135 | .borderRadius(16) 136 | } -------------------------------------------------------------------------------- /entry/src/main/ets/utils/PermissionUtil.ets: -------------------------------------------------------------------------------- 1 | import { fileShare, fileIo as fs } from '@kit.CoreFileKit'; 2 | import { abilityAccessCtrl, common, Permissions, wantAgent } from '@kit.AbilityKit'; 3 | import { backgroundTaskManager } from '@kit.BackgroundTasksKit'; 4 | import { BusinessError } from '@kit.BasicServicesKit'; 5 | import { hilog } from '@kit.PerformanceAnalysisKit'; 6 | import { PathUtils } from './PathUtils'; 7 | import AVSessionUtil from './AVSessionUtil'; 8 | import { Setting } from './ObservedUtil'; 9 | import json from '@ohos.util.json'; 10 | 11 | //权限类 12 | export default class PermissionUtil { 13 | public static permissions: Permissions[] = ['ohos.permission.FILE_ACCESS_PERSIST', 'ohos.permission.VIBRATE']; 14 | 15 | static async activatePermission(uri: string | undefined): Promise { 16 | if (!uri) { 17 | return false; 18 | } 19 | try { 20 | // 首先尝试直接访问 21 | let fd = fs.openSync(uri); 22 | fs.closeSync(fd); 23 | return true; 24 | } catch (error) { 25 | // 直接访问失败,检查是否支持权限管理 26 | if (!canIUse('SystemCapability.FileManagement.AppFileService.FolderAuthorization')) { 27 | return false; 28 | } 29 | try { 30 | const policyInfo: fileShare.PolicyInfo = { 31 | uri: uri, 32 | operationMode: fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE, 33 | }; 34 | const policies: fileShare.PolicyInfo[] = [policyInfo]; 35 | const results = await fileShare.checkPersistentPermission(policies); 36 | const permissionsToActivate = results 37 | .map((needActivate, index) => needActivate ? policies[index] : null) 38 | .filter(Boolean); 39 | if (permissionsToActivate.length > 0) { 40 | await fileShare.activatePermission(permissionsToActivate) 41 | } 42 | // 验证权限是否真正生效 43 | let fd = fs.openSync(uri); 44 | fs.closeSync(fd); 45 | return true; 46 | } catch (error) { 47 | console.error('Permission activation failed:', error); 48 | return false; 49 | } 50 | } 51 | } 52 | 53 | static applyContinuousTask() { 54 | if (Setting.allowBackgroundPlay) { 55 | PermissionUtil.startContinuousTask() 56 | } 57 | } 58 | 59 | static applyStopContinuousTask() { 60 | if (Setting.allowBackgroundPlay) { //若发生错误跳转FFMpeg播放器,确保提前关闭申请防止两个播放器重复申请导致失败 61 | AVSessionUtil.destroySession() 62 | PermissionUtil.stopContinuousTask() 63 | } 64 | } 65 | 66 | static async startContinuousTask() { 67 | if (canIUse('SystemCapability.BundleManager.BundleFramework.Core')) { 68 | let wantAgentInfo: wantAgent.WantAgentInfo = { 69 | wants: [ 70 | ({ 71 | bundleName: PathUtils.appContext!.abilityInfo.bundleName, 72 | abilityName: "EntryAbility" 73 | }) 74 | ], 75 | actionType: wantAgent.OperationType.START_ABILITY, 76 | requestCode: 114514, 77 | actionFlags: [wantAgent.WantAgentFlags.UPDATE_PRESENT_FLAG] 78 | }; 79 | wantAgent.getWantAgent(wantAgentInfo).then((wantAgentObj: Object) => { 80 | try { 81 | backgroundTaskManager.startBackgroundRunning(PathUtils.appContext!, 82 | backgroundTaskManager.BackgroundMode.AUDIO_PLAYBACK, wantAgentObj).then(() => { 83 | })?.catch((error: BusinessError) => { 84 | hilog.error(777, 'testFlag', 'background play failed BusinessError: ' + json.stringify(error)) 85 | }); 86 | } catch (error) { 87 | hilog.error(777, 'testFlag', 'background play failed: ' + error) 88 | } 89 | })?.catch(() => { 90 | console.error('getWantAgent error') 91 | }) 92 | } 93 | } 94 | 95 | static stopContinuousTask() { 96 | backgroundTaskManager.stopBackgroundRunning(PathUtils.appContext!)?.catch(() => { 97 | console.error('stopBackgroundRunning error') 98 | }) 99 | } 100 | 101 | static async persistPermission(uri: string): Promise { 102 | try { 103 | if (canIUse('SystemCapability.FileManagement.AppFileService.FolderAuthorization')) { 104 | let policyInfo: fileShare.PolicyInfo = { 105 | uri: uri, 106 | operationMode: fileShare.OperationMode.READ_MODE | fileShare.OperationMode.WRITE_MODE, 107 | }; 108 | let policies: Array = [policyInfo]; 109 | fileShare.persistPermission(policies)?.catch(() => { 110 | console.error('persistPermission error') 111 | }) 112 | let fd = await fs.open(uri); 113 | await fs.close(fd); 114 | } 115 | } catch (error) { 116 | return true 117 | } 118 | return false 119 | } 120 | 121 | // 重新申请权限 122 | static reqPermissionsFromUser(permissions: Permissions[], context: common.UIAbilityContext): void { 123 | let atManager: abilityAccessCtrl.AtManager = abilityAccessCtrl.createAtManager(); 124 | atManager.requestPermissionsFromUser(context, permissions).then((data) => { 125 | let grantStatus: number[] = data.authResults; 126 | let length: number = grantStatus.length; 127 | for (let i = 0; i < length; i++) { 128 | if (grantStatus[i] === 0) { 129 | } else { 130 | return; 131 | } 132 | } 133 | })?.catch(() => { 134 | console.error('requestPermissionsFromUser error') 135 | }) 136 | } 137 | } 138 | --------------------------------------------------------------------------------